Compare commits

..

492 Commits

Author SHA1 Message Date
bryanthaboi 6cd8f0ddea Merge pull request #1487 from bryanthaboi/dev
[release 0.2.0]
2026-08-17 13:10:08 -04:00
bryanthaboi 7e25da70f0 CLOSES #1467 2026-08-17 13:06:09 -04:00
bryanthaboi df3d3e7600 [release 0.2.0] 2026-08-17 10:35:40 -04:00
bryanthaboi 8c9af95598 CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1444, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1461, CLOSES #1464, CLOSES #1465, CLOSES #1466, CLOSES #1468, CLOSES #1469, CLOSES #1470 2026-08-17 10:27:14 -04:00
bryanthaboi 45519ad550 CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1464, CLOSES #1465, CLOSES #1468, CLOSES #1469, CLOSES #1470 2026-08-17 10:15:06 -04:00
bryanthaboi 3cca70608f skins and skin studio 2026-08-17 06:47:33 -04:00
github-actions fb4eaeda10 chore(ios): update app-repo.json [skip ci] 2026-08-16 22:37:21 -04:00
bryanthaboi 69100301a1 Merge pull request #1460 from bryanthaboi/dev 2026-08-16 22:28:20 -04:00
bryanthaboi a5b674f9da Merge pull request #1454 from ShaneMcGovernIE/feat/mod-compute-permission 2026-08-16 22:05:10 -04:00
bryanthaboi 4356b94483 Merge pull request #1404 from emre155/fix/save-editor-cycle-move-safety 2026-08-16 22:04:49 -04:00
bryanthaboi c8bd205d0c Merge pull request #1378 from syybott/experiment/fixed-extended-world-alignment 2026-08-16 22:04:28 -04:00
bryanthaboi 995444774b Merge pull request #1458 from AverageConsumer/codex/android-asymmetric-display-routing 2026-08-16 22:00:33 -04:00
AverageConsumer 360b692963 android: route asymmetric companion displays 2026-08-17 03:00:05 +02:00
bryanthaboi 051040371f Merge pull request #1439 from thibautbus/fix/stat-rise-message-translation 2026-08-16 20:43:54 -04:00
bryanthaboi c5ff95edcf Merge pull request #1450 from thibautbus/fix/translate-clock-and-day-of-week 2026-08-16 20:43:36 -04:00
bryanthaboi d6627eda4c Merge pull request #1426 from thibautbus/fix/manager-state-draw-color 2026-08-16 20:36:13 -04:00
bryanthaboi 6e28cd5dca Merge pull request #1434 from sanjinpepic/upstream-fixes 2026-08-16 20:34:55 -04:00
bryanthaboi 08ddb882af Merge pull request #1448 from AverageConsumer/codex/android-companion-contract 2026-08-16 20:33:22 -04:00
bryanthaboi 2f6f094559 Merge pull request #1446 from 1Jamie/feat/launcher-fixes-and-patch-notes 2026-08-16 20:33:11 -04:00
bryanthaboi 6ac425144d Merge pull request #1436 from ShaneMcGovernIE/feat/android-post-bridge 2026-08-16 20:32:47 -04:00
James Hall 73f561e256 Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment 2026-08-16 19:30:19 -05:00
Shane McGovern b739fa76c0 mods: gate love.thread behind a compute permission
Threads open a fresh Lua state with a full standard library, so they
stayed blocked wholesale. PotatoVoxel's prebuilder wants to run its
pure geometry phase on worker threads; the mod declares the new
"compute" permission and the sandbox hands out love.thread only
then. The worker runs the mod's own source (source-only, like every
mod file) and receives data only through channels.
2026-08-16 23:45:09 +01:00
Shane McGovern 0b00faf38e feat(android): add httpPost bridge for mod.postLog log sends
Android ships no curl and the JNI bridge was GET-only, so mod.postLog
failed there with 'no POST transport on this platform' (HostShell.lua).
Add the mirror of httpDownload: GameActivity.httpPost (https-only,
hand-followed redirects re-POSTing the body, one-way), the JNI bridge
with the same old-APK-skew tolerance, the love.system.httpPost binding,
and the HostShell arm that rides it when curl is absent. The body
crosses the JNI as raw bytes (jbyteArray) so a log ring with arbitrary
UTF-8 cannot corrupt through modified-UTF-8 jstring conversion.
2026-08-16 22:22:38 +01:00
thibautbus b20b1370ab Reset ManagerState's draw color after Font.drawBox
Font.drawBox leaves the caller's color at white; every other screen
resets to black right after calling it, but ManagerState.lua's draw()
and drawOverlay() never did. That's invisible on the vanilla tile font
(tile glyphs are black-on-transparent regardless of color) but renders
fully invisible white-on-white text once a mod's TTF font is active.
2026-08-16 23:15:59 +02:00
thibautbus 467566c799 Cover Gold's Light Screen / Reflect fix in the same test
The targeted test only covered the RBY side (ItemEffects.lua,
TrainerAI.lua); src/battle/gen2/Battle.lua's EFFECT_LIGHT_SCREEN/
EFFECT_REFLECT fix had no test at all, spotted when asked whether the
Gold changes were covered.

Adds a minimal MACHOP/TACKLE gen2 fixture (same shape as
tests/gen2_move_effects_test.lua's), calls both MOVE_EFFECTS directly,
and checks a loaded catalog reaches the whole message template (Gold
wraps the full sentence, not just the stat name). Confirmed it catches
the regression: reverting Battle.lua to its pre-fix state fails 2 of
the suite's now 8 checks.
2026-08-16 23:15:18 +02:00
thibautbus d6ddf23f97 Add targeted coverage for the stat-rise message translation fix
No existing test could tell a translated stat name apart from a raw
stat:upper() that never went through Strings() at all: every rose!
message assertion in the suite runs with no catalog loaded, where
Strings() is an identity function either way.

Loads a real catalog (Strings.load) that translates one stat name at a
time and checks it actually reaches the X-item, vitamin, and AI-trainer
X-item messages -- ROM-free, over tests/fixture_data. Confirmed this
catches the regression: reverting src/inventory/ItemEffects.lua and
src/battle/TrainerAI.lua to their pre-fix state fails 5 of 6 checks.
2026-08-16 23:15:18 +02:00
thibautbus 9423337bcc Make the rose! messages' stat name harvestable by the mod catalog tool
Strings(stat:upper()) is a dynamic argument -- tools/modkit.py's
STRINGS_CALL harvester only matches a literal string right after
Strings(/Strings.source(, so it can't discover "ATTACK"/"DEFENSE"/etc.
from these call sites. Translation coverage happened to still work
only because the same literals are independently harvested from
unrelated call sites (MoveEffects.lua's STAT_LABEL, BattleState.lua's
literal Strings("ATTACK") calls) -- real but fragile, found in review.

Reuse the codebase's existing pattern for exactly this situation
(MoveEffects.lua's STAT_LABEL): a local table built at require time
with Strings.source(...), which the harvester can see, resolved to a
translated label at use time with Strings(TABLE[key]). Adds one such
table to ItemEffects.lua (covering its X-item and vitamin call sites,
including "hp") and one to TrainerAI.lua.
2026-08-16 23:15:18 +02:00
thibautbus c280119d03 Translate Gold's Light Screen / Reflect rose! messages
Same theme as the RBY fix, found while checking whether Gold had the
same gap: EFFECT_LIGHT_SCREEN and EFFECT_REFLECT built their "'s
SPCL.DEF/DEFENSE rose!" message by raw string concatenation, bypassing
Strings() entirely -- unlike most other messages in this file (e.g.
"%s\nused %s!" a few lines up), which already go through it.

Wrap the whole message template in Strings(), matching that existing
pattern; the substituted name still comes from monName() as before.

Gold's gen2/Battle.lua has many more messages built the same
unwrapped way (fainted!, learned..., missed!, and so on) -- that is
the much larger "Battle messages" gap already tracked separately and
deliberately left out of this change.
2026-08-16 23:15:18 +02:00
thibautbus 24d0c6528d Translate the stat name in RBY's X-item and vitamin rose! messages
Both the player-side and AI-trainer stat-rise messages (X ATTACK/
DEFENSE/etc. and the vitamins) passed the raised stat's name as a raw
uppercase Lua string (stat:upper()), bypassing Strings() entirely, so
it always rendered in English regardless of the active language even
though the surrounding sentence template was already translated.

Wrap the substituted stat name in Strings() at every call site
(src/inventory/ItemEffects.lua's two player-side messages and
src/battle/TrainerAI.lua's AI-trainer X-item message, found in
review), reusing the same "ATTACK"/"DEFENSE"/"SPEED"/"SPECIAL"/"HP"
keys SummaryMenu.lua's stat labels already look up the same way.
2026-08-16 23:15:18 +02:00
AverageConsumer 1fc34da2a9 android: extend secondary display presentation 2026-08-16 22:57:10 +02:00
sanjinpepic 70b9def0b0 Make Mon.syncIdentity's shiny recompute monotonic
syncIdentity unconditionally recomputed mon.shiny from the mon's DVs,
overwriting whatever was there. It is wired into refreshStats, which
SummaryMenu.new calls on every menu open, so a forced shiny -- Mon.new's
opts.shiny path, DVs that do not themselves read as shiny -- got
un-shinied the moment the summary screen opened, even though opts.shiny
already wins over shiny.roll at construction for exactly this case (a
scripted shiny is the cart overriding the roll, not a roll to be
hooked).

mon.shiny now only ever gets PROMOTED by the DV check, never demoted:
`mon.shiny or Mon.isShiny(...)`. A naturally shiny mon and a plain one
are unaffected -- the DV check still runs and still decides the first
time -- and a mon whose DVs are edited to justify shininess later still
promotes normally; only an already-true shiny stops being able to flip
back to false on a later refresh.
2026-08-16 22:48:34 +02:00
sanjinpepic d03d2af5f8 Pass data through to ItemEffects.partyAction for Gen 2 pack items
Both call sites -- Game2:usePartyItem (the field pack) and
BattleState:useItem (the battle pack) -- asked ItemEffects.partyAction
for an item's family with no `data` argument, even though every other
call in the same functions (useOnMon, usePpItem, applyPartyItem) passed
it through correctly. partyAction resolves through recordFor, which
reads data.gen2ItemEffects when given a dataset and falls back to the
module's own built-in RECORDS table when not -- so with no data, a
mod's own item_effects record was invisible and every mod-defined Gen 2
field or battle item resolved to a nil action, falling straight through
to "isn't going to help here" / "isn't going to help here" without ever
opening the party picker.

Both now pass the live dataset (self.data on Game2, self.game.data on
the battle screen) the same way their sibling calls already did.
2026-08-16 22:48:34 +02:00
sanjinpepic c23f85cba9 Bind gen2Constants in the save editor's Gold bootstrap
bindGoldData points gen2Palettes, gen2Icons, gen2Pokedex, gen2Landmarks,
gen2Roofs and gen2Sprites at the extractor's own Gold tables through
loadGen, but never gen2Constants -- despite Schemas.GEN2 routing
`constants` to that same namespaced-and-differently-shaped category
palettes and icons are in. A save editor boot left data.gen2Constants
unset, so mod.content.constants:get(...) read an empty table instead
of the cart's ordered name lists, misreading the generation and
rejecting every record a mod shaped off it.

data.gen2Constants now goes through the same loadGen("constants") path
the other five already use, falling back the same way they do when no
ROM cache is active.
2026-08-16 22:48:33 +02:00
sanjinpepic 455ff21aff Validate a map object's pokemon field against the pokemon registry
R.maps.objects was f.opt(f.list(f.any)): a static wild encounter's
species (OverworldController.lua's d.pokemon, handed straight to
BattleState.newWild) went completely unchecked at load time, unlike an
encounter slot's species. A typo'd or removed id sat in a loaded mod
and only surfaced as a crash the moment a player reached that object.

Objects share one array across every kind -- NPCs, signs, warps and
static encounters all coexist with no field the loader could use to
tell them apart ahead of time -- so a strict f.rec covering the whole
shape would reject every kind this schema does not enumerate. Added
f.partial, an open counterpart to f.rec: it type-checks (and, through
collectRefs, cross-reference-checks) only the fields it is given and
leaves everything else on the value alone, the same extensibility
f.rec already grants at a record's top level but nowhere further in.
R.maps.objects now types just `pokemon` through it, so a bad species
id is a load-time "unresolved reference" error instead of a runtime
crash, while an NPC object's sprite/movement/range/... fields -- never
named in this schema -- still pass through untouched.
2026-08-16 22:48:33 +02:00
sanjinpepic 2da2168dac Refuse a TM/HM on a species with no tmhm list instead of crashing
ItemEffects.use walked speciesDef.tmhm with a bare ipairs() to check
whether the species could learn the machine's move. A record with no
tmhm field at all -- a mod species that never set one, or any record
missing it for whatever reason -- hit ipairs(nil) and took the whole
game down on the first TM/HM use, rather than reaching the ordinary
"can't learn that move" refusal a species whose list simply omits the
move already gets.

An absent list now reads the same as an empty one: nothing to learn,
same refusal, same sound, same text.
2026-08-16 22:48:33 +02:00
sanjinpepic f62b1268c8 Add an item.use hook around BagMenu's item-use dispatch
useOn was a plain Lua local: every result ItemEffects.use returned fell
through to one unconditional showMessages with no seam a mod could
reach, unlike menu.lua/boxmark.lua/formview.lua's screens, which wrap
their own default behavior as a table field or a Runtime hook. A mod
could not suppress a message, delay it behind a screen of its own, or
substitute a different outcome for one item id -- exactly the gap noted
against Ultra Burst's item-driven fusion, which had nowhere left to
attach a bespoke animation once TextBox.new turned out to be the only
other reachable seam.

This wraps the whole dispatch in a Runtime.call("item.use", ...) hook,
the same mechanism "battle.overlay", "ui.party.submenu" and the rest of
src/ui already use, rather than exporting BagMenu.useOn as a table
field. A hook is the smaller commitment: it is additive (a fresh
Runtime.call site needs no schema or manifest change and costs nothing
unsubscribed -- see tests/engine/gate_hooks.lua's null-object case) and
a mod can still run the vanilla flow unchanged by calling the handed-in
vanilla function, whereas a table field would fix useOn's exact
signature as public API the moment it shipped. If the maintainer would
rather match the sibling screens' convention directly, exporting
BagMenu.useOn is the alternative and does not conflict with this hook
existing alongside it.

vanillaUseOn keeps the original function body; useOn is now the thin
wrapper mods observe through, and every internal caller in this file
still goes through useOn so the hook fires on every path into it.
2026-08-16 22:48:33 +02:00
sanjinpepic 82ae667611 Update the crossValidate comment for growth_rates / evolution_methods
The comment above the gatedFor skip in Schemas.crossValidate still
described growth_rates and evolution_methods as unconfirmable Gen 2
namespaces, the way they were before each got a real Gen 2 id space:
growth_rates keeps its Gen 1 target and is seeded from the extractor's
data.pokemon.growthRates (src/battle/gen2/Mon.lua), and
evolution_methods routes to gen2EvolutionMethods, a fixed literal set
(src/core/gen2/Evolution.lua) that exists with or without a ROM
import. Schemas.GEN2 does not gate either name -- gate_gen2_mod_api.lua
pins that directly, including a case that a bad evolution method on a
Gold species is still caught -- so the two are validated like any other
reference today, not skipped.  Nothing here changes that behavior;
only the comment, which was describing an earlier state of the code,
is corrected.
2026-08-16 22:48:33 +02:00
sanjinpepic 2b5229e73f Deny require("jit.util") in the mod sandbox
DENIED_PREFIX blocked love.* and ffi.* submodule requires but had no
entry for jit, so require("jit.util") walked straight through to the
real module.  jit.util is LuaJIT's own equivalent of the debug library
this file already denies by name: funcbc, funck and the rest read the
bytecode and constants of any function a chunk can reach, which is
enough to recover upvalues -- the real _G, love, io -- that the sandbox
exists to keep out of a mod's hands.

Adding "jit" to DENIED_PREFIX blocks jit.* submodule requires the same
way love.* and ffi.* already are, while leaving the bare jit global
(env.jit, handed over directly for jit.on/off/flush) and a bare
require("jit") untouched -- jit.util is not a field of that table
without its own require, so neither route was ever a way to reach it.
2026-08-16 22:48:33 +02:00
sanjinpepic 881670db91 Clear drainHold once the HP-bar drain actually finishes
stepHPDrain counts drainHold down to 0 as the last step of every phase
(pixel slide, HP-number step, closing frames) but never let go of the
field afterward, so it sat at 0 -- not nil -- for the rest of the
battle.  BattleSafety.inspect uses drainHold ~= nil as its
settled-presentation gate for checkpoint capture, so the very first HP
change in a battle permanently refused every checkpoint after it with
battle_phase_busy, even once the bar had long since caught up.

Only nil the field when the whole drain is actually over (bar pixel,
HP number and the closing-frame hold all settled), not on every
mid-sequence 0 -- a fresh HP change still needs drainHold to read as
busy so BattleSafety keeps refusing captures until that one settles
too.
2026-08-16 22:48:33 +02:00
1jamie a542ed90ba refactor: consolidate loose constants into tables in World.lua and add GameViewport module dependency
and updated the behavior of the patch notes
also fixed manual update checking
added test to make sure no prs or build tasks are able to pass if the luajit limits ar exceeded.
2026-08-16 15:47:56 -05:00
1jamie e9a4a592a4 feat(update): cache fetched release notes and strict-match patch notes version 2026-08-16 15:47:56 -05:00
thibautbus 70f7d5c028 Translate the clock-setting screens' day names and time-of-day word
DAYS (SUNDAY..SATURDAY), the MORN/DAY/NITE word PrintHour prints, and the
"o'clock"/"min." suffixes bypassed src/core/Strings.lua entirely -- they
were plain Lua literals with no lookup, so a translation mod's `strings`
registry had nothing to catch and Oak's clock screens, the day-of-week
wheel, the main menu clock box and the Pokegear's clock card kept printing
English regardless of the loaded language (reported from a real Spanish
Gold build).

Both live in src/core/gen2/Clock.lua, which already owns weekday/hour
arithmetic and is already required by InitClock.lua, MainMenu.lua and
Pokegear.lua: Clock.DAY_NAMES + Clock.weekdayName(day) is the one place the
three screens read a weekday's name from, so a fix to it cannot land on one
screen and silently miss the other two. Clock.daytimeLabel(hour) is the
translated counterpart to Palettes.clockDaytime, which keeps answering the
untranslated MORN/DAY/NITE key every FORCED_DAYTIME lookup in Palettes.lua
compares against -- src/world/gen2/Palettes.lua itself is untouched, so
that module stays pure table/color math with no Strings coupling.
2026-08-16 22:45:03 +02:00
bryanthaboi 46f73b7bb3 Merge pull request #1445 from AverageConsumer/codex/desktop-companion-display 2026-08-16 16:40:10 -04:00
AverageConsumer 99d9908017 render: add cross-platform desktop companion display 2026-08-16 22:24:12 +02:00
bryanthaboi b72d1d34b5 Merge pull request #1438 from AverageConsumer/codex/fix-gen2-viewport-local-limit 2026-08-16 16:15:05 -04:00
AverageConsumer f3619a00c2 fix(gen2): stay below LuaJIT local limit 2026-08-16 20:43:38 +02:00
syybott 524138ff27 Fix wide battle shake test fixture 2026-08-16 11:52:00 -05:00
bryanthaboi fdffb12571 Merge pull request #1399 from AverageConsumer/codex/mod-battle-special-intents 2026-08-16 12:09:03 -04:00
bryanthaboi 0c941cecd4 Merge pull request #1402 from AverageConsumer/codex/mod-field-advanced-actions 2026-08-16 12:08:47 -04:00
bryanthaboi dd59175c71 Merge pull request #1405 from AverageConsumer/codex/mod-render-viewport 2026-08-16 12:08:03 -04:00
github-actions 114352b75f chore(ios): update app-repo.json [skip ci] 2026-08-16 10:23:08 -04:00
bryanthaboi 0e40a7a1f4 Merge pull request #1408 from bryanthaboi/dev 2026-08-16 10:14:26 -04:00
bryanthaboi fe6a580e20 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	src/world/gen2/World.lua
2026-08-16 10:11:53 -04:00
bryanthaboi a910b65434 CLOSES #1231, CLOSES #1269, CLOSES #1301 2026-08-16 10:04:54 -04:00
AverageConsumer 1f3d13adaf mods: add OS-independent game viewport composition 2026-08-16 15:17:35 +02:00
Yunus Emre Umar 393a1013e4 fix(save-editor): guard cycleMove against undefined moves in catalog
Closes #1403
2026-08-16 16:11:32 +03:00
AverageConsumer a3a20a07e1 feat(mods): expose Fly and Softboiled field actions 2026-08-16 14:57:46 +02:00
bryanthaboi 1151c188a7 CLOSES #1211, CLOSES #1228, CLOSES #1229, CLOSES #1232, CLOSES #1251, CLOSES #1265, CLOSES #1267, CLOSES #1276, CLOSES #1279, CLOSES #1282, CLOSES #1293, CLOSES #1296, CLOSES #1303, CLOSES #1329, CLOSES #1338, CLOSES #1341, CLOSES #1343, CLOSES #1344, CLOSES #1368, CLOSES #1385, CLOSES #1388, CLOSES #1389, CLOSES #1391 2026-08-16 08:55:40 -04:00
AverageConsumer b39e11b7cd feat(mods): add special battle intents 2026-08-16 14:43:08 +02:00
bryanthaboi 65128e13a4 Update bug_report.yml 2026-08-16 07:28:08 -04:00
github-actions d74662ba99 chore(ios): update app-repo.json [skip ci] 2026-08-16 07:04:20 -04:00
bryanthaboi 5f2b2c616e CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362 (#1395)
* CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362

* conv
2026-08-16 06:55:09 -04:00
bryanthaboi 32e6e4cef0 conv 2026-08-16 06:50:17 -04:00
bryanthaboi fc841f7525 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-16 06:41:58 -04:00
bryanthaboi 12fdfa1e88 CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362 2026-08-16 06:41:56 -04:00
syybott 90eb53b00c Keep WIDE battle visible beneath opaque menus 2026-08-15 23:29:04 -05:00
github-actions a34d8611bf chore(ios): update app-repo.json [skip ci] 2026-08-15 23:38:06 -04:00
bryanthaboi 73fbaaa250 Merge pull request #1383 from bryanthaboi/dev
two absolut hogs doing hog level sh
2026-08-15 23:28:08 -04:00
bryanthaboi 9469e39926 Merge pull request #1379 from AverageConsumer/codex/mod-battle-intents
mods: add validated battle menu intents
2026-08-15 23:11:21 -04:00
bryanthaboi 179048a58e Merge pull request #1380 from AverageConsumer/codex/android-secondary-hotplug
android: rebind secondary displays after hotplug
2026-08-15 23:11:13 -04:00
bryanthaboi e1f5c2b217 Merge pull request #1382 from ShaneMcGovernIE/chore/postlog-body-limit
Raise postLog body ceiling to 512 KiB
2026-08-15 23:11:06 -04:00
bryanthaboi 1d8ac1e692 Merge pull request #1381 from ShaneMcGovernIE/fix/postlog-staging-temp-env
HostShell: stage postLog bodies via OS temp env, not tmpnam
2026-08-15 23:10:59 -04:00
Shane McGovern 39df5bdfa6 Raise postLog body ceiling to 512 KiB
A diagnostic ring (boot evidence + recent lines + status) routinely exceeds 64 KiB on a long session: a 651-line evidence ring measured ~90 KB and was rejected with "log body too large" (mod.postLog returned nil and the send was dropped).

The transport stages the body to a file and streams it via curl, so the ceiling is a budget, not a memory spike. 512 KiB is generous for real support logs while staying far under the 5 MiB the reference loghook endpoint accepts.
2026-08-16 03:31:36 +01:00
Shane McGovern 4b7a4daf2c HostShell: stage postLog bodies via OS temp env, not tmpnam
tmpnam() on the Windows CRT returns a bare, CWD-relative name (e.g. \sb4c.2), and io.open on it fails with Permission denied when the game's working directory is not writable -- a Program Files (or otherwise protected) install. postLog then dies before curl runs: the mod reports a send failure and no bytes leave the machine (confirmed on a Windows install: "could not create request body: \sb4c.2: Permission denied").

Stage the request body under the OS temp contract instead: TEMP/TMP on Windows (always set, always per-user writable), TMPDIR with a /tmp fallback on POSIX. The transport stays on plain io/os -- no love.filesystem dependency.

Tests updated to mock os.getenv and assert the staged path sits under the temp dir; 10/10 checks pass.
2026-08-16 03:29:33 +01:00
AverageConsumer e1d233d026 fix(android): rebind secondary displays after hotplug 2026-08-16 03:07:54 +02:00
AverageConsumer c22888a7fd feat(mods): add validated battle menu intents 2026-08-16 02:56:03 +02:00
syybott 6780393f45 Add extended battle HUD visual coverage 2026-08-15 19:39:48 -05:00
syybott 530f2bdd15 Add optional extended widescreen battle HUD 2026-08-15 19:39:48 -05:00
github-actions 0e4fc3c54a chore(ios): update app-repo.json [skip ci] 2026-08-15 20:31:09 -04:00
bryanthaboi 82b91e36ca Merge pull request #1377 from bryanthaboi/dev 2026-08-15 20:22:42 -04:00
bryanthaboi 3588a5f3fe Merge pull request #1369 from AverageConsumer/codex/mod-option-conditional-rows 2026-08-15 20:20:26 -04:00
bryanthaboi 992dc80aa7 Merge pull request #1370 from AverageConsumer/codex/mod-field-action-busy 2026-08-15 20:20:12 -04:00
bryanthaboi 6e7073d32b Merge pull request #1372 from AverageConsumer/codex/android-async-canvas-readback 2026-08-15 20:19:58 -04:00
bryanthaboi 000e691966 Merge pull request #1374 from AverageConsumer/codex/mod-battle-snapshot 2026-08-15 20:19:45 -04:00
bryanthaboi fdbc51c5d1 Merge pull request #1375 from ShaneMcGovernIE/fix/postlog-pipe-mode 2026-08-15 20:19:29 -04:00
Shane McGovern 3e3566d1b4 Fix desktop postLog transport 2026-08-16 01:04:01 +01:00
AverageConsumer fcb5d1d348 feat(mods): expose read-only battle snapshots 2026-08-16 01:32:23 +02:00
AverageConsumer b45d783dee feat(android): add asynchronous canvas readback 2026-08-16 01:09:10 +02:00
AverageConsumer d99072b44e feat(mods): report field action availability state 2026-08-16 01:01:16 +02:00
AverageConsumer 0ac55b0f9e feat(mods): support conditional option rows 2026-08-16 00:54:52 +02:00
github-actions ab48572852 chore(ios): update app-repo.json [skip ci] 2026-08-15 17:06:08 -04:00
bryanthaboi 5d2c13ed2b Merge pull request #1366 from bryanthaboi/dev
baloney
2026-08-15 16:57:46 -04:00
bryanthaboi d87f6b8ad1 Merge pull request #1357 from anxiousintrovert/agent/fix-launcher-versioned-conflicts
Fix versioned mod conflicts in launcher
2026-08-15 16:51:52 -04:00
bryanthaboi 24cf367758 Merge pull request #1363 from ShaneMcGovernIE/feat/fetch-postlog
feat(mods): mod.postLog — one-way log reporting for mod owners
2026-08-15 16:51:33 -04:00
bryanthaboi 829d398a94 Merge pull request #1365 from bryanthaboi/dependabot/github_actions/actions/setup-dotnet-6
build(deps): bump actions/setup-dotnet from 4 to 6
2026-08-15 16:51:14 -04:00
bryanthaboi 3ee50a27c5 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-15 16:50:43 -04:00
bryanthaboi 871087a16b Delete timekeepers_hut 2026-08-15 16:50:40 -04:00
dependabot[bot] 180ce6b2e7 build(deps): bump actions/setup-dotnet from 4 to 6
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 20:32:54 +00:00
Shane McGovern cf335f67de feat(mods): add mod.postLog one-way log reporting to a manifest-declared URL 2026-08-15 21:03:24 +01:00
github-actions 7e0d81a431 chore(ios): update app-repo.json [skip ci] 2026-08-15 14:38:33 -04:00
bryanthaboi 10314bcdcf Merge pull request #1355 from bryanthaboi/dev 2026-08-15 14:29:27 -04:00
anxiousintrovert b8ec4fe6b5 Fix versioned mod conflicts in launcher 2026-08-15 13:17:04 -05:00
bryanthaboi 099a4266a8 Merge pull request #1354 from 1Jamie/fix/save-editor-lifecycle 2026-08-15 13:59:32 -04:00
1jamie cec1f196be refactor: implement HostShell transport abstraction for multi-platform update fetching and downloads so the "check for updates" button works on mobile os 2026-08-15 12:48:36 -05:00
1jamie e24410f0fb fix: improve Gen2 data loading and error handling to prevent crashes when cache modules are missing on android 2026-08-15 12:48:36 -05:00
1jamie b29b6fd7bd fix(save-editor): reload Data and mods if App.dataVersion differs from active session 2026-08-15 12:48:36 -05:00
1jamie f06c4d4584 fix(save-editor): guard event scraping for mobile environments and fix editor state reset on close 2026-08-15 12:48:36 -05:00
1jamie 3a997e8a62 fix(save-editor): clean up module registry and pristine Data state on editor unload so subsequent edits perform safely 2026-08-15 12:48:36 -05:00
bryanthaboi e6ccdd57eb Merge pull request #1353 from anxiousintrovert/fix/required-import-platform-pickers 2026-08-15 13:37:51 -04:00
bryanthaboi 927507f8f7 Merge pull request #1351 from TheRealSolidusSnake/feature/sandbox-tls-legacycompat 2026-08-15 13:36:44 -04:00
anxiousintrovert 9bf15c33fd Cover required imports across platforms 2026-08-15 12:20:21 -05:00
anxiousintrovert 52efdabf61 Harden required import picker for Android 13 2026-08-15 12:20:21 -05:00
Solidus Snake ef208035ec Fix Gen1Tls.lua encoding (was UTF-16).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 12:33:22 -04:00
Solidus Snake 18d61779eb Hang gen1tls on love.system and forward tls* through LegacyCompat.
We already ship the DLL, but mods can't ffi-load it under the sandbox, and
the compat love.system shim wasn't passing tls* through either. So wss://
still died on stock builds. Engine loads the dialer at boot; compat forwards
those keys; clipboard/openURL stay stubbed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 12:32:37 -04:00
github-actions d573878a2f chore(ios): update app-repo.json [skip ci] 2026-08-15 10:55:33 -04:00
bryanthaboi a66efe207d Merge pull request #1347 from bryanthaboi/dev
bazinga performance and addl hooks
2026-08-15 10:46:39 -04:00
bryanthaboi 5198b35945 bazinga 2026-08-15 10:44:20 -04:00
github-actions 40977337b1 chore(ios): update app-repo.json [skip ci] 2026-08-15 06:31:55 -04:00
bryanthaboi 1598f34954 Merge pull request #1337 from bryanthaboi/dev
legcompat
2026-08-15 06:23:05 -04:00
bryanthaboi 43cbc554c3 legcompat 2026-08-15 06:04:04 -04:00
github-actions 3c3e2c54c5 chore(ios): update app-repo.json [skip ci] 2026-08-14 23:48:42 -04:00
bryanthaboi 00c72c441b Merge pull request #1322 from bryanthaboi/dev 2026-08-14 23:40:02 -04:00
bryanthaboi 7804ef9793 Merge pull request #1323 from 1Jamie/fix/patch-notes-ci-resilience 2026-08-14 23:37:59 -04:00
1jamie 673d8b3ad8 fix(tests): make launcher_patch_notes resilient to release note wording changes 2026-08-14 22:36:24 -05:00
bryanthaboi 62e1296ced Merge pull request #1321 from 1Jamie/fix/gold-save-bug 2026-08-14 23:20:49 -04:00
1jamie a3bbd78e7b Remove androidcrash.log from project root 2026-08-14 22:17:33 -05:00
1jamie 8dfbd1daae fix(save/gen2): auto-recover orphaned save slots and fix PartyMenu field moves
- Auto-recover save slots: Scan saves/<version>/ on boot to re-index orphaned slot files if options.lua is reset or loses its slot registry.
- Fix Gen 2 field moves: Make PartyMenu field actions (Strength, Surf, Cut, Flash) generation-aware so they execute safely in Gold.
- Fortify platform stability: Guard native controller/hidapi init against Android 14 receiver exceptions and ensure safe Windows file replacement.
- Add unit test coverage for slot auto-recovery in save_editor_gen2_tests.lua.
2026-08-14 22:16:17 -05:00
github-actions 4046b28a8c chore(ios): update app-repo.json [skip ci] 2026-08-14 21:53:50 -04:00
bryanthaboi a86d57ac44 Merge pull request #1316 from bryanthaboi/dev 2026-08-14 21:44:05 -04:00
bryanthaboi 72f126ae26 Merge pull request #1312 from AverageConsumer/feat/contextual-field-moves 2026-08-14 21:42:22 -04:00
bryanthaboi 5d7a8c9291 Merge pull request #1300 from anxiousintrovert/feature/required-mod-imports 2026-08-14 21:41:49 -04:00
anxiousintrovert c48fc578ca Address required import review feedback 2026-08-14 20:34:14 -05:00
anxiousintrovert dfc216f974 Remove required imports RFC 2026-08-14 20:34:14 -05:00
anxiousintrovert 542856c83d Add manifest-driven required mod imports 2026-08-14 20:34:14 -05:00
bryanthaboi 545a99d86d Merge pull request #1311 from 1Jamie/feat/gold-save-editor 2026-08-14 21:15:07 -04:00
AverageConsumer 2b6473ae03 feat(mods): extend contextual field actions 2026-08-15 02:01:20 +02:00
1jamie df0be1cba6 Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
2026-08-14 18:48:06 -05:00
github-actions c9d67582ae chore(ios): update app-repo.json [skip ci] 2026-08-14 18:02:54 -04:00
bryanthaboi 1cfd91a503 Merge pull request #1309 from bryanthaboi/dev
path read
2026-08-14 17:54:26 -04:00
bryanthaboi fb738fa1ce path read 2026-08-14 17:51:55 -04:00
github-actions c0ad6d0328 chore(ios): update app-repo.json [skip ci] 2026-08-14 17:19:40 -04:00
bryanthaboi 84de8c9cb1 Merge pull request #1306 from bryanthaboi/dev
for greg
2026-08-14 17:10:29 -04:00
bryanthaboi 6307bc92f6 Merge branch 'main' into dev 2026-08-14 17:07:19 -04:00
bryanthaboi 052dd26b3e Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-14 17:07:08 -04:00
bryanthaboi c8f6c7241b i did it for greg 2026-08-14 17:07:06 -04:00
bryanthaboi b3928388ef Merge pull request #1304 from ShaneMcGovernIE/agent/mod-storage-opaque-bytes
Add opaque byte storage to mod API
2026-08-14 16:59:06 -04:00
bryanthaboi a38fae5a97 Merge pull request #1302 from AverageConsumer/feat/mod-field-items
feat(mods): expose contextual field items
2026-08-14 16:57:16 -04:00
Shane McGovern 9fab992d42 Add opaque byte storage to mod API 2026-08-14 21:42:45 +01:00
AverageConsumer dcc388a942 feat(mods): expose contextual field items 2026-08-14 21:39:18 +02:00
bryanthaboi 797a6bebfe Merge pull request #1253 from mleo2003/padcursor-edge-scroll
launcher: pad cursor edge-scroll for stickless handhelds
2026-08-14 15:14:45 -04:00
bryanthaboi 97a9c0f58f Merge pull request #1286 from MaxTomahawk/adaptive-trainers/battle-party-scope
feat(mod-api): add trainer battle party scope
2026-08-14 15:14:31 -04:00
MaxTomahawk 407f649e9d fix(mod-api): harden deferred trainer preparation 2026-08-14 17:57:59 +02:00
github-actions 84635cdfdf chore(ios): update app-repo.json [skip ci] 2026-08-14 11:52:58 -04:00
bryanthaboi 3de45b671c Merge pull request #1288 from bryanthaboi/dev
CLOSES #1283
2026-08-14 11:43:13 -04:00
bryanthaboi 4395792226 CLOSES #1283 2026-08-14 11:41:54 -04:00
MaxTomahawk a77210799f feat(mod-api): add trainer battle party scope 2026-08-14 17:33:17 +02:00
github-actions 6f67292b0e chore(ios): update app-repo.json [skip ci] 2026-08-14 10:53:45 -04:00
bryanthaboi 78e8a31ead Merge pull request #1280 from bryanthaboi/dev
Update build_appimage.sh
2026-08-14 10:44:43 -04:00
bryanthaboi b6388013ec Update build_appimage.sh 2026-08-14 10:40:36 -04:00
github-actions ba7cd8fabf chore(ios): update app-repo.json [skip ci] 2026-08-14 10:28:05 -04:00
bryanthaboi c8f7f396ce Merge pull request #1278 from bryanthaboi/dev
updates changes and modifications
2026-08-14 10:19:00 -04:00
bryanthaboi a94fecfec8 Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033 2026-08-14 10:05:51 -04:00
bryanthaboi 2010ba71a6 better hover 2026-08-14 07:20:48 -04:00
bryanthaboi e90dff6764 Merge branch 'grandmas-kitchen' into dev 2026-08-14 07:13:00 -04:00
bryanthaboi f4658b89ae Merge pull request #1246 from 1Jamie/fix/gen2-battle-rng-static-damage 2026-08-14 05:57:51 -04:00
bryanthaboi da63163216 Merge pull request #1242 from AverageConsumer/codex/final-frame-output-hooks 2026-08-14 05:55:26 -04:00
bryanthaboi fceaa2b54c Merge pull request #1240 from AverageConsumer/codex/gen2-battle-ui-visibility 2026-08-14 05:54:22 -04:00
bryanthaboi be2ef46ebc Merge pull request #1247 from ShaneMcGovernIE/fix/1243-tm-sell-at-marts 2026-08-14 05:53:30 -04:00
bryanthaboi d8e86e6878 Merge pull request #1258 from TheRealSolidusSnake/feature/ship-gen1tls-windows 2026-08-14 05:52:52 -04:00
Solidus Snake 7e57e3174e Ship gen1tls.dll in Windows release zips.
TLS source landed earlier, but release CI never packaged the dialer, so
hosted archipelago.gg rooms (wss://) failed on stock Windows builds.
Build the Native AOT DLL on windows-2022 and bundle it from build.sh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:38:04 -04:00
mleo2003 5192106730 launcher: pad cursor edge-scroll for stickless handhelds
The launcher's SHORT-WINDOW SCROLL engages correctly on a 480px-tall
panel, but _pageScroll has only three inputs -- mouse wheel, touch drag,
and the right analog stick -- and a device with none of them cannot
reach anything below the fold. On an RG35XXSP (muOS, 640x480)
/proc/bus/input/devices lists one gpio-keys-polled device reporting the
D-pad as a hat: no analog axes exist. There is also no scroll-to-focus
anywhere, so moving the pad cursor never touches _pageScroll.

The pad cursor already computes the motion: it is clamped to the safe
area and discarded. Spend that overshoot on the existing wheel path
instead -- the same one the right stick feeds a few lines below.

Only the overshoot scrolls, so parking the cursor at the edge does
nothing; it has to be actively pushed. The enclosing block runs only on
non-zero pad input, so a real mouse never reaches it and desktop
behaviour is unchanged.

Tested on an RG35XXSP at stock Kit.lua scale (0.9): the pager and footer
become reachable, pushing up at the top scrolls back, and parking at the
edge does nothing. Refs #1142.
2026-08-13 18:14:28 -07:00
1jamie dfeacc36b0 - adding ability to specify gen specific deps in manifest system.
- fixed issue with wayland users drag and drop causing CTD
2026-08-13 18:18:59 -05:00
ShaneMcGovernIE 1586aec9f6 fix(gen2): sell TMs at Poké Marts instead of opening the teach party
Selecting a TM in the mart's SELL pack (and the item PC's DEPOSIT pack) opened the teach-party screen: PackMenu:useSelected taught any item carrying `teaches`, even when the pack was built as a chooser (DepositSellPack, world = {}). On the cart that chooser's jumptable is four ScrollingMenus and never reaches tmhm.asm, so the row must hand back to the caller instead.

Gate the teach and field-NOUSE branches behind world.useFieldItem so only the real field PACK teaches; choosers now hand TMs to onChoose. Selling then prices a TM at half its ItemAttributes price -- exactly half of what the Goldenrod/Celadon TM shelves charge, and half of the hidden price for the rest, the way SelectQuantityToSell -> GetItemPrice -> Sell_HalvePrice does on the cart.

Closes #1243
2026-08-13 23:55:31 +01:00
1jamie 35d44efb8b fixes to the failing test systems 2026-08-13 17:44:45 -05:00
1jamie 500d8c2c07 feat(mods): enhance mod management with profile controls and dependency checks
- Added dedicated profile control in the MODS panel for easier profile management.
- Implemented dependency checking during mod installation and updates to ensure compatibility.
- Improved manifest parsing to support GitHub repository hints for dependencies.
- Enhanced UI interactions for saving and renaming profiles.
- Updated tests to cover new functionality and ensure stability.
2026-08-13 17:33:29 -05:00
AverageConsumer 09204daa28 feat(mods): expose opt-in final-frame output hooks 2026-08-13 23:04:46 +02:00
github-actions 01329758ff chore(ios): update app-repo.json [skip ci] 2026-08-13 16:49:24 -04:00
AverageConsumer 7b8cb127a8 feat(gen2): share battle UI visibility hooks 2026-08-13 22:41:26 +02:00
bryanthaboi 8e4d5b6807 Merge pull request #1239 from bryanthaboi/dev
something for avg consumer and something for everyone else
2026-08-13 16:40:04 -04:00
bryanthaboi 26e9e1d597 Merge pull request #1238 from AverageConsumer/codex/gen2-map-overview-parity
feat(mods): expose map overviews in Gold
2026-08-13 16:30:47 -04:00
bryanthaboi f7f32d2786 scroll list 2026-08-13 16:29:59 -04:00
AverageConsumer c5192eaea4 feat(mods): expose map overviews in Gold 2026-08-13 22:08:00 +02:00
github-actions c2906744ce chore(ios): update app-repo.json [skip ci] 2026-08-13 15:53:20 -04:00
bryanthaboi 8e6b5ae73d Merge pull request #1234 from bryanthaboi/dev
bugs and slugs
2026-08-13 15:44:26 -04:00
bryanthaboi 0b96ac0ab6 fancy cart 2026-08-13 15:36:08 -04:00
bryanthaboi f6a035947f Update OverworldController.lua 2026-08-13 13:31:36 -04:00
bryanthaboi 941181d31c check bonxes 2026-08-13 13:22:18 -04:00
bryanthaboi 9ceb1a8940 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 13:07:22 -04:00
bryanthaboi 37051a26b5 CLOSES #1206, CLOSES #1189, CLOSES #1175, CLOSES #1129, CLOSES #1119, CLOSES #1089, CLOSES #1073, CLOSES #1069, CLOSES #1065, CLOSES #1010, CLOSES #990, CLOSES #989, CLOSES #988, CLOSES #978, CLOSES #944, CLOSES #936, CLOSES #1221, CLOSES #1220, CLOSES #1193, CLOSES #1101, CLOSES #1066, CLOSES #1056, CLOSES #1041, CLOSES #984, CLOSES #1225, CLOSES #1214, CLOSES #1011, CLOSES #1035, CLOSES #1219, CLOSES #1048, CLOSES #1047, CLOSES #964, CLOSES #949, CLOSES #1149, CLOSES #1007, CLOSES #914, CLOSES #1115, CLOSES #1146, CLOSES #999, CLOSES #1207, CLOSES #1029, CLOSES #1120, CLOSES #987, CLOSES #983 2026-08-13 13:07:20 -04:00
bryanthaboi b12a0dac91 Merge pull request #1205 from 1Jamie/fix/gen2-battle-rng-static-damage 2026-08-13 11:27:02 -04:00
bryanthaboi e4786a78d9 Merge pull request #1226 from mresnick67/steps-bridge 2026-08-13 11:23:55 -04:00
Myles Resnick bde606f966 Permission-gated step bridge for sandboxed mods
The sandbox blocks love.system and love.filesystem, which orphans the
native step bridge (#452, #489): its one consumer can no longer call
syncHealthSteps or read steps_pending.json (#1186).

Adds a "steps" manifest permission (shown to the player like the
others) gating a mod.steps facade: available() probes the bridge
quietly, sync() forwards the async refresh, poll() hands the mod its
copy of a delivery. The engine owns the pending file -- mods never name
a path and receive only { steps, from, to }. Without the permission the
acting calls name it, following the network gate. No new events, hooks
or registries; nothing removed.

RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold
bridge, permissioned sync/poll, per-mod copies, contract-field
filtering, malformed-delivery drop, unpermissioned refusal, bridgeless
build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:19:06 -04:00
1jamie a722d24759 fix merge conflicts with dev 2026-08-13 07:39:11 -05:00
github-actions 833388c235 chore(ios): update app-repo.json [skip ci] 2026-08-13 06:22:43 -04:00
bryanthaboi 2ac527ae3c Merge pull request #1222 from bryanthaboi/dev
bug fixing and friends
2026-08-13 06:14:29 -04:00
bryanthaboi df35193ad3 Merge pull request #1204 from castdrian/ios-improvements
feat(ios): improve branding and fix haptic feedback
2026-08-13 06:06:31 -04:00
bryanthaboi 9b6365c9cc Merge pull request #1202 from HimioneGranger/upstream-android-host-extension
Android: add optional native host lifecycle hooks
2026-08-13 06:06:24 -04:00
bryanthaboi 285a98533a Merge pull request #1201 from HimioneGranger/upstream-payload-host-contract
feat(update): gate payloads by native host family
2026-08-13 06:06:18 -04:00
bryanthaboi f952edf77a Merge pull request #1200 from HimioneGranger/upstream-host-display-api
feat(host): add optional display lifecycle backend
2026-08-13 06:06:12 -04:00
bryanthaboi ae8360b2ae Merge pull request #1199 from HimioneGranger/upstream-quest-focus-api
fix(launcher): route focus input through mod dialogs
2026-08-13 06:06:05 -04:00
bryanthaboi 52e36ad7e4 fix testsZ 2026-08-13 06:05:27 -04:00
bryanthaboi 62b9f04191 Merge pull request #1209 from MaxTomahawk/fix/checkpoint-engine-version-restore
fix(mods): allow checkpoint restore across engine versions
2026-08-13 05:35:05 -04:00
bryanthaboi 1010d0584a Merge pull request #1210 from Yukitty/patch-hopFrames
fix(OverworldController): ledge jump arc to match player stepFrames
2026-08-13 05:33:02 -04:00
bryanthaboi 6864173f35 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 05:30:35 -04:00
bryanthaboi e9d431b3ff CLOSES #1091, CLOSES #1097, CLOSES #1100, CLOSES #1104, CLOSES #1135, CLOSES #1155, CLOSES #1157, CLOSES #1158, CLOSES #1159, CLOSES #1160, CLOSES #1167, CLOSES #1208 2026-08-13 05:30:33 -04:00
Yukita Mayako f138827917 fix(OverworldController): ledge jump arc to match player stepFrames 2026-08-12 21:23:35 -04:00
Max a68c47e7e1 fix(mods): allow checkpoint restore across engine versions 2026-08-13 02:28:57 +02:00
Adrian Castro d648990a81 feat(ios): brand app and tune haptic feedback 2026-08-13 02:19:45 +02:00
1jamie 6ff7dd0c38 refactor(battle): enhance RNG handling and fixed damage mechanics
- Always supply Gen 2 battle random (BattleRandom) and Gen 1 / love-style rng.
- Add EFFECT_STATIC_DAMAGE for Sonic Boom and Dragon Rage (move power; Ghost
  immunity via resettypematchup for static damage only).
- Keep Magnitude rolling via BattleRandom instead of collapsing to Magnitude 4.
- Cover RNG, static damage, and Magnitude in gen2 battle tests.
2026-08-12 17:52:02 -05:00
Codex Agent ee00728e9e android: add optional native host lifecycle hooks 2026-08-12 17:41:26 -05:00
Codex Agent 0aab11b690 feat(update): gate payloads by native host family 2026-08-12 17:23:25 -05:00
Codex Agent c2d9af693d feat(host): add optional display lifecycle backend 2026-08-12 17:07:32 -05:00
Codex Agent 1642113d1f fix(launcher): route focus input through mod dialogs 2026-08-12 16:48:29 -05:00
github-actions d64627e062 chore(ios): update app-repo.json [skip ci] 2026-08-12 17:16:13 -04:00
bryanthaboi 5fea0b05a4 Merge pull request #1198 from bryanthaboi/dev
bugs and slugs
2026-08-12 17:07:13 -04:00
bryanthaboi c3136bf8f7 Merge pull request #1034 from ShaneMcGovernIE/find-mods-release-stats
Add generic Linux ARM SBC PortMaster build
2026-08-12 16:57:46 -04:00
bryanthaboi 517170f1c8 Merge pull request #1068 from anxiousintrovert/agent/fix-disabled-battle-ui-background
Fix hidden battle UI overlay backgrounds
2026-08-12 16:56:46 -04:00
bryanthaboi e5bebea437 Merge pull request #1077 from MaxTomahawk/feat/battle-menu-auxiliary
feat(mods): add battle menu auxiliary action
2026-08-12 16:56:04 -04:00
bryanthaboi 3320eacc26 Merge pull request #1079 from MaxTomahawk/feat/mod-pokemon-icon
feat(mods): expose canonical Pokémon icon presentation
2026-08-12 16:55:55 -04:00
bryanthaboi b40ff167e7 Merge pull request #1170 from thibautbus/fix/resume-boot-music
Fix lingering title music after Resume Game
2026-08-12 16:55:32 -04:00
bryanthaboi c0f654f8c2 Merge pull request #1195 from AverageConsumer/codex/mod-power-info
feat(mods): expose read-only device power info
2026-08-12 16:55:15 -04:00
bryanthaboi f4497b4dbb Delete KANTO-CONTINUE.md 2026-08-12 16:52:38 -04:00
bryanthaboi b9684ef162 Merge branch 'main' into dev 2026-08-12 16:51:44 -04:00
bryanthaboi f394f56395 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-12 16:51:29 -04:00
bryanthaboi 6731937841 Pulled every open Gold bug (bodies, comments, screenshots/videos), matched them against the port and ../pokegold, then split them so each fixer owns a disjoint file set. Duplicates collapsed: #1164 and #1188 are the same Radio Tower stair softlock. #1150 and #1190 are the same Gold rebind freeze. Enhancements (#1161, #1130, #1112, #1100) were left out. Battle 1 src/battle/gen2/*, src/ui/gen2/BattleState.lua #1168 multi-hit always 2 #1180 DV / stats #1185 learn-move text order after a trainer fight #1152 no "Use next POKéMON?" / run after a wild faint Overworld 2 src/world/gen2/*, trade anim, hidden items #1184 Kurt blocking Slowpoke Well #1164 / #1188 Radio Tower gentleman on the stairs #1173 Rock Smash rocks walk left each visit #1172 last item name used Rock Smash #1167 Ilex Forest hidden items #1165 trade movie + ledge hop still missing (#1121 / #1126) UI 3 naming, party, mart, pokegear #1166 grey nickname screen #1162 TM/HM missing ABLE / NOT ABLE #1169 underground herb lady whites out the map #1151 Pokegear black player icon + extra A/B on the phone card Platform 4 Gold options, save slots, pack, mod tab #1150 / #1190 controls freeze #1178 no vibration row #1177 cannot move Gold touch controls #1107 SAVE with no launcher slot #1145 Gold mod tab still lists Gen 1-only mods #1192 RGXX missing tools/rom_manifest_gold.json Drivers only for the progress / data-loss ones (faint-run, multi-hit, Kurt, Radio Tower, Rock Smash shift, herb shop, save slot). No new unit tests. New comments are pokegold file.asm:line only. A verifier agent will get this same list once the four finish and check that the diffs actually fix the reports. Verifier checked the batch against pokegold. Almost everything landed. I closed the two leftovers that were still real bugs: #1185: the active mon no longer reprints GrewToLevel (engine/battle/core.asm:7044). Bench mons still print it, then the stats box, then the learn-move line. #1180: the party list recalcs stats on open, same as Summary. Gold boot now applies options.touchControls / haptics from the Gold options block, not the shared Red pad. # Verdict 1168 multi-hit FIXED 1180 DVs / stats FIXED 1185 learn-move order FIXED (was partial) 1152 run after faint FIXED 1184 Kurt FIXED 1164 / 1188 Radio Tower FIXED 1173 rocks slide FIXED 1172 Rock Smash name FIXED 1167 Ilex hidden items SKIP-OK (already in the Gold cache) 1165 trade + ledge FIXED 1166 nickname color FIXED 1162 TM ABLE / NOT ABLE FIXED 1169 herb shop white-out FIXED 1151 Pokegear FIXED 1150 / 1190 rebind freeze FIXED 1178 vibration FIXED 1177 touch layout FIXED 1107 save slot FIXED 1145 mod tab filter FIXED 1192 RGXX Gold manifest FIXED Nothing is committed. Drivers, from the repo root: POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . Fury Attack should hit 2-5 times. Always twice is still broken. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . After the lead faints: "Use next POKéMON?" YES opens the party. NO/B tries to run. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . Kurt at the well entrance (16, 14), not on the inner path. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . Gentleman in the 5F office (3, 6). Stairs at (12, 0) clear. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . Route 40 rocks stay put. A slide one cell left is the old bug. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . Herb shop intro over the Underground map, not a white screen. POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . Look for PASS and a saves/gold/slot*.lua path. CONTINUE should see it. Worth a hand check with no driver: nickname color, a TM for ABLE/NOT ABLE, Pokegear phone B-to-close, OPTION > CONTROLS, a ledge hop, a trade movie, and on a phone OPTION > TOUCH LAYOUT / VIBRATION. give me CLOSES #93939, in a comma separated list each saying CLOSES before each issue number that is fixed CLOSES #1107, CLOSES #1145, CLOSES #1150, CLOSES #1151, CLOSES #1152, CLOSES #1162, CLOSES #1164, CLOSES #1165, CLOSES #1166, CLOSES #1168, CLOSES #1169, CLOSES #1172, CLOSES #1173, CLOSES #1177, CLOSES #1178, CLOSES #1180, CLOSES #1184, CLOSES #1185, CLOSES #1188, CLOSES #1190, CLOSES #1192 2026-08-12 16:51:27 -04:00
AverageConsumer e44769a48a Expose read-only device power info to mods 2026-08-12 22:13:32 +02:00
bryanthaboi b2819d04e0 Merge pull request #1191 from AverageConsumer/codex/gen2-screen-render-visible
fix(mods): honor screen visibility in Gold rendering
2026-08-12 16:10:43 -04:00
AverageConsumer 52fac8ced6 Honor hidden screen states in Gold rendering 2026-08-12 20:59:35 +02:00
bryanthaboi d38faab03c Delete mod-sandbox-notice.txt 2026-08-12 09:49:43 -04:00
bryanthaboi 83682f011d grandmas-kitchn 2026-08-12 09:22:30 -04:00
thibautbus 1176a269e5 Add unit tests 2026-08-12 11:38:17 +02:00
thibautbus 96af652d3b Fix lingering title music after Resume Game
Continue dropped the player into the overworld with the title
screen's song still cross-fading into the map theme over ~1.2s
(Music.MAP_FADE), audibly wrong since the player already has control.
New Game never showed this because OakSpeech's own unfaded
Music.play/playMap masks it before the player is ever placed in the
overworld. The same bug was also reachable through F2 quickload
(pressed at the title screen, or mid-session -- F2 always jumps
straight to the loaded save's map/position with no walking
transition, so it needs the same instant swap as Continue rather than
an ordinary warp's crossfade either way) and through the
checkpoint-resume mod API (RFC 0006's mod.checkpoint:resume).

OverworldState:setMap now takes an opts.freshBoot flag: when set, the
map's music swaps in at once instead of cross-fading, like every
other map's PlayDefaultMusic. It is set by every real hard state
teleport -- TitleState's onContinue, New Game's push, F2 quickload,
and Game:restoreCheckpointSave (whose only caller is itself
title-gated) -- and deliberately kept separate from the pre-existing
opts.via == "boot" default, which dev tooling (the console's `warp`
verb, hot reload's map rebuild) also reuses for unrelated reasons and
must keep its ordinary crossfade.
2026-08-12 11:25:07 +02:00
MaxTomahawk 29a2b9a123 Merge remote-tracking branch 'origin/dev' into feat/mod-pokemon-icon
# Conflicts:
#	docs/modding.md
2026-08-12 09:46:44 +02:00
MaxTomahawk 238af263d6 Merge remote-tracking branch 'origin/dev' into feat/battle-menu-auxiliary
# Conflicts:
#	src/core/Checkpoint.lua
2026-08-12 09:46:00 +02:00
github-actions dbd8abe14c chore(ios): update app-repo.json [skip ci] 2026-08-11 21:44:16 -04:00
bryanthaboi 04490c9b9a Merge pull request #1147 from bryanthaboi/dev
bunch of bugs and addl api stuff
2026-08-11 21:34:58 -04:00
bryanthaboi 49d094b14d Merge branch 'main' into dev 2026-08-11 21:32:14 -04:00
bryanthaboi cd7985e4ac Merge pull request #1072 from dlloa/gen1recomp-issue-28
Export versioned mod option schemas for native launchers
2026-08-11 21:24:57 -04:00
bryanthaboi 61edf3470f Merge pull request #1067 from swuff-star/audio-mod-load-fix
load user's imported cache for modkit validation
2026-08-11 21:23:59 -04:00
bryanthaboi cd02cd6f33 Merge pull request #1061 from AverageConsumer/codex/mod-world-party-reorder
Mod API: expose safe overworld party reordering
2026-08-11 21:23:43 -04:00
bryanthaboi 127e3da909 Merge pull request #1062 from AverageConsumer/codex/mod-caught-marker-visibility
Mod API: allow opt-in caught markers in wild battle HUDs
2026-08-11 21:23:31 -04:00
bryanthaboi 8fa702e715 Merge pull request #1064 from TheRealSolidusSnake/feature/tls-support
TLS support
2026-08-11 21:23:20 -04:00
bryanthaboi 3aaaf9936e Merge pull request #1076 from MaxTomahawk/feat/mod-title-checkpoint-resume
feat(mods): resume selected checkpoints from title
2026-08-11 21:23:08 -04:00
bryanthaboi af33c6e810 Merge pull request #1078 from MaxTomahawk/feat/scripted-battle-checkpoints
feat(mods): checkpoint scripted battle decisions
2026-08-11 21:22:40 -04:00
bryanthaboi e8eccfd4df Merge pull request #1080 from MaxTomahawk/feat/device-date-time
feat(mods): add shared local date and time formatting
2026-08-11 21:21:51 -04:00
bryanthaboi 241c3345bd Merge pull request #1086 from thibautbus/fix/yellow-pallet-oak-music
Bring Oak's Pallet Town cutscene in line with the original Yellow game
2026-08-11 21:21:36 -04:00
bryanthaboi 20e0692486 Merge pull request #1087 from MaxTomahawk/fix/battle-decision-settling
fix: settle real battle checkpoint decisions
2026-08-11 21:21:14 -04:00
bryanthaboi 0136429d3e CLOSES #1090, CLOSES #1093, CLOSES #1094, CLOSES #1095, CLOSES #1098, CLOSES #1102, CLOSES #1105, CLOSES #1106, CLOSES #1108, CLOSES #1109, CLOSES #1110, CLOSES #1111, CLOSES #1113, CLOSES #1114, CLOSES #1117, CLOSES #1118, CLOSES #1121, CLOSES #1122, CLOSES #1123, CLOSES #1124, CLOSES #1126, CLOSES #1127, CLOSES #1128, CLOSES #1131, CLOSES #1132, CLOSES #1134, CLOSES #1137, CLOSES #1141 2026-08-11 21:19:36 -04:00
bryanthaboi 01aab1d763 Merge pull request #1138 from dburton95/dev
Add trueColor to Gen 2's BattleState.lua
2026-08-11 20:00:50 -04:00
Dorian Burton 3d2d53362d Add trueColor to Gen 2's BattleState.lua
Without the trueColor option from Gen 1's BattleState, custom sprites are rendered with the orignial sprites color pallete.
2026-08-11 17:51:23 -04:00
github-actions 03838ee1d4 chore(ios): update app-repo.json [skip ci] 2026-08-11 12:14:09 -04:00
bryanthaboi 2fabc03841 Merge pull request #1088 from bryanthaboi/dev
G2 support
2026-08-11 12:05:05 -04:00
bryanthaboi ae6cac89e1 G2 support 2026-08-11 11:53:30 -04:00
MaxTomahawk 41f02ecfbc fix: settle real battle checkpoint decisions 2026-08-11 17:13:29 +02:00
thibautbus cef286b170 Add unit tests
Regression coverage for the four Yellow Pallet Town fixes on this
branch:

- tests/parity_J.lua: the old-man-style demo bag shows x1 in Yellow
  (SimulatedInputBattleItemList), not just the pre-existing x50
  (pokered's OldManItemList) case.
- tests/engine/push_battle_transition.lua: Commands.pushBattle calls
  ctx.overworld:pushBattle when available and falls back with a
  logged warning otherwise. Logger.warn is spied (pcall-safe, always
  restored) rather than read off the shared Logger.history ring
  buffer, so the fallback checks don't leave noise behind for
  whatever else runs in the same process.
- tests/parity_yellow_pallet_pikachu.lua: drives the real onStep
  closure through the real StateStack/OverworldState, with no mocked
  battle -- Red never plays Music_MuseumGuy for this escort (Blue
  shares the same code path: onStep only branches on
  GameVersion.isYellow(), never isBlue(), so a separate Blue run
  would exercise nothing new), and Yellow's hold before the Pikachu
  battle is armed for exactly 2 frames before handing off to
  BattleTransition rather than a bare stack push. Stops there rather
  than also driving the demo battle to completion just to assert
  Music_MuseumGuy fires in Yellow (that fix's own coverage): the
  extra coupling to unrelated battle menu/bag/throw frame budgets
  wasn't worth it for one more assertion. That side stays manually
  verified. scenario() restores Game/GameVersion and re-inits
  StateStack on the way out.

All three pass standalone (luajit tests/<path>). The Yellow E2E test
hits the same pre-existing "Music.playMap is nil" gap three other
map-warping parity tests already hit inside the aggregated
tests/run_tests.lua run (missing data/generated/audio.lua in this dev
environment) -- confirmed by diffing the identical error text against
parity_warp_after_warp_step.lua, which also passes clean standalone.
2026-08-11 16:55:06 +02:00
thibautbus ab94b96a79 Simplify comments 2026-08-11 15:23:32 +02:00
thibautbus cc43bd77d2 Give Oak's Pikachu catch the battle-start flash/wipe transition
story2.lua pushed the demo battle straight onto the stack, skipping
the flash + wipe that every other wild battle gets.

InitWildBattle calls DoBattleTransitionAndInitBattleVariables
unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN or
BATTLE_TYPE_PIKACHU special case -- so the old-man tutorial and Oak's
Pikachu catch get the wipe like any other wild battle, same as every
scripted trainer. Commands.old_man_demo already routed the Viridian
old man's tutorial catch through OverworldState:pushBattle for
exactly this reason; the fallback it used for a pushBattle-less
overworld (ctx.overworld and ctx.overworld.pushBattle then ... else
game.stack:push(battle)) was duplicated verbatim in Commands
.start_battle and would have been a third copy in story2.lua, so it's
pulled into one Commands.pushBattle(ctx, battle) helper instead, used
by all three call sites; the fallback branch now logs a warning
rather than silently dropping the transition and battle-theme start.
2026-08-11 14:01:19 +02:00
thibautbus d4dc72d0f4 Fix the old-man demo bag to show 1 Poke Ball in Yellow, 50 in Red/Blue
Confirmed against pokeyellow's engine/battle/core.asm: the Viridian
old man's demo and Oak's Pikachu catch share the same canned
one-item bag (SimulatedInputBattleItemList), quantity 1 -- pokered's
equivalent (OldManItemList, old man only, no Pikachu battle type) is
quantity 50. The port hardcoded x50 for both versions.
2026-08-11 14:01:06 +02:00
thibautbus 6d841526b1 Hold two frames on Oak's turn before the Pikachu battle starts
oak.facing was set and the battle pushed in the same synchronous
callback, so the overworld never rendered a frame of Oak already
turned toward the grass before the screen cut to battle.

Traced home/overworld.asm: PalletTownOakGreetsPlayerScript (the turn)
and PalletTownPikachuBattleScript (arming wCurOpponent) are separate
script ticks, one main-loop iteration apart. OverworldLoopLessDelay
burns two DelayFrame calls per iteration, calls RunMapScript (via
JoypadOverworld) first, and only then checks wCurOpponent to jump
into the battle -- so the turn from iteration A is on screen for the
two DelayFrame calls that open iteration B, before that same
iteration's RunMapScript arms wCurOpponent and falls straight into
the battle check. hold(2, ...) restores exactly that beat.
2026-08-11 14:00:59 +02:00
thibautbus 8e48bb4e32 Play the museum-guy cue during Oak's lab walk in Yellow
Yellow's Pallet Town intro (professor catches the wild Pikachu, then
walks the player to the lab) played the map's default Pallet Town
theme the whole time instead of the dedicated escort cue. Per
pokeyellow's actual scripts: PlayDefaultMusicFadeOutCurrent (run on
every battle exit) legitimately restores Pallet Town's theme after
the Pikachu demo battle for the Whew.../Come with me lines; the
escort cue (MUSIC_MUSEUM_GUY, the same "led by an NPC" theme Pewter's
museum guide uses) only starts in PalletMovementScript_OakMoveLeft,
the first function of the escort script -- but only in pokeyellow's
copy. pokered's copy of that same shared Red/Yellow function only
sets BIT_NO_MAP_MUSIC and leaves whatever was already playing
(MUSIC_MEET_PROF_OAK) running uninterrupted into the lab.

Start Music_MuseumGuy at the top of escortToLab, gated on Yellow so
Red/Blue keeps its unchanged behavior, and fix the Oak-escort warp's
keepMusic comment to say which song rides the warp in each version.
2026-08-11 14:00:40 +02:00
Shane McGovern a0279f5be2 Merge branch 'dev' into find-mods-release-stats
Resolve conflicts with the dev rewrite:

- LauncherView.lua / RomImporter.lua / ModUpdate.lua /
  docs/new-features.md: dev already carries the Find Mods stats &
  sort work (in evolved async form), so take dev's versions. The
  auto-merge's duplicate blocking _findStats is dropped.
- release.yml: keep dev's split version/love-payload/linux-arm64/
  xbox-uwp job structure and re-wire the Linux ARM SBC PortMaster
  step to needs.version.outputs.version (steps.ver no longer exists).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-11 11:30:15 +01:00
MaxTomahawk 2c8c800584 feat: add shared date and time formatting 2026-08-11 11:17:32 +02:00
MaxTomahawk ccb5358aad feat: expose public Pokemon icon presentation 2026-08-11 11:16:58 +02:00
MaxTomahawk 4db97164bb feat: anchor first checkpoint for cold restart 2026-08-11 08:59:47 +02:00
MaxTomahawk 882763cfe1 feat: checkpoint scripted battle decisions 2026-08-11 08:58:33 +02:00
david 798f3c25c0 Export legacy mod option schemas 2026-08-10 20:23:01 -07:00
anxiousintrovert d21a0edffc Fix hidden battle UI overlay backgrounds 2026-08-10 20:03:33 -05:00
david 02024fef58 Export versioned mod option schemas 2026-08-10 17:34:08 -07:00
Hanan Rodebaugh a4c51eccea expand cached data used when checking mods 2026-08-10 19:12:37 -04:00
Solidus Snake a9767e5df1 Add native TLS for Android love.system and desktop gen1tls.
Expose a non-blocking TLS socket API to mods (WSS clients) without bundling
any game-specific multiworld content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:43:53 -04:00
AverageConsumer e59175fc89 feat(mods): allow caught markers in wild battle HUDs 2026-08-10 23:36:38 +02:00
MaxTomahawk 59725c0ead feat(mods): add battle menu auxiliary action 2026-08-10 23:32:38 +02:00
MaxTomahawk 4e20f4585f fix(mods): emit restore lifecycle after title resume 2026-08-10 23:32:35 +02:00
MaxTomahawk 1c37867e52 fix(mods): keep title resume independent 2026-08-10 23:32:35 +02:00
MaxTomahawk 67491e2dac feat(mods): expose selected save chronology 2026-08-10 23:32:35 +02:00
MaxTomahawk 2d85c9595d test: cover title checkpoint resume recovery 2026-08-10 23:32:35 +02:00
MaxTomahawk b8138ef850 feat(mods): resume selected checkpoints from title 2026-08-10 23:32:35 +02:00
AverageConsumer e3fc8380cf feat(mods): expose safe overworld party reordering 2026-08-10 23:25:54 +02:00
github-actions b9afaaae60 chore(ios): update app-repo.json [skip ci] 2026-08-10 17:19:13 -04:00
bryanthaboi 4d03a38067 Merge pull request #1060 from bryanthaboi/dev
bugs and slugs
2026-08-10 17:11:22 -04:00
bryanthaboi 79ed37699e Merge pull request #1055 from AverageConsumer/codex/mod-grid-navigation-hooks
Mod API: allow alternate battle grids to own navigation
2026-08-10 17:08:24 -04:00
bryanthaboi abf0f9e98a Merge pull request #1057 from ShaneMcGovernIE/shanemcgovernie-fix-index-unexpected-character
Handle non-JSON update responses
2026-08-10 17:07:43 -04:00
bryanthaboi 3955721bfc Merge pull request #1058 from castdrian/ios-documents
feat(ios): expose app data in Documents dir
2026-08-10 17:07:05 -04:00
bryanthaboi c3855cebec more buggies CLOSES #960, CLOSES #961, CLOSES #968, CLOSES #995, CLOSES #1006, CLOSES #1009, CLOSES #1013, CLOSES #1021, CLOSES #1031, CLOSES #1044, CLOSES #1049, CLOSES #1050, CLOSES #1045,, 2026-08-10 17:06:24 -04:00
Adrian Castro 41e6507665 docs(ios): document public Documents storage 2026-08-10 22:56:15 +02:00
Adrian Castro 5482ac590d fix(ios): guard missing game payload 2026-08-10 22:56:15 +02:00
Adrian Castro 28440eb936 fix(ios): expose runtime data in Documents 2026-08-10 22:56:15 +02:00
Shane McGovern f606840216 Handle non-JSON update responses
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 20:51:57 +01:00
AverageConsumer 6a990bf79f feat(mods): allow alternate battle grids to own navigation 2026-08-10 21:22:09 +02:00
github-actions ab5fed60fe chore(ios): update app-repo.json [skip ci] 2026-08-10 15:16:03 -04:00
bryanthaboi e3a215e081 Merge pull request #1052 from bryanthaboi/dev
revisions and buggies
2026-08-10 15:05:40 -04:00
bryanthaboi 7fa758602a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-10 14:59:06 -04:00
bryanthaboi bb979911c8 Update common.sh 2026-08-10 14:59:04 -04:00
bryanthaboi f225f8a6e7 Merge pull request #918 from KikiManjaro/pok-nickname-changer-in-save
Add nickname editing to the save editor's mon inspector
2026-08-10 14:32:14 -04:00
bryanthaboi ee891fb8fa Merge pull request #993 from MaxTomahawk/feat/checkpoint-restore-event
feat(mods): signal verified checkpoint restores
2026-08-10 14:31:45 -04:00
bryanthaboi 9d73ff4110 yellow copyright diff 2026-08-10 14:27:00 -04:00
bryanthaboi 0b7ecf1599 Merge pull request #985 from steve1337/issue_#969_linux_app_image
[#969] Fixes linux AppImage
2026-08-10 14:21:57 -04:00
bryanthaboi aa45f05c63 Merge pull request #1038 from dlloa/upstream-pr/per-category-game-speed
Per-category GAME SPEED (overworld/battle/menu) + core.logic_speed hook
2026-08-10 14:15:59 -04:00
bryanthaboi 9234f12ed0 Merge remote-tracking branch 'origin/dev' into fix1038
# Conflicts:
#	docs/modding.md
2026-08-10 14:14:39 -04:00
bryanthaboi 26e0bd227e Merge pull request #1037 from dlloa/upstream-pr/platform-lifecycle-hooks
Add core.update / core.quit_to_launcher platform lifecycle hooks
2026-08-10 14:14:17 -04:00
bryanthaboi 64ce66a619 Merge pull request #1023 from AverageConsumer/feature/battle-ui-visibility
Mod API: expose battle UI visibility hooks
2026-08-10 14:12:57 -04:00
bryanthaboi 9302bbcbf3 Merge branch 'fix1037' into fix1038
# Conflicts:
#	docs/modding.md
2026-08-10 14:12:21 -04:00
bryanthaboi 1855f838ef Merge remote-tracking branch 'origin/dev' into fix1037
# Conflicts:
#	docs/modding.md
2026-08-10 14:11:45 -04:00
bryanthaboi 11af06a752 Merge remote-tracking branch 'origin/dev' into fix1023
# Conflicts:
#	docs/modding.md
2026-08-10 14:10:52 -04:00
bryanthaboi 05668591ef Merge pull request #965 from crusty/feat/worldapi-start-wild-battle
Add mod.world:startWildBattle
2026-08-10 14:09:28 -04:00
bryanthaboi e74beec32d Merge pull request #970 from swuff-star/modded-evo-item
add modding support for custom item effects
2026-08-10 14:09:23 -04:00
bryanthaboi 3efb2c9a6c Merge pull request #996 from thibautbus/fix/modkit-dump-dataset-utf8-decode
Fix: tools/modkit.py crashes on Windows when dumped text isn't representable in the system codepage
2026-08-10 14:09:19 -04:00
bryanthaboi d3af63e013 Merge pull request #1020 from ArmstrongThomas/agent/variable-size-overworld-sprites
Support variable-size anchored overworld sprites
2026-08-10 14:09:13 -04:00
bryanthaboi ca6bee64b9 Merge pull request #1042 from thibautbus/fix/android-ttf-dpi
Fix custom TTF rendering on Android
2026-08-10 14:09:07 -04:00
bryanthaboi b52d183dba Merge pull request #1024 from AverageConsumer/feature/semantic-pc-list-kinds
Mod API: expose semantic PC list kinds
2026-08-10 14:09:01 -04:00
bryanthaboi 23e1877a62 Merge pull request #1025 from AverageConsumer/feature/map-overview-detail-markers
Mod API: add detailed map overview data and POIs
2026-08-10 14:08:58 -04:00
bryanthaboi d8736fe33e Merge pull request #1015 from ShaneMcGovernIE/shanemcgovernie-fix-substitute-fainting
Fix Substitute exact-quarter-HP trainer softlock
2026-08-10 14:08:53 -04:00
bryanthaboi a74365a4fd Merge pull request #1043 from ShaneMcGovernIE/shanemcgovernie-fix-rival-collision
Fix champion rival walk-out route
2026-08-10 14:08:49 -04:00
bryanthaboi 1c2163bb5f Merge pull request #1046 from ShaneMcGovernIE/shanemcgovernie-fix-encounter-rate-grace-period
Fix wild encounter grace period
2026-08-10 14:08:46 -04:00
bryanthaboi 12c2677dc2 title screen issues, audio issues, and replacing gf c 2026-08-10 14:00:26 -04:00
Shane McGovern ae1b59ce7f fix: restore wild encounter grace period
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 12:15:18 +01:00
Shane McGovern 02c4de897d Fix champion rival walk-out route
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 10:59:06 +01:00
thibautbus 3c6242717e Use 1x TTF rasterizer on Android 2026-08-10 08:43:46 +02:00
david 3c71afb9fa Per-category GAME SPEED: overworld/battle/menu + core.logic_speed hook (RFC 0007)
GameSpeed is a single fast-forward multiplier applied uniformly to the
whole logic clock -- overworld walking, menu navigation and battle turns
all scale together. A player who wants 4X battles but 1X overworld (so a
cutscene or NPC dialogue doesn't blur past) has no way to get both.

Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each
cycling independently, with an automatic migration so an existing save's
speed choice carries over. Game.speedCategoryInStack resolves which
category is active by walking the state stack (the same idiom
wideBattleInStack/fillScaleInStack already use), so a menu opened mid-
battle inherits battle speed rather than resetting to whatever "menu"
defaults to. Adds a new core.logic_speed hook so a mod can read or
override the resolved multiplier for the current frame regardless of
which category produced it, sitting after the link-play and run-argument
overrides so neither is a seam a mod can defeat.

RFC 0007 status: Proposed.
2026-08-09 20:13:21 -07:00
david e79107c644 Add core.update/core.quit_to_launcher platform lifecycle hooks (RFC 0006)
A platform-specific launcher wrapper (a native shell embedding this engine,
owning its own UI around the game window) needs to pause the simulation
while its own UI is on top, live-reload options it wrote outside any Lua
UI, and veto main.lua's "closing the window returns to the Lua launcher"
behavior when it owns that job itself. Implementing this by hand-patching
main.lua's love.update/love.quit directly ties every such integration to
editing the one file every other engine change also touches, guaranteeing
merge conflicts. No existing hook covers "should the per-frame simulation
step run" or "should closing the window return to the Lua launcher."

Adds two generic, additive hooks (src/core/PlatformHooks.lua): core.update
and core.quit_to_launcher, replacing what would otherwise be inline
main.lua special-casing. Also adds Manifest.force_enable_env, letting a
mod that cannot function disabled on the one build where its env var is
set (a platform-bridge mod bundled only with that build) re-enable itself
regardless of a saved disable.

RFC 0006 status: Proposed.
2026-08-09 20:05:06 -07:00
Shane McGovern 9f54734f62 Add generic Linux ARM SBC PortMaster build
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 00:19:52 +01:00
steve1337 0afce96c8d [#969] Fixes linux AppImage
StartupWMClass matches what SDL reports: AppRun execs bin/love, so the
window's WM_CLASS / Wayland app_id is "love" and without this the taskbar
entry never resolves back to this desktop file (no name, no icon).
2026-08-09 22:52:38 +07:00
AverageConsumer d881997acd feat(mods): add detailed map overview POIs 2026-08-09 15:57:17 +02:00
AverageConsumer 8906133b93 feat(ui): expose semantic PC list kinds 2026-08-09 15:49:28 +02:00
AverageConsumer b9e3c4a689 feat(mods): expose battle UI visibility hooks 2026-08-09 15:46:33 +02:00
Thomas Armstrong 81f18e244d Support variable-size anchored overworld sprites 2026-08-09 04:10:39 -04:00
Shane McGovern ff91481018 Fix Substitute zero-HP boundary
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-09 00:17:51 +01:00
thibautbus fdb3184c71 Fix Windows crash: decode LuaJIT dump output as UTF-8, not the locale codepage
subprocess.run(..., capture_output=True, text=True) with no explicit
encoding falls back to locale.getpreferredencoding(False) -- the OS
default codepage. On Windows that's a legacy single-byte codepage
(e.g. cp1252), never UTF-8. When the LuaJIT dump contains a byte with
no mapping in that codepage, subprocess's internal _readerthread
crashes with an uncaught UnicodeDecodeError in a background thread; the
thread dies silently and the caller gets back stdout=None instead of a
string, crashing one line later with
AttributeError: 'NoneType' object has no attribute 'splitlines'.

Concretely, the Yellow-side imported dataset contains:
  "_ColosseumHeightText" -> "...6’8” tall!"
The right double quotation mark (U+201D) encodes in UTF-8 as E2 80 9D;
0x9D has no defined character in cp1252, so decoding as cp1252 fails
outright. Verified against the real imported dataset: the Red/Blue-only
dump has zero bytes outside cp1252's defined range; the Yellow dump has
exactly one, at this row.

UTF-8 is the actual encoding these dumps are produced in -- the driver
Lua sources are read/written as UTF-8 throughout this file, and LuaJIT
writes those source strings' bytes back out verbatim -- so passing
encoding="utf-8" explicitly at the three affected call sites
(run_loader, check_data_dump, dump_dataset) is a no-op on platforms
whose default codepage is already UTF-8 (Linux/macOS) and a correctness
fix on Windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 16:14:07 +02:00
MaxTomahawk aa3b2a18ec feat(mods): signal verified checkpoint restores 2026-08-08 13:28:56 +02:00
Jakub Lisicki d498016a47 Merge remote-tracking branch 'origin/dev' into feat/worldapi-start-wild-battle 2026-08-08 12:42:10 +02:00
Jakub Lisicki 492e0344b5 Fold the startWildBattle coverage into mod_world_tests
mod_world_tests is already the mod.world suite and already the T3 tier,
with the same off-the-world refusal pattern the standalone file was
duplicating. Reuses its liveWorld fixture instead of standing up a
second one.
2026-08-08 12:35:06 +02:00
Jakub Lisicki a710c64909 Refuse re-entrant, fractional-level and no-party wild battles
overworld() resolves the world from under the stack, so a call from a
battle hook stacked a second battle over the live one -- on a loss its
afterBattle blacked out and warped with the outer battle still up.

newWild marks the species SEEN before it reports an empty party, so a
refused call still wrote the Pokedex; test the party before building it.

tonumber accepts 5.5, which Pokemon.new writes straight into the stat
calc and the exp curve.
2026-08-08 12:23:05 +02:00
bryanthaboi 943ba5dcbf Merge pull request #991 from AverageConsumer/codex/mod-world-map-tiles
Mod API: expose visual tile data in map overviews
2026-08-08 06:16:24 -04:00
AverageConsumer 371edd1a02 feat(mods): expose map tile shading 2026-08-08 11:59:57 +02:00
Jakub Lisicki 9fc6ba5b01 Add mod.world:startWildBattle
Starting a wild encounter had no supported entry point, so mods built a
BattleState and pushed it themselves -- silently losing onFinish (and
with it evolutions and blackout-on-loss) and pushBattle (entry wipe,
battle theme). Neither failure raises.

Also covers awardExp -> leveledUp -> afterBattle -> checkParty, which
parity_trainer_evolution_order stubs BattleState out of.
2026-08-08 11:58:10 +02:00
bryanthaboi 983bea61aa Merge pull request #986 from MaxTomahawk/feat/mod-battle-checkpoints
feat: add persistent battle safe-point checkpoints
2026-08-08 05:46:34 -04:00
bryanthaboi ab74f7adf7 Merge pull request #974 from hernan0078/mods/spanish-ui
mods: a Spanish translation of the app's own text
2026-08-08 05:45:29 -04:00
bryanthaboi bdd0bf45bd Merge pull request #973 from hernan0078/options/cancel-through-strings
OPTIONS: route CANCEL through Strings so a translation mod can reach it
2026-08-08 05:44:34 -04:00
bryanthaboi 4b950fc23d Merge pull request #972 from hernan0078/ios/stadium-picker-kind
iOS picker: a "stadium" kind, and a way to ask which kinds exist
2026-08-08 05:44:22 -04:00
bryanthaboi af183d95ad Merge pull request #962 from AverageConsumer/feature/mod-world-map-overview
Mod API: expose a read-only active map overview
2026-08-08 05:42:55 -04:00
bryanthaboi 1d4d5d2337 Merge pull request #940 from Yukitty/patch-title_player
Allow TitleState player overrides
2026-08-08 05:42:34 -04:00
bryanthaboi 653b72d86a Merge pull request #925 from Bortlesboat/multiplayer-session-foundation
Add a reusable multiplayer session layer
2026-08-08 05:42:23 -04:00
MaxTomahawk 12b6ef6350 test: prove complete overworld progress fidelity 2026-08-08 09:53:41 +02:00
MaxTomahawk c979685a1f test: cover switched battle party fidelity 2026-08-08 09:53:41 +02:00
MaxTomahawk 5b1d0261ff test: prove public battle checkpoint roundtrip 2026-08-08 09:53:41 +02:00
MaxTomahawk 5bffc89a2f docs: specify battle checkpoint extension 2026-08-08 09:53:41 +02:00
MaxTomahawk 2ce612d5b1 test: prove deterministic battle checkpoint fidelity 2026-08-08 09:53:41 +02:00
MaxTomahawk 6999e5aecf feat: preserve overworld checkpoint RNG 2026-08-08 09:53:41 +02:00
MaxTomahawk 44a7910c69 feat: reconstruct standard battle continuations 2026-08-08 09:53:41 +02:00
MaxTomahawk 85c6fde443 feat: reconstruct battle checkpoints from data 2026-08-08 09:53:41 +02:00
MaxTomahawk 67b6dcc293 feat: capture data-only battle checkpoints 2026-08-08 09:53:41 +02:00
MaxTomahawk 24d9f279ec feat: expose settled battle checkpoint boundary 2026-08-08 09:53:41 +02:00
hernan 83f93c27a1 mods: a Spanish translation of the app's own text
A LANGUAGE-profile mod filling the `strings` registry -- the seam #791 opened
up, used for the thing it was opened for. 566 keys: the launcher, OPTIONS,
the save-slot and profile screens, the mod manager, the naming screen, and
the battle menu.

WHAT IT DOES NOT TOUCH is the cartridge. Dialogue, species, items, moves and
places all come out of the player's ROM and stay exactly as they are, so an
English cartridge is still an English adventure with Spanish menus around
it. The lang/ tables for those ship empty on purpose rather than absent:
they are where a full translation would go, and an empty value falls through
to English, so anyone continuing this can fill one row at a time and the
game stays playable throughout.

THE FONT IS THE CONSTRAINT, and it decided the wording. The 8x8 charmap has
no N-tilde, no accented vowels and no inverted punctuation -- the sole
exception in the whole atlas is the small e-acute of POKeMON. So every value
on an 8x8 surface is plain A-Z: DISENO COMBATE, MESETA ANIL, SEGURO? OTRA
VEZ. Not a spelling preference; a glyph that is missing renders as a hole,
which is how the first cut of this shipped "ESPA OL" to a phone. The
launcher draws with a real font and keeps proper Spanish, inverted marks and
all -- the split is by surface, not by taste.

Adding the glyphs to the atlas would let the 8x8 side read properly too, and
lang/font.lua and lang/charmap.lua are already the place that would hook
into. I have not done it here: it is a separate change with its own taste
questions, and it should not ride in on a catalog.

Tested end to end on iOS and macOS.
2026-08-07 22:16:06 -04:00
hernan d756b7cd43 OPTIONS: route CANCEL through Strings so a translation mod can reach it
CANCEL is appended after the `ui.options.rows` hook, deliberately -- that is
what stops a mod from orphaning the exit. But it also means no translation
mod can ever see it: there is no row for one to rewrite, and the hook has
already run by the time it is added.

The result is that a fully translated OPTIONS menu has exactly one English
word left on it, and it is the way out. I hit this with a Spanish catalog
where every row translated and the exit did not.

One call, matching how every other label on this screen is already built.
Nothing changes without a catalog loaded: Strings is an identity function
until a mod supplies one.

Follows the same reasoning as #791.
2026-08-07 22:14:29 -04:00
hernan 1e6f504a84 iOS picker: a "stadium" kind, and a way to ask which kinds exist
Two things, the second of which is the reason the first is safe.

A NEW KIND. pickFile("stadium") opens the document picker for a Nintendo 64
cartridge and lands it as picked_stadium.z64. The caller I wrote it for is
the Dramatic Shape voxel mod, which builds Pokemon Stadium battle models out
of the player's own cartridge -- on desktop it opens a dialog for that, and
on iOS it could only print a sandbox path to a screen and ask the player to
put a file somewhere they cannot reach from a phone.

Its own filename, not picked_rom.gb, because that is the name the Game Boy
importer watches: a 32 MB N64 ROM landing there is deleted and then reported
to the player as a broken cartridge.

WHICH IS ALSO WHAT AN UNKNOWN KIND USED TO DO. The switch's default case
treated anything it did not recognise as a Game Boy ROM, so a caller asking
for a kind the build predates lost the player's file -- the worst available
answer to "I have not heard of that one". Unknown kinds are refused now.

That refusal is invisible on its own: pickFile returns false, which is also
what "the picker would not open" returns, and a mod cannot tell them apart.
So the host says what it knows -- love.system.pickFileKinds() returns
"rom,mod,sav,stadium", or nil where there is no bridge. A caller asks first
and keeps whatever fallback it had; the voxel mod shows its folder note
again, which is what it did before any picker existed.

Tested on an iPhone 17 Pro: a Stadium cartridge imports from the picker and
the models build.
2026-08-07 22:13:50 -04:00
Hanan Rodebaugh abb4f3f9f0 better mod item registry 2026-08-07 20:24:11 -04:00
Yukita Mayako 5b0209a7ce feat(TitleState): add field.title.player 2026-08-07 14:28:25 -04:00
AverageConsumer c1e0685e97 feat(mods): expose read-only map overview 2026-08-07 20:20:57 +02:00
Bortlesboat 3c57b28c6d ci: retrigger cancelled checks 2026-08-07 13:16:06 -04:00
bryanthaboi cab62ff7b3 Merge pull request #903 from martin2844/martin2844/fix-blue-rom-setup
Fix Blue ROM source setup
2026-08-07 12:50:26 -04:00
bryanthaboi 5b6dfed359 Merge pull request #959 from MaxTomahawk/feat/reproducible-mod-packages
fix: make modkit packages reproducible with SOURCE_DATE_EPOCH
2026-08-07 12:43:50 -04:00
bryanthaboi 91a85a065b Merge pull request #927 from AverageConsumer/feature/second-screen-touch
Mod API: expose Android second-screen touch input
2026-08-07 12:41:48 -04:00
bryanthaboi 32e2c51365 Merge pull request #957 from ShaneMcGovernIE/shanemcgovernie-issue-916-trainer-sprite-at-end-of-fly-animation-9e5f77
Hide trainer sprite through the Fly/Dig warp fade (#916)
2026-08-07 12:41:05 -04:00
bryanthaboi 1992fdf398 Merge pull request #955 from ShaneMcGovernIE/shanemcgovernie-issue-945-cannot-edit-trainer-class-s-battle-theme-ad701b
Honor trainer battleTheme override (fixes #945)
2026-08-07 12:40:55 -04:00
bryanthaboi 59ac27971a Merge pull request #953 from ShaneMcGovernIE/shanemcgovernie-fix-reset-settings-bugs
saveOptions three-way merge: stop partial writes dropping launcher keys (#932)
2026-08-07 12:39:56 -04:00
bryanthaboi cd0ace2a39 Merge pull request #952 from MaxTomahawk/feat/mod-state-checkpoints
Add playthrough-scoped storage and stable overworld checkpoints
2026-08-07 12:39:32 -04:00
bryanthaboi 274c728e77 Merge pull request #950 from jherediagu/fix/translation-readiness
Translation-readiness: route the last hardcoded labels through the Strings catalog.
2026-08-07 12:36:47 -04:00
bryanthaboi 8547127f54 Merge pull request #948 from ShaneMcGovernIE/shanemcgovernie-issue-915-wrong-sprite-used-in-opening-2f6ea5
Fix Yellow Oak speech showing Nidorino instead of Pikachu (#915)
2026-08-07 12:36:37 -04:00
bryanthaboi 45d799aa98 Merge pull request #946 from ShaneMcGovernIE/shanemcgovernie-issue-931-mod-update-checks-fail-594ba9
Fix mod update checks failing on non-JSON responses (#931)
2026-08-07 12:36:25 -04:00
bryanthaboi 660c90626d Merge pull request #937 from Yukitty/patch-oak_speech_shrink
feat(OakSpeech): use field.playerSprites.walk in shrink step
2026-08-07 12:36:14 -04:00
AverageConsumer d9dc42895f chore: retrigger CI 2026-08-07 18:10:22 +02:00
MaxTomahawk 02fd21bcdb fix: honor source date in mod packages 2026-08-07 17:54:06 +02:00
MaxTomahawk af00d6ad14 feat: expose storage engine compatibility context 2026-08-07 17:14:42 +02:00
Shane McGovern 0d4a518a08 saveOptions three-way merge: stop partial writes dropping launcher keys (#932)
options.lua is a whole-file rewrite, so a caller handing saveOptions a
partial table (only the keys it changed) silently dropped every key it did
not mention: launcher-only keys like lastVersion, and keys the launcher set
(battleBg, tilt) all fell back to defaults.

saveOptions now reads the on-disk file first and folds caller-absent
values underneath before mergeOptions backfills defaults.  A table holding
every defaultOptions key is a full snapshot and stays authoritative, so the
fold is inert for all in-repo writers (every one passes loadOptions-ed
tables) and cannot resurrect the bindings/activeProfile deletions the
RESET REBINDS and mod-manager paths make on full tables.

Adds a regression suite (options_partial_write_bug932.lua) pinning the
merge, and updates the #828 suite's partial-write assertion, which now
expects lastVersion to survive a delta write.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 14:55:55 +01:00
MaxTomahawk 05b43ca258 feat: include engine version in checkpoints 2026-08-07 15:21:16 +02:00
MaxTomahawk 9d6ea845d7 fix: reject invalid checkpoint content 2026-08-07 15:09:16 +02:00
MaxTomahawk bbca4e8e39 docs: specify mod storage and checkpoint APIs 2026-08-07 15:05:43 +02:00
MaxTomahawk 49954ec4ad fix: allocate playthrough identity only on demand 2026-08-07 15:01:18 +02:00
MaxTomahawk 6e94625f2a feat: expose stable overworld checkpoints to mods 2026-08-07 14:49:45 +02:00
MaxTomahawk 0399ad040f feat: add playthrough-scoped mod storage 2026-08-07 14:43:04 +02:00
MaxTomahawk 726ed1102e feat: add opaque playthrough identity 2026-08-07 14:43:04 +02:00
Juan Heredia 5bc7d203a5 Route the last hardcoded UI labels through the Strings catalog
Wrap the battle stat box, the PC quantity footer and the dex metric
labels in Strings() (SummaryMenu -- and MoveEffects since #811 --
already do this); give the battle menu a 'battle' lookup context so a
translation can shorten FIGHT/ITEM/RUN independently of the pause menu;
align the metric dex rows with the imperial ones and make the No.
prefix translatable; make the title menu's recolor zone follow Menu's
auto-grown width; honor the declared-but-unread boot.title seam,
drawing an explicit versionRibbon as one centered piece. With an empty
catalog every path is pixel-identical to vanilla.
2026-08-07 13:59:01 +02:00
Shane McGovern ce204aaf16 CLOSES #916: hide trainer sprite through the Fly/Dig warp fade
After the Fly departure animation finished (bird off-screen) and during
Dig/teleport, the trainer sprite popped back in standing at the old cell
for the whole 32-frame black fade-out before the transition.  The
player-hide guard only held while a departure animation was live:
flyAnim went nil the instant path2 completed and the teleportOut
countdown cleared the spin fields at 0, but startWarpTo's Transition
(not isOpaque) keeps the overworld drawing beneath the veil, and the
arrival animation is not armed until setMap's midpoint.

Add a playerHidden flag on OverworldState that bridges the gap:
- set when each departure completes (flyAnim path2 / teleportOut hit 0),
  immediately before the warp starts;
- cleared in startWarpTo's Transition enter callback, synchronously
  after setMap and before the arrival arms flyArrive / spinDrop, so the
  player is never drawable mid-fade and never bare on the landing frame;
- folded into both player-draw guards.

ROM-free regression test (tests/engine/warp_sprite_hidden_bug916.lua)
drives the REAL Transition + setMap headlessly for Dig and Fly and
asserts zero fade frames leave the player drawable bare (would have
observed 31/32 gap frames before the fix).  Runs in the CI headless T2
tier.

Dig spin timing/lift and the black fade color are left as-is (fade is
intentional per #607).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 11:14:20 +01:00
Shane McGovern ed8a89c5ce Honor trainer battleTheme override (fixes #945)
trainers.battleTheme validated and merged onto the trainer record but was
never read: battle music came solely from data.audio.battle[kind] where
kind is computeMusicKind()'s final/gym/trainer/wild.  Route both battle-
theme start sites through a single choke point:

- BattleState:playBattleTheme() cues Music.playBattle with the override
  (self.trainer.battleTheme via battleTheme()), defaulting to the kind
  when unset, so vanilla fights and #782's non-gym Giovanni are unchanged.
- BattleState:enter() and OverworldController:pushBattle() both call it.
- Music.playBattle gains an optional 4th song arg that overrides the kind
  default, and real call sites now populate the music.select trainerId.
- Victory jingles stay kind-based: a custom battle theme has no derivable
  win-variant.

New ROM-free T2 suite tests/engine/trainer_battle_theme_bug945.lua covers
mod load, override resolution, the choke point, and the nil-override
parity gate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:53:42 +01:00
Shane McGovern 66e7d9432e Fix Yellow Oak speech showing Nidorino instead of Pikachu (#915)
Yellow's field.oakSpeech manifest carried only shrink frames, so
OakSpeech.lua's `oakGfx.demoSpecies or "NIDORINO"` fallback fired and
the opening speech showed Nidorino's sprite and cry instead of the
player's Pikachu.

- Stamp demoSpecies "PIKACHU" in the Yellow import manifest (source of
  truth for fresh ROM imports and developer builds).
- Stamp it in make_yellow_manifest.py so regeneration keeps the value.
- Repair stale Yellow caches in Data:applyVersionedFieldData() with a
  fill-if-absent block, matching the #617 oldManBattle RATTATA pattern.
- Add parity test (manifest carries PIKACHU; stale cache filled;
  pre-stamped value left alone).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:51:50 +01:00
Shane McGovern d59a9522ee Fix mod update checks failing on non-JSON responses (#931)
The mod update and Find Mods feeds feed the raw HTTP body straight to
Json.decode. When the endpoint hands back something that is not JSON
(an HTML error page, a proxy/captive prompt, or a plain-text outage
message like "Exceeded secondary rate limit" -- usually still HTTP 200),
the decoder's "unexpected character 'E'" assert escaped through the
pcall and became the error message, blaming the parser instead of the
response.

Add Json.describeUnexpected() as a pre-decode content-type guard: it
returns nil for body shapes the endpoints actually publish (JSON object
or array) and otherwise a short message naming what the server sent
(HTML page / plain text / empty, with a preview). Wire it into
ModUpdate.parseReleases and ModIndex.parse, so both the sync and async
update-check paths surface the real answer instead of the parse error.
HTTP status was already checked upstream by HostShell.httpGet (non-2xx
becomes "HTTP <code> from <url> (...)"); this closes the remaining
"2xx but not JSON" gap everywhere, including bridge platforms that
expose no status or headers.

Add regression tests for plain-text, HTML, and empty bodies; strengthen
the ModIndex HTML soft-fail test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:49:10 +01:00
Yukita Mayako f1661b62b3 feat(OakSpeech): use field.playerSprites.walk in shrink step 2026-08-07 00:28:24 -04:00
AverageConsumer a98562f46b feat(android): expose secondary-screen touch events 2026-08-06 22:56:13 +02:00
Andrew Barnes 365bd24740 Document multiplayer session boundary 2026-08-06 16:50:30 -04:00
Andrew Barnes d50ea4d067 Route tournaments through multiplayer sessions 2026-08-06 16:50:30 -04:00
Andrew Barnes 52c96437fa Route link play through multiplayer sessions 2026-08-06 16:50:29 -04:00
Andrew Barnes 2a7c8ec81b Harden multiplayer session terminal handling 2026-08-06 16:50:29 -04:00
Andrew Barnes bd6c606303 Add multiplayer session FIFO facade 2026-08-06 16:50:28 -04:00
bryanthaboi 112120e8fe Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-06 14:36:03 -04:00
bryanthaboi 00f6e3a7b4 CLOSES #889 2026-08-06 14:36:01 -04:00
kikimanjaro 9cc72f951b Add nickname editing to the save editor's mon inspector
The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape.

Input is gated at the field instead of refused at commit. Only glyphs the game
font can actually draw AND that round-trip through a .sav are accepted, capped
at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and
Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the
dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled.
2026-08-06 18:52:24 +02:00
github-actions 0f484682d8 chore(ios): update app-repo.json [skip ci] 2026-08-06 10:41:34 -04:00
bryanthaboi 60cf07fb0a Merge pull request #912 from bryanthaboi/dev
launcher update, switch and xbox updates
2026-08-06 10:32:17 -04:00
Martin 493caee715 Merge dev and fix headless ROM builder dependency 2026-08-06 16:22:34 +02:00
bryanthaboi aa6217e581 Merge pull request #910 from andrewqsantos/feat/switch-ota-unified-launcher
Feat Switch OTA Update
2026-08-06 10:20:23 -04:00
bryanthaboi a640f5504f Merge pull request #906 from caorthann-celt/dev
Fix Xbox mod downloads and internal ROM imports
2026-08-06 10:20:00 -04:00
Caorthann e01672b97d Update Xbox UWP assets to match upstream 2026-08-06 15:15:07 +01:00
Caorthann 9e3d487a08 Add UWP LocalState baserom discovery 2026-08-06 15:15:07 +01:00
Caorthann 506cd2c5c5 Enable UWP mod downloads 2026-08-06 15:13:54 +01:00
bryanthaboi 6cb0ac5666 Merge pull request #908 from johnjohto/fix-red-cache-folder-899
Move Red's extracted cache under red/ with a legacy migration
2026-08-06 10:09:34 -04:00
bryanthaboi b7d0ff2e16 Merge pull request #904 from ShaneMcGovernIE/shanemcgovernie-hit-sfx-pitch-regression-test
Guard hit-sfx noise pitch with a CI regression test (#902)
2026-08-06 10:09:08 -04:00
bryanthaboi 984cefdc7b launcher updates 2026-08-06 09:57:31 -04:00
johnjohto 6c892cb7c1 Move Red's extracted cache under red/ with a legacy migration
Importing Red unpacked data/generated, assets/generated and
rom-cache.complete straight into the save-dir root, while Blue and
Yellow land under blue/ and yellow/. Red now uses cachePrefix red/
like the others.

CacheFs.migrateLegacyRedCache moves a pre-existing root cache into
red/ on first boot, from RomImporter.new before the readiness loop
and from mountVersion, so existing installs keep their import instead
of being asked for the ROM again. The move only runs when the root
marker resolves to the save directory, so a dev checkout's source
tree is never touched, and the portable game folder is skipped when
it is the physfs source.

Closes #899
2026-08-06 09:55:28 -04:00
Andrew Quenehen 52d1ddc5af Fix Switch OTA clean and NX version chip test after launcher layout changes. 2026-08-06 10:29:43 -03:00
Andrew Quenehen 75cd28435c Update build scripts and tests for ports/switch OTA paths.
Point launcher, fused build, docs, and gitignore at the relocated Switch port tree.
2026-08-06 10:24:04 -03:00
Andrew Quenehen 9aa8b4e371 Move Switch OTA launcher sources under ports/switch.
Consolidate the native OTA launcher, bootstrap, and assets into the Switch port tree.
2026-08-06 10:22:51 -03:00
Andrew Quenehen e94d8e32ef Drop stb_image from Switch OTA launcher with a pre-baked logo asset. 2026-08-06 10:14:11 -03:00
Andrew Quenehen 3fae3088ca Fix Switch OTA launcher self-update via a bootstrap NRO.
A running launcher cannot replace its own NRO on sdmc/FAT; stage the new
binary and chainload a tiny helper from romfs to swap it in before loading
the game.
2026-08-06 09:59:01 -03:00
Shane McGovern cbb08f76ae Add ROM-free regression test for hit-sfx noise pitch (#826/#902)
The super-effective / not-very-effective hit sounds were only covered by
the manual ears-only driver (tests/drivers/hit_sfx_bug826_test.lua), so
the wFrequencyModifier wiring that fixed #826 (and that #902 reports as
a swap had no CI guard.  This suite pins the polynomial-counter bytes
for all three hit sounds against the real audio/sfx programs: bare they
read swapped (super effective ends duller), and pitched they read
correct (super effective 4 shift 1 crack, not very effective
shift 10 thud).  A port that drops the modifier fails 11 of 24 checks.

CLOSES #902

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
EOF
)
2026-08-06 13:35:07 +01:00
Andrew Quenehen dd503706b0 Merge origin/dev (v0.1.74) into feat/switch-ota-unified-launcher.
Keep Switch version chip alongside upstream's new quit button in the launcher header.
2026-08-06 09:28:26 -03:00
Andrew Quenehen eeb181bcc0 Wrap Switch OTA button labels in parentheses so A/B/+ read as controls. 2026-08-06 09:17:49 -03:00
Andrew Quenehen 287534cf26 Mount romfs before the quiet OTA release check so HTTPS can load cacert.pem.
ota_net_init() now mounts romfs and initializes curl before the GitHub check runs, fixing silent update-check failures after TLS verification was enabled.
2026-08-06 09:14:35 -03:00
Andrew Quenehen d08fce5fd5 Fix fused Switch build polluting GAME_LOVE path capture.
Route common.sh say() to stderr so sourcing it before pack_game_love does not
mix progress lines into command substitution output.
2026-08-06 09:07:58 -03:00
Martin 0ae29744c8 Fix Blue ROM source setup 2026-08-06 14:06:55 +02:00
Andrew Quenehen 84e1a5177b Harden Switch OTA install with TLS verification and reliable NRO replace.
Enable HTTPS peer checks via a romfs CA bundle, remove existing NROs before rename on sdmc, and surface launcher replace failures before writing version.txt.
2026-08-06 08:58:22 -03:00
Andrew Quenehen c7e72149f1 Tighten Switch docs prose and drop filler patterns. 2026-08-06 08:43:32 -03:00
Andrew Quenehen ecfca19f11 Consolidate Switch docs into three production guides.
Remove internal development and hardware-evidence docs, drop issue references, and update cross-links and doc gates.
2026-08-06 08:36:43 -03:00
Andrew Quenehen 90a53e509a Remove duplicate Switch port credits from the README footer. 2026-08-06 08:29:03 -03:00
Andrew Quenehen 15fc97a996 Unify --fused with OTA launcher and require DEVKITPRO preflight for release builds. 2026-08-06 08:28:31 -03:00
github-actions 35ed326dda chore(ios): update app-repo.json [skip ci] 2026-08-06 07:27:09 -04:00
bryanthaboi f4a1dd9e16 Merge pull request #900 from bryanthaboi/dev
some bugs
2026-08-06 07:18:33 -04:00
bryanthaboi 6f75a64c46 deleting stale tests 2026-08-06 07:11:16 -04:00
Andrew Quenehen dc04a4e848 Improve Switch OTA flow with download progress and clearer error screens. 2026-08-06 07:54:28 -03:00
bryanthaboi cd37aa175c Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-06 06:36:46 -04:00
bryanthaboi cb6cfb5556 CLOSES #883, CLOSES #887, CLOSES #894 2026-08-06 06:36:12 -04:00
github-actions 439e97aee6 chore(ios): update app-repo.json [skip ci] 2026-08-06 05:23:19 -04:00
bryanthaboi 5528525888 Merge pull request #892 from MyFriendPKMN/dev 2026-08-06 05:18:15 -04:00
bryanthaboi a49ba66c36 Merge pull request #871 from ratherDashing/linux-arm64-appimage 2026-08-06 05:17:55 -04:00
bryanthaboi 9de4db8531 Merge pull request #882 from bryanthaboi/dev 2026-08-06 05:17:04 -04:00
Marcelo Machado dbc48f377a feat(trainerCard): enhance player portrait handling with trueColor support 2026-08-06 00:15:07 -03:00
bryanthaboi 1d77d42fe2 Merge pull request #881 from AverageConsumer/codex/upstream-screen-render-visible
Mod API: allow active screen states to be hidden from the main render
2026-08-05 17:13:14 -04:00
github-actions 683fee8028 chore(ios): update app-repo.json [skip ci] 2026-08-05 16:49:41 -04:00
bryanthaboi a83d18fc51 Merge pull request #879 from bryanthaboi/dev
CLOSES #604, CLOSES #666, CLOSES #716, CLOSES #727, CLOSES #763, CLOS…
2026-08-05 16:42:56 -04:00
AverageConsumer b9e8b00af0 feat(mods): add screen render visibility hook 2026-08-05 22:42:37 +02:00
bryanthaboi e2820e02c5 CLOSES #604, CLOSES #666, CLOSES #716, CLOSES #727, CLOSES #763, CLOSES #781, CLOSES #784, CLOSES #799, CLOSES #801, CLOSES #810, CLOSES #828, CLOSES #834, CLOSES #838, CLOSES #849, CLOSES #852, CLOSES #857, CLOSES #863, CLOSES #864, CLOSES #867, CLOSES #869, CLOSES #870, CLOSES #872, CLOSES #839 2026-08-05 16:36:37 -04:00
ratherDashing f92364a002 Build SDL2, OpenAL and the codecs from source for the arm64 AppImage
CI on a headless ubuntu-24.04-arm runner caught what a desktop Pi could not:
the AppImage only started on a machine that already had a full desktop stack
installed. Three distinct causes, all from bundling Debian's builds of
libraries that Debian builds for a co-versioned system, which is the opposite
of an AppImage's situation.

1. Hard-linked backends. Debian's libSDL2 lists libpulse, libasound, libX11
   and libwayland-client as DT_NEEDED rather than dlopening them, so the
   loader demanded all four at startup; the CI job failed with
   "libpulse.so.0 => not found". Debian's OpenAL does the same through
   libsndio, which itself hard-links libasound. Built from source with
   --enable-*-shared and ALSOFT_DLOPEN, both dlopen their backends, so the
   image now runs on a Wayland-only session, a KMSDRM handheld with no X
   server, or a box with ALSA and no PulseAudio.

2. A stray link. Debian's libtheoradec is linked against libcairo, which
   drags in X11, xcb, fontconfig and freetype for a video decoder.
   --disable-examples leaves it needing only libogg.

3. SONAME collision with the host. OpenAL dlopens ALSA, ALSA's config loads
   its PulseAudio hook plugin, and that plugin pulls the host's libsndfile
   into the process. libsndfile links libogg, libvorbis and libmpg123 -- the
   same three we bundle -- and since the loader resolves a SONAME once per
   process it bound to our bullseye copies. A bullseye libmpg123 has no
   mpg123_info2 (added in 1.32), so the plugin failed to relocate, ALSA
   config collapsed, and the game ran with no audio device at all. Building
   them current means our copies satisfy the host's libsndfile instead of
   starving it.

The general rule, now stated as an assertion instead of a comment: never
bundle a library the host's own stack may also load unless ours is at least
as new as theirs. build_appimage.sh fails if any shipped object hard-requires
anything beyond glibc, libstdc++ and the font stack, and CI re-checks it on
the extracted artifact.

Host requirements drop from "a working desktop" to glibc 2.29+, libstdc++,
libfreetype6 and zlib. Bundled libraries drop from 13 to 10: libcairo,
libpixman and libsndio are gone entirely.

Verified on a Raspberry Pi 5 (trixie, Wayland): boots, imports, plays, and
audio works -- SDL 2.30 now picks the native Wayland backend rather than
falling back to XWayland as bullseye's 2.0.14 did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:53 -04:00
github-actions 9696c8d3e2 chore(ios): update app-repo.json [skip ci] 2026-08-05 14:59:49 -04:00
bryanthaboi 18b2bcd0a7 Merge pull request #873 from bryanthaboi/dev
bug fixes
2026-08-05 14:53:57 -04:00
bryanthaboi f56970350a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
# Conflicts:
#	data/scripts/story4.lua
#	src/ui/DexEntryMenu.lua
#	tests/drivers/fighting_dojo_bug197_test.lua
2026-08-05 14:43:05 -04:00
bryanthaboi 863f371e68 CLOSES #806, CLOSES #809, CLOSES #853, CLOSES #854, CLOSES #860, CLOSES #862, CLOSES #865, CLOSES #866 2026-08-05 14:38:10 -04:00
Andrew Quenehen 1bfc89fdd4 Fix Switch OTA parser for GitHub releases/latest JSON.
Scan asset objects by brace bounds instead of an 800-byte window so
browser_download_url survives fat uploader blocks; mirror logic in Lua
and add realistic API fixtures.
2026-08-05 15:15:44 -03:00
ratherDashing 24c5114745 Ship a Linux arm64 (aarch64) AppImage
scripts/build.sh's `linux` target only ever produces x86_64: it unpacks
LOVE's official love-11.5-x86_64.AppImage and re-fuses game.love into it.
There is no aarch64 equivalent to unpack -- LOVE 11.5 publishes win32,
win64, macOS, Android, iOS and exactly one x86_64 AppImage -- so arm64
desktop Linux (Raspberry Pi 4/5, Armbian, arm64 VMs on Apple Silicon) had
no artifact at all.

Compile LOVE 11.5 from the official linux-src tarball instead, inside a
Debian bullseye arm64 container, and assemble the AppImage from scratch.
Both pinned inputs (the LOVE source tarball and the AppImage type-2
runtime, on a dated tag rather than `continuous`) are SHA-256 verified on
the host, so the container runs with no network access.

Bullseye is the compile environment, not a claim about where the artifact
runs: glibc is backward but not forward compatible, so linking against the
oldest supported glibc is the only thing that makes one artifact work
everywhere. The binaries come out needing only glibc 2.29 / GLIBCXX_3.4.21,
covering Raspberry Pi OS bullseye through trixie and Ubuntu 20.04 onward.

The dependency walker copies in LOVE's own libraries and leaves the
driver-coupled, loader-coupled and font-stack libraries to the host. That
last category is not cosmetic: Debian's libtheoradec is linked against
libcairo, so a host cairo gets loaded into the process, and because the
loader resolves one SONAME once per process it then binds to whatever
libfreetype we bundled -- bullseye's 2.10.4 has no FT_Get_Transform, which
cairo 1.18 needs, and the game died at startup with a symbol lookup error.
Excluding the whole font stack makes the process self-consistent.

CI gets three path-gated jobs: an offline selftest on ubuntu-latest (pins,
the host-arch guard, the exclude list, the AppRun fusion contract), a real
build on ubuntu-24.04-arm that asserts the layout, that every bundled
object resolves under AppRun's LD_LIBRARY_PATH, and that the glibc floor is
still <= 2.31, and a release job that reuses the shared game.love payload.
None of it needs secrets or self-hosted hardware, so it runs on fork PRs.

Verified end to end on a Raspberry Pi 5 (Debian trixie, Wayland): the
launcher boots from the AppImage and renders correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:39:48 -04:00
github-actions 7def560726 chore(ios): update app-repo.json [skip ci] 2026-08-05 12:08:35 -04:00
bryanthaboi 6cbd0de77d Merge pull request #861 from bryanthaboi/dev
dunka dunka dunka dunka dunkaccino
2026-08-05 12:00:32 -04:00
bryanthaboi 9a8101df6a Merge pull request #859 from dburton95/modkit-validate-fedora-fix
Added Love stub table to validate codeblock
2026-08-05 11:20:51 -04:00
bryanthaboi 78744a6853 Merge pull request #856 from johnjohto/fix-karate-master-dex-entry
Show the Pokédex entry for the Fighting Dojo prize balls
2026-08-05 11:18:03 -04:00
bryanthaboi efce1bb116 Merge pull request #858 from johnjohto/fix-modkit-headless-love
Keep CacheFs.read from crashing when modkit runs headless
2026-08-05 11:17:07 -04:00
bryanthaboi 104c95a942 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-05 11:15:49 -04:00
bryanthaboi f6392e8932 CLOSES #788, CLOSES #795, CLOSES #796, CLOSES #797, CLOSES #805, CLOSES #826, CLOSES #833, CLOSES #835, CLOSES #837, CLOSES #844, CLOSES #845, CLOSES #846, CLOSES #847 2026-08-05 11:09:05 -04:00
Dorian Burton 88e34ef65c Added Love stub table to validate codeblock
This fixes the nil value returned when running validate or pack on Fedora 43. Since Love isn't running, luajit calls on an empty table. Providing a stub table resolves the nil error.
2026-08-05 10:55:58 -04:00
johnjohto 8f0117a145 Treat headless cache reads as misses in CacheFs
The modkit validate and pack drivers run the real loader under plain
luajit, with no love global.  With --base imported, Data:load falls
back to CacheFs.readActive for a generated module require cannot find
(an optional module like data/generated/audio.lua is legitimately
absent from developer and stale caches), and CacheFs.read indexed
love.filesystem once there was no portable root, so validate and pack
died with MK100 before the mod was even looked at.  Headless there is
no save directory to read from, so return nil like any other cache
miss.

Refs #850
2026-08-05 10:25:19 -04:00
johnjohto 1f878c3098 Show the Pokédex entry for the Fighting Dojo prize balls 2026-08-05 10:18:47 -04:00
Andrew Quenehen ae5276b2d1 Show the running app version on the Switch launcher header.
Add an NX-only yellow version chip so players can confirm which build is on the microSD after OTA or zip updates.
2026-08-05 02:08:20 -03:00
Andrew Quenehen 4dd4c5c463 Add launcher-styled framebuffer UI for Switch OTA prompts.
Replace the console flash with a quiet branded screen (RGB rail, logo, A/B buttons) that only appears when an update needs confirm.
2026-08-05 02:03:51 -03:00
Andrew Quenehen cafa6f3d61 Add Switch OTA launcher with unified SD zip and quiet UI.
Ships a dual-NRO native launcher that checks GitHub Releases, updates both NROs from gen1recomp-*-switch.zip with matching NACP versions, and stays silent unless an update needs confirm.
2026-08-05 01:48:39 -03:00
bryanthaboi f0ed2efe07 Merge pull request #832 from KikiManjaro/add-sprites-to-dex-of-file-editor 2026-08-04 21:10:08 -04:00
bryanthaboi bbf48c7e9e Merge pull request #831 from KikiManjaro/order-dex-in-file-editor 2026-08-04 19:04:30 -04:00
bryanthaboi 99d54b82b6 Merge pull request #829 from KikiManjaro/oversize-save-import 2026-08-04 19:04:19 -04:00
kikimanjaro 49776ca1eb Show front sprites in the save editor DEX grid 2026-08-05 01:03:01 +02:00
kikimanjaro 272305f3a4 Validate save imports by main-data checksum, not raw file size
The SAVE FILES card only accepted saves of exactly 32768 bytes and
refused anything else. importToSlot now classifies a non-32768 file by
the integrity of its main-data checksum instead:

- Oversize + valid checksum -> an emulator RTC footer, so the launcher
  asks for confirmation, then truncates to 32768 on force.
- Oversize + invalid checksum -> rejected.
- Undersize + valid checksum -> imports zero-padded; otherwise refused.

Adds the "Oversized save file" confirm modal, a new vendor-oracle test
built by gen1lib (PKHeX-derived) run as its own Lua 5.4 tier, oversize/
truncated policy tests, and the LUA54 wiring in test.sh.

# Conflicts:
#	src/import/LauncherView.lua
2026-08-05 00:50:35 +02:00
kikimanjaro 89f023c7dd Order the save editor DEX grid by Pokedex number or name
The DEX grid now defaults to Pokedex-number order with an A-Z view, set
from two chips in the header.  Sorting is view-only: it never dirties the
save, resets the scroll, and no-ops on a re-click.  Ops.dexList builds the
order deterministically and sorts partial mod records last.  Tests pin the
orderings against the real generated data.
2026-08-05 00:10:10 +02:00
github-actions afc5a52978 chore(ios): update app-repo.json [skip ci] 2026-08-04 16:55:32 -04:00
bryanthaboi 12a04f4188 Merge pull request #827 from bryanthaboi/dev
bang bang
2026-08-04 16:48:50 -04:00
bryanthaboi 0fe7a96781 Merge pull request #818 from johnjohto/fix-rare-candy-menu-796
Keep the bag open after using a Rare Candy
2026-08-04 16:38:21 -04:00
bryanthaboi a06a49bfed Merge pull request #820 from johnjohto/fix-gym-bag-full-797
Gym leaders skip the TM hand-over when the bag is full
2026-08-04 16:38:11 -04:00
bryanthaboi 86b90fd4b8 Merge pull request #821 from johnjohto/fix-held-direction-799
Rebuild held input after lifecycle resets
2026-08-04 16:37:59 -04:00
bryanthaboi d18824cfae bang bang 2026-08-04 16:37:21 -04:00
bryanthaboi 12b4c93279 Merge branch 'dev' into performance-enhanced 2026-08-04 15:21:23 -04:00
bryanthaboi af47e19e1a Rebuild the launcher and save editor on a small immediate-mode UI kit
The launcher spent ~9ms per frame building and drawing, and the Find Mods
tab could hang the window for minutes. Both had the same root cause: a
retained UI tree rebuilt every frame, and blocking curl calls made from the
draw path.

Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit,
Theme, Layout, Loader). The kit caches Text objects and all measurement,
allocates nothing in the steady state, and draws flat. Build+draw is now
under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF).

Move every network call off the render thread onto a love.thread pool
(src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab
stats, thumbnails and mod installs. Mod indexes prewarm at boot so the
Find Mods tab is populated before it is opened.

Paginate every list -- mods, find, save slots, settings, release notes,
versions -- with the page size derived from the real viewport height, so a
500-mod index costs what a 10-mod one does. Scrolling is gone.

Anything that waits now raises a non-dismissable loader; per-row background
work shows an inline spinner instead. The in-app updater moves to the top
right beside the settings gear and pulses when an update is waiting.

Theme is black with white outlines, no gradients or glows, and solid
colour-coded embossed buttons with bold labels. The game tabs keep their
cartridge colours. Everything is 1.3x larger. The save editor shares the
theme, and adding an item there is now a searchable pop-up like adding a
Pokemon.

Also:
- Reset rebinds, in Settings and under Touch Controls. Rebinds are additive
  (Input:applyBindings layers them over the defaults), so there was no
  in-game way to undo one.
- Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into
  a game for shortcuts and frontends, falling back to that game's tab when
  its ROM is not imported.

Fixes found while porting:
- Ellipsis and letterspacing truncated bytes, not codepoints, so a
  multi-byte mod name crashed the first frame on a Japanese index.
  Measurement no longer throws on malformed input either.
- The new font set missed UiFont's kana fallback, rendering translated
  builds as tofu.
- Fetch workers idle in Channel:demand() and LOVE waits for live threads at
  exit, so the process outlived the window; quitting mid-download also
  waited on curl's 300s ceiling. Shut the pool down in love.quit and bound
  its transfer timeouts.
- In one column the save-slot card drew below the fold, over the footer,
  with no scrollbar left to reach it.

The two FlexLove engine tests guarded a scroll manager and an auto-height
propagation bug that no longer exist; replace them with a kit suite covering
page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test
to assert the dependency is gone rather than that its perf guards are set.
2026-08-04 15:17:09 -04:00
bryanthaboi 4e666303d0 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-04 15:13:07 -04:00
bryanthaboi 0fa8206321 CLOSES #785, CLOSES #807, CLOSES #811, CLOSES #814 2026-08-04 15:13:06 -04:00
johnjohto 78ac154998 Rebuild held input after lifecycle resets
A focus flip, visibility flip, joystick add/remove, or resume reset all
held input, and a still-held direction never re-fires keypressed, so any
spurious reset (macOS Bluetooth re-enumeration fires joystickadded with
no hotplug) parked the player until every direction was re-pressed.
Reconcile from device ground truth after each reset; only what is
physically down comes back, so swallowed releases still clear.
2026-08-04 14:08:15 -04:00
johnjohto bbcaac7b71 Skip the gym leader TM hand-over when the bag is full
The originals run GiveItem before printing the received texts, and when
the bag can't hold the TM they print a make-room line instead and leave
EVENT_GOT_TM* unset, so talking to the leader again retries the give.
The victory reward path added the TM straight into the inventory, so a
full bag went to 21/20.

Route the gym TM give through Bag.add, split the TM lines out of the
victory dialogue table into tmPre/tmDialogue/noRoom, and port the
beaten-leader middle branch that re-runs the ReceiveTM script. Saves
that already hold the TM without the flag count as received so they
don't collect a second copy.

Refs #797
2026-08-04 13:44:46 -04:00
bryanthaboi 719ba49a85 Merge pull request #816 from johnjohto/fix-hm-menu-position-792 2026-08-04 13:18:38 -04:00
johnjohto 54ae20c69b Keep the bag open after using a Rare Candy (#796) 2026-08-04 13:18:32 -04:00
bryanthaboi f864837cbe Merge pull request #815 from johnjohto/fix-fly-menu-788-795 2026-08-04 13:18:25 -04:00
bryanthaboi 21191b8e80 Merge pull request #813 from johnjohto/fix-leech-seed-784 2026-08-04 13:18:08 -04:00
johnjohto 6426e913b6 Pin HM moves above STATS/SWITCH in the party submenu (#792) 2026-08-04 12:42:22 -04:00
johnjohto 3d3e42c6a3 Fix the fly map cursor start and cycle directions 2026-08-04 12:28:24 -04:00
johnjohto 6fe106f55c Stop offering the route Pokemon Centers as fly destinations 2026-08-04 12:28:08 -04:00
johnjohto ba8ac3d143 Tick residuals after each move in Gen 1 mode
In pokered, MainInBattleLoop calls HandlePoisonBurnLeechSeed right
after every Execute*Move (core.asm:426-464), so a seeded, poisoned or
burned mon takes its residual before the slower side acts. The port
ran the whole sweep in endOfTurn, which made leech seed behave like
Gen 3+ and never showed the drain animation (#784).

The residual sweep now runs per action under the gen1_faithful
ruleset, gated by a new residualAfterMove flag; modern_clean keeps
the end of round sweep. The leech seed drain plays the ABSORB
animation from the healing side, the way the original flips
hWhoseTurn before PlayMoveAnimation. Item, ball, failed run and
ghost-fear turns still tick the player's residual, matching
ExecutePlayerMoveDone.
2026-08-04 12:06:21 -04:00
github-actions 1963a5453e chore(ios): update app-repo.json [skip ci] 2026-08-04 11:10:21 -04:00
Shane McGovern eec396e388 Add the MODS tab sort options to the Find Mods tab 2026-08-04 15:11:55 +01:00
Shane McGovern 1ef0d8c2c0 Retry Find Mods stats after failed repo fetches
A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.
2026-08-04 13:39:36 +01:00
Shane McGovern 0f24105686 Fix Find Mods crash: require ModUpdate in the find panel
buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.
2026-08-04 13:37:48 +01:00
Shane McGovern d35b9e5c3e Fix crash opening the Find Mods tab: rename the stats cache field
The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.
2026-08-04 13:36:30 +01:00
Shane McGovern ee5c168983 Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them
A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.
2026-08-04 13:34:36 +01:00
1044 changed files with 258282 additions and 41727 deletions
+7 -1
View File
@@ -22,12 +22,14 @@ body:
id: game id: game
attributes: attributes:
label: Which game were you playing label: Which game were you playing
description: Pick every version you saw the bug in. description: Pick every version you saw the bug in. Use N/A if it isn't game-specific.
multiple: true multiple: true
options: options:
- Red - Red
- Blue - Blue
- Yellow - Yellow
- Gold
- N/A
validations: validations:
required: true required: true
@@ -35,12 +37,16 @@ body:
id: os id: os
attributes: attributes:
label: Which build are you running label: Which build are you running
description: Official release targets. Pick Multiple platforms if you saw it on more than one.
options: options:
- macOS - macOS
- Windows - Windows
- Linux - Linux
- Android - Android
- iOS - iOS
- Nintendo Switch
- Xbox
- Anbernic RG34XXSP
- Multiple platforms - Multiple platforms
validations: validations:
required: true required: true
+3 -2
View File
@@ -30,13 +30,14 @@ body:
id: game id: game
attributes: attributes:
label: Which game is this about label: Which game is this about
description: Pick every version it applies to. description: Pick every version it applies to. Use N/A if it isn't game-specific.
multiple: true multiple: true
options: options:
- Red - Red
- Blue - Blue
- Yellow - Yellow
- Not version-specific - Gold
- N/A
validations: validations:
required: true required: true
+3 -2
View File
@@ -27,13 +27,14 @@ body:
id: game id: game
attributes: attributes:
label: Which game is this for label: Which game is this for
description: Pick every version the mod should cover. description: Pick every version the mod should cover. Use N/A if it isn't game-specific.
multiple: true multiple: true
options: options:
- Red - Red
- Blue - Blue
- Yellow - Yellow
- Not version-specific - Gold
- N/A
validations: validations:
required: true required: true
+117 -3
View File
@@ -94,8 +94,8 @@ jobs:
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: gen1recomp-ios-ipa name: gen1recomp++-ios-ipa
path: dist/ios/gen1recomp.ipa path: dist/ios/gen1recomp++.ipa
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
- name: clean up signing keychain - name: clean up signing keychain
@@ -199,7 +199,7 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
else else
echo "changed=false" >> "$GITHUB_OUTPUT" echo "changed=false" >> "$GITHUB_OUTPUT"
@@ -260,6 +260,117 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
linux-arm64-changes:
name: detect Linux arm64 changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
linux-arm64-selftest:
name: Linux arm64 offline selftest
needs: linux-arm64-changes
if: needs.linux-arm64-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Deliberately on x86_64: everything this gate checks (pins, the
# host-arch guard, the dependency exclude list, the AppRun fusion
# contract) is answerable without a container or an aarch64 machine,
# so the slow native job below only ever starts on a sane tree.
- name: Linux arm64 offline selftest
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
linux-arm64-build:
name: Linux arm64 AppImage build
needs: [linux-arm64-changes, linux-arm64-selftest]
if: |
always()
&& needs.linux-arm64-changes.outputs.changed == 'true'
&& needs.linux-arm64-selftest.result == 'success'
# No fork restriction, unlike switch-build: this needs no secrets and no
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
# so contributors get the same coverage on their own PRs.
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Build the aarch64 AppImage
run: |
set -euo pipefail
scripts/build_linux_arm64.sh --version 0.0.0
- name: Verify the AppImage is self-contained and bullseye-compatible
run: |
set -euo pipefail
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
# --appimage-extract needs no FUSE, so this works on a runner
# without /dev/fuse and still exercises the real payload.
"$image" --appimage-extract >/dev/null
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
[ -e "squashfs-root/$required" ] \
|| { echo "::error::AppImage is missing $required"; exit 1; }
done
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash.
#
# This runs on a HEADLESS runner on purpose, and that is the point.
# The first version of this build bundled Debian's SDL2, which
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
# The whole point of compiling on bullseye. If a future change moves
# the builder to a newer base, the glibc floor silently rises and
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
- name: Upload the AppImage
uses: actions/upload-artifact@v7
with:
name: gen1recomp-linux-arm64
path: |
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
if-no-files-found: error
retention-days: 7
headless: headless:
name: headless suites (no ROM) name: headless suites (no ROM)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -272,6 +383,9 @@ jobs:
- name: install luajit - name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit run: sudo apt-get update && sudo apt-get install -y luajit
- name: install Pillow
run: python3 -m pip install --upgrade pillow
- name: interpreter version - name: interpreter version
run: luajit -v run: luajit -v
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: | run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')" artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')"
[ -n "$artifact_id" ] || exit 0 [ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}" head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')" pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
@@ -44,7 +44,7 @@ jobs:
uses: thollander/actions-comment-pull-request@v3 uses: thollander/actions-comment-pull-request@v3
with: with:
message: | message: |
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }}) [gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }}) **Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}` **Build Time**: `${{ steps.build-info.outputs.time }}`
+129 -14
View File
@@ -1,8 +1,9 @@
name: Release name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS # Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic # IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a # RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster
# handheld ports on the self-hosted Mac runner, and publishes them as a
# GitHub Release. # GitHub Release.
# #
# Versioning: # Versioning:
@@ -140,6 +141,36 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
linux-arm64:
name: build Linux arm64 AppImage
needs: [version, love-payload]
# GitHub's free arm64 runner for public repos. It has to be arm64: the
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
# container, and the qemu-emulated alternative takes hours.
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: .bazinga/work
- name: Build Linux arm64 AppImage
run: |
set -euo pipefail
scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love
- name: Upload Linux arm64 release
uses: actions/upload-artifact@v7
with:
name: gen1recomp-linux-arm64-release
path: |
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
if-no-files-found: error
retention-days: 1
xbox-uwp: xbox-uwp:
name: build Xbox UWP release name: build Xbox UWP release
needs: [version, love-payload] needs: [version, love-payload]
@@ -221,8 +252,40 @@ jobs:
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
} }
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
# it here and inject it in the release job before scripts/build.sh win.
native-tls-win:
name: build Windows gen1tls.dll
needs: version
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Setup .NET 8
uses: actions/setup-dotnet@v6
with:
dotnet-version: "8.0.x"
- name: Publish gen1tls (win-x64 Native AOT)
shell: pwsh
run: |
$out = "dist/native/win-x64"
New-Item -ItemType Directory -Force -Path $out | Out-Null
dotnet publish native/tls_dial/Gen1Tls.csproj `
-c Release -r win-x64 -o $out
if (-not (Test-Path "$out/gen1tls.dll")) {
throw "gen1tls.dll missing after publish"
}
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
- name: Upload gen1tls.dll
uses: actions/upload-artifact@v7
with:
name: gen1tls-win-x64
path: dist/native/win-x64/gen1tls.dll
if-no-files-found: error
retention-days: 1
release: release:
needs: [version, xbox-uwp] needs: [version, xbox-uwp, linux-arm64, native-tls-win]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps: steps:
@@ -238,6 +301,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
- name: Download Windows gen1tls dialer
uses: actions/download-artifact@v8
with:
name: gen1tls-win-x64
path: dist/native/win-x64
- name: Import signing certificate into a temporary keychain - name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
run: | run: |
@@ -276,13 +345,21 @@ jobs:
security find-identity -v -p codesigning "$KEYCHAIN_PATH" security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows + Linux - name: Build macOS + Windows + Linux
env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
run: | run: |
set -euo pipefail set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain); # Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a # notarize separately below so it uses secret credentials, not a
# login-keychain profile. "all" also builds the Linux AppImage, # login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization. # which needs no signing/notarization.
if [ ! -f "$GEN1TLS_DLL" ]; then
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
exit 1
fi
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
- name: Build Android - name: Build Android
run: | run: |
@@ -325,6 +402,20 @@ jobs:
# runtime from PortMaster-GUI, so it needs no signing/notarization. # runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}" ./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
- name: Build Linux ARM SBC PortMaster port
env:
# The release workflow must package the commit being released. The
# script defaults to the latest published release for standalone
# builds, while this explicit local override keeps CI source-aligned.
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
run: |
set -euo pipefail
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
# keeps its own cache because the two scripts use different staging
# layouts and runtime package paths.
./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}"
- name: Notarize & staple macOS app - name: Notarize & staple macOS app
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
run: | run: |
@@ -367,6 +458,13 @@ jobs:
name: gen1recomp-xbox-uwp-release name: gen1recomp-xbox-uwp-release
path: dist/xbox-uwp path: dist/xbox-uwp
- name: Download Linux arm64 release
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/download-artifact@v8
with:
name: gen1recomp-linux-arm64-release
path: dist/linux-arm64
- name: Stage release assets - name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
id: assets id: assets
@@ -379,13 +477,22 @@ jobs:
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip" cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip" cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip" cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
# its own runner because LÖVE publishes no aarch64 binary and the
# AppImage has to be compiled natively; ships as a runnable
# AppImage rather than a zip so `chmod +x && ./it` just works.
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
apk="$(find dist/android/debug -name '*.apk' | head -1)" apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa" ipa="dist/ios/gen1recomp++.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; } [ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa" cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip" swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; } [ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
@@ -403,6 +510,11 @@ jobs:
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; } [ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
# Linux ARM SBC PortMaster handheld port.
sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
[ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; }
cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip"
# Platform-independent update payload, built alongside the desktop # Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them). # apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love" love_file=".bazinga/work/game.love"
@@ -512,11 +624,13 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip" "dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip" "dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip" "dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa" "dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip" "dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-xbox-uwp.zip" "dist/release/gen1recomp-${v}-xbox-uwp.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" "dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
"dist/release/gen1recomp-${v}-sbc-portmaster.zip"
"dist/release/gen1recomp-${v}.love" "dist/release/gen1recomp-${v}.love"
"dist/release/sha256sums.txt" "dist/release/sha256sums.txt"
) )
@@ -534,14 +648,15 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
v="${{ needs.version.outputs.version }}" v="${{ needs.version.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa" ipa="dist/release/gen1recomp++-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json" app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; } [ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; } [ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
date="$(date -u +"%Y-%m-%d")" date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')" size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa" download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
bundle_id="com.theboisclub.gen1recompplusplus"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke" localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)" release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then if [ -n "$release_notes" ]; then
@@ -555,15 +670,15 @@ jobs:
--argjson size "$size" \ --argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')" '{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
if jq -e --arg version "$v" \ if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \ 'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
"$app_repo" >/dev/null; then "$app_repo" >/dev/null; then
jq --arg version "$v" --argjson entry "$entry" \ jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \ '(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
"$app_repo" > "$app_repo.tmp" "$app_repo" > "$app_repo.tmp"
else else
jq --argjson entry "$entry" \ jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \ '(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
"$app_repo" > "$app_repo.tmp" "$app_repo" > "$app_repo.tmp"
fi fi
mv "$app_repo.tmp" "$app_repo" mv "$app_repo.tmp" "$app_repo"
+36 -2
View File
@@ -3,8 +3,9 @@
data/generated/ data/generated/
assets/generated/ assets/generated/
# LÖVE packages # LÖVE packages & archives
*.love *.love
*.zip
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway) # Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
save/ save/
@@ -31,12 +32,29 @@ mobile/ios/love-src/
mobile/ios/cache/ mobile/ios/cache/
mobile/ios/build/ mobile/ios/build/
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*) # love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
.bazinga/love-nx/ .bazinga/love-nx/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh # Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/ /dist/
# Switch OTA launcher build outputs
ports/switch/ota-launcher/build/
ports/switch/ota-launcher/build-host/
ports/switch/ota-launcher/*.nro
ports/switch/ota-launcher/*.nacp
ports/switch/ota-launcher/*.elf
ports/switch/ota-launcher/*.map
ports/switch/ota-launcher/romfs/logo.rgba
ports/switch/ota-launcher/romfs/logo.png
ports/switch/ota-launcher/romfs/cacert.pem
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
ports/switch/ota-bootstrap/build/
ports/switch/ota-bootstrap/*.nro
ports/switch/ota-bootstrap/*.nacp
ports/switch/ota-bootstrap/*.elf
ports/switch/ota-bootstrap/*.map
# Legacy manual convenience-copy location (superseded by /dist/android/) # Legacy manual convenience-copy location (superseded by /dist/android/)
mobile/dist/ mobile/dist/
@@ -51,3 +69,19 @@ mobile/ios/bundle_id.local
/ports/uwp/build/ /ports/uwp/build/
/ports/uwp/third_party/*/source/ /ports/uwp/third_party/*/source/
/ports/uwp/third_party/angle/depot_tools/ /ports/uwp/third_party/angle/depot_tools/
# Native TLS dialer build output (dotnet publish)
/native/tls_dial/bin/
/native/tls_dial/obj/
/dist/native/
/dist/win/
/.bazinga/
# Local options / preferences
/options.lua*
# User-owned ROMs imported for individual mods. Manifests declare the
# destinations, but source checkouts and packaged mods never ship the files.
/mods/*/baseroms/
/imports/baseroms/
/imports/baseroms-recovery/
+118
View File
@@ -0,0 +1,118 @@
# AI Disclosure
This is a disclosure of the use of AI in this project.
## AI Use
Anyone who demands the dislosure of how AI was used in an engineering project,
has no idea what AI is, or how it works.
AI was used in this project as a tool. Several contributors used AI in their
commits, and so you will see like 7 commits by Claude or Codex or Cursor.
However those commits were reviewed by human beings, and it was declared that
the exact same fix would have been done by a human, so they were accepted.
AI was not used to make decisions, or to create the project.
If you would like to read more, well then continue reading:
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ante leo, luctus in semper a, maximus ut est. Vivamus nec magna vitae quam luctus suscipit nec eu orci. Vestibulum ut felis a dolor cursus vulputate. Phasellus pharetra elementum sollicitudin. Aenean elementum imperdiet ultrices. In risus mauris, scelerisque sed viverra in, iaculis non eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin quam justo, maximus sit amet fringilla in, tristique eget sapien.
Pellentesque rhoncus, erat nec elementum ultricies, augue purus suscipit augue, in condimentum nisl enim vel velit. Etiam at semper turpis. Vestibulum ipsum magna, ultrices non sem ut, hendrerit bibendum mi. Curabitur in varius sapien. Morbi posuere bibendum ex, at ultrices orci. Fusce feugiat ultrices varius. Suspendisse sed ante ligula. Sed dignissim lorem est, nec tristique arcu commodo sed. Cras consectetur sapien dolor, vitae finibus enim lacinia id.
Donec quis magna est. Maecenas dui arcu, venenatis sit amet libero nec, lacinia eleifend leo. Quisque lobortis vulputate lacus a elementum. Proin nec metus lectus. Donec eu auctor sem, at finibus ipsum. Curabitur eget dignissim justo. Donec lobortis leo eu arcu tristique, in volutpat augue eleifend. Morbi lacinia a risus in suscipit. Maecenas suscipit est eu interdum dictum. Cras in nulla imperdiet, dapibus mauris posuere, facilisis velit. Nunc dapibus, leo quis interdum tempor, elit mi mattis dolor, sagittis dictum mi urna sed lectus. Maecenas elementum, mauris id molestie dapibus, diam arcu egestas erat, at tempor justo orci vitae nibh. Etiam sagittis facilisis erat a vulputate. Praesent condimentum ac odio quis sollicitudin.
Fusce vitae orci vestibulum, sagittis dolor non, cursus urna. Morbi eleifend pretium pellentesque. Pellentesque ornare elementum sem in imperdiet. Maecenas dapibus, erat et lobortis porttitor, velit magna auctor odio, quis interdum elit est eu justo. In posuere euismod odio, in porttitor magna iaculis eget. In id quam pulvinar, ultrices dolor in, pellentesque dolor. Nunc varius ante at felis dictum, id porttitor sem efficitur. Integer pretium dignissim commodo. Suspendisse in est a arcu blandit faucibus. Donec quis lacus mollis, tincidunt nunc quis, suscipit nunc. Nunc non arcu dignissim, dignissim sem in, finibus neque. Aliquam non porta eros. Donec et pretium augue, non cursus eros.
Nunc at dignissim nisi. Nam nec metus augue. Proin nulla sapien, tristique a purus vel, vulputate commodo mi. Sed id erat leo. Quisque ullamcorper a nisl id molestie. Aliquam erat volutpat. Donec eget hendrerit mauris. Fusce tincidunt nisl a lorem tincidunt dapibus. Nam volutpat rhoncus tortor.
Nunc et sapien enim. Proin at nunc a nulla maximus consectetur nec eget tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris orci odio, sodales et elementum laoreet, porta at lacus. Maecenas vestibulum lectus risus, pulvinar scelerisque dolor posuere viverra. Proin gravida tellus vitae accumsan dignissim. Nunc non sapien aliquet ex cursus ultricies ac quis diam. Sed luctus feugiat risus eu tincidunt. Duis auctor lacinia fringilla. Donec pretium cursus magna a feugiat. Duis tristique, leo vulputate semper iaculis, est ipsum dapibus lacus, et molestie nulla enim nec ex. Nunc non feugiat neque.
Fusce euismod egestas elit ut pretium. Nulla eros quam, auctor sit amet faucibus eu, scelerisque eu neque. Sed nisi felis, lobortis in sapien a, tempor efficitur nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque ut justo est. Sed maximus, tellus in bibendum posuere, quam augue finibus eros, sed gravida arcu diam quis lectus. Donec eu placerat ligula. Ut quis imperdiet lorem. Maecenas a mi ac augue semper sodales. Curabitur in justo velit. Praesent et felis quis enim porttitor sagittis.
Nulla sed sagittis felis, sit amet placerat tortor. Ut metus est, sollicitudin ac turpis quis, aliquet congue lorem. Fusce auctor erat non convallis aliquet. Nullam sodales rutrum tellus ac malesuada. Quisque sem diam, iaculis in ultricies sit amet, fermentum quis sem. Integer condimentum placerat purus non lacinia. Integer hendrerit ultricies tellus, at dignissim nibh. Suspendisse accumsan eget tortor nec cursus. Proin accumsan rhoncus leo, eget pretium est tristique ac.
Sed feugiat sed diam a porta. Nullam varius lacus at fermentum fringilla. Morbi pharetra scelerisque pharetra. Nulla placerat vitae ligula non efficitur. Suspendisse quam dui, rutrum eget nulla eu, semper eleifend ante. Aenean ut condimentum arcu. Suspendisse auctor metus non sem ornare, vel tincidunt odio vehicula. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Donec vel metus ut libero sollicitudin posuere a nec nulla. Vivamus a scelerisque nisi. Aliquam eu sollicitudin tortor. Aliquam purus eros, blandit et quam et, pretium porttitor sem. Nunc iaculis arcu enim, et maximus sem malesuada in. Morbi nec nunc volutpat, semper diam sit amet, gravida elit. Vestibulum eu turpis vel lacus imperdiet congue. Donec rhoncus auctor sem.
Suspendisse eu lorem non dolor pretium finibus euismod quis dolor. Cras finibus egestas velit, commodo rutrum est placerat sit amet. Pellentesque vitae semper diam, sit amet auctor metus. Sed porttitor porttitor nunc, vel imperdiet neque volutpat quis. Sed hendrerit sapien et lacus imperdiet, nec hendrerit lorem sodales. Integer lobortis rutrum odio at ullamcorper. Aliquam tincidunt magna a tellus suscipit, at porta turpis dapibus. Ut ultricies auctor felis eu feugiat. Sed tempus sem et dictum fringilla. Nunc non pellentesque tortor. Suspendisse pulvinar, arcu ut imperdiet gravida, eros ex mattis mauris, vel ultricies est erat et dui. Praesent porttitor tortor et erat interdum efficitur. Phasellus et luctus lectus, et egestas ante. Praesent ex ipsum, rutrum id efficitur et, vulputate non tortor. Aenean maximus nunc ac purus sodales, et venenatis lacus laoreet.
Cras egestas ultrices dui, at tempor leo varius vitae. Donec porta, nisl nec ornare maximus, est arcu auctor mauris, varius elementum nisl arcu venenatis neque. Aenean metus quam, vestibulum eget justo non, hendrerit dapibus nunc. Vivamus diam ante, mattis sed nulla at, iaculis elementum magna. Sed massa diam, efficitur vel nunc sed, malesuada interdum tortor. Aliquam non neque aliquet ex imperdiet finibus eget ac neque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
Pellentesque fringilla tortor metus, luctus commodo leo gravida et. Fusce nec turpis at lorem rutrum porta vel in justo. Curabitur mattis suscipit felis, id dapibus arcu ornare ut. Proin sapien felis, pulvinar ut tristique vitae, aliquet ac dui. Vestibulum a erat tellus. Vestibulum sagittis dolor eget augue egestas fringilla. Fusce et purus a nunc auctor dapibus vel sed dui. Fusce interdum, libero vel pellentesque rutrum, urna massa iaculis tellus, et aliquam lectus diam vel sem. Donec a nunc et dui semper gravida. Donec posuere, eros eu consequat efficitur, justo metus ullamcorper lectus, et molestie massa ipsum eu sapien. Mauris eu suscipit neque. Morbi convallis sit amet leo a scelerisque. Cras ultrices libero ac mattis accumsan. Nam gravida ligula id erat semper ornare. Duis consequat ut ipsum eu volutpat. Quisque egestas sollicitudin ullamcorper.
Cras pellentesque quam non neque porta fringilla. Integer elementum, augue mattis blandit consequat, enim ipsum finibus ex, quis finibus neque eros eu ex. Fusce at urna justo. Donec erat eros, maximus id mauris vel, rutrum rutrum sapien. Morbi sed rutrum ex. Suspendisse lacus velit, varius ut elementum vitae, finibus non enim. Suspendisse vehicula euismod ipsum, id consequat nulla sodales vel. Morbi eu sem id leo congue dapibus a nec velit. Sed nec neque quam. Etiam rhoncus id nulla id volutpat. Nulla facilisi. Donec non maximus enim. Aenean consequat, sapien sit amet malesuada rutrum, erat sem euismod sapien, et feugiat lectus mauris id velit.
Fusce sodales porttitor gravida. Proin placerat ante nec nibh tempor aliquam. Sed ut diam eu sem fringilla malesuada. Maecenas aliquam risus vel quam dictum, at iaculis nibh pretium. Mauris convallis quam vitae dolor varius suscipit. Etiam nec fermentum dui. Aliquam in magna tincidunt, consectetur quam eget, aliquam purus. Ut dictum aliquet finibus. Pellentesque vel lacinia felis. Nulla malesuada vestibulum varius. Sed quam diam, efficitur id felis in, volutpat bibendum erat. Praesent luctus vulputate urna at interdum. Aenean ac aliquam eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi mollis, nisl vel consequat vehicula, massa tellus porta arcu, vel dictum sapien mauris sit amet nunc. Integer sed maximus neque, ac iaculis urna.
Integer non erat a leo euismod convallis quis eget magna. Morbi gravida ac urna sed ornare. Nunc vehicula mauris accumsan, ornare sem in, egestas mauris. Vestibulum vel vulputate felis. Nulla eu scelerisque diam. Suspendisse ac odio tempor nunc pellentesque hendrerit at a magna. Vivamus ultrices nunc ut orci fermentum pharetra. Nullam laoreet hendrerit ligula ut gravida. Proin scelerisque magna sit amet arcu malesuada, pharetra ultrices est molestie. Nullam pulvinar placerat dui, vitae hendrerit tortor luctus sed. Pellentesque elementum tellus eget arcu pulvinar varius.
Ut placerat, magna vitae tincidunt ultricies, est orci aliquet urna, at luctus augue erat vel ipsum. Fusce odio sem, venenatis vel consequat nec, bibendum sed dolor. Cras a sodales eros. Nullam eget dui congue, vehicula purus ut, condimentum dui. Maecenas libero ipsum, condimentum tincidunt nisi in, sodales lacinia ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
Duis gravida velit ac euismod suscipit. Proin sed ligula erat. Nullam eu ornare massa, non fringilla felis. Curabitur eu erat ex. Quisque sit amet dolor id arcu mattis scelerisque at at eros. In arcu nulla, fermentum non maximus eget, rhoncus in lacus. Nulla sit amet augue eu tortor vulputate congue. Vivamus laoreet condimentum tempus. Maecenas tempor, diam sed laoreet venenatis, mauris arcu lacinia enim, quis facilisis nunc turpis a ligula. Pellentesque quis placerat nisi, sit amet ullamcorper diam. Suspendisse a elementum elit, vel tristique dolor. Nam pretium ante tortor, vel tristique ipsum ultricies ut. Quisque non lectus imperdiet, placerat erat ac, pharetra tellus. In condimentum at magna a posuere. Aliquam et fringilla ipsum. Sed facilisis, nulla a finibus gravida, elit elit vulputate velit, dictum ornare est sapien a nisl.
Mauris eleifend vulputate felis sed mattis. Praesent id velit vitae ex porta pretium. Cras mollis malesuada justo, ut ornare quam placerat ac. Donec lobortis arcu tellus, luctus tempor mi malesuada quis. Maecenas condimentum libero vitae finibus malesuada. Vestibulum sollicitudin fringilla diam eget egestas. Sed vulputate urna nec ipsum maximus hendrerit. Maecenas blandit ex ut massa sodales, vitae tincidunt lorem ullamcorper. Phasellus vitae nisl ornare, cursus sem a, pulvinar arcu. Vestibulum faucibus risus nec tincidunt pellentesque. Pellentesque vel porttitor ex. Vivamus sollicitudin gravida lacus in suscipit. Aliquam urna neque, sodales quis quam ac, suscipit condimentum ante.
Morbi id arcu sit amet sapien ornare gravida eget quis sapien. In hac habitasse platea dictumst. In quis interdum ligula. Donec sed mi vulputate, scelerisque turpis vitae, interdum odio. Proin tristique condimentum arcu, et malesuada tellus convallis in. Fusce egestas maximus magna, sit amet convallis velit porttitor ut. Curabitur venenatis lacus ut blandit convallis. Phasellus scelerisque congue turpis eget vehicula. Nam venenatis mi sit amet rhoncus pretium. Nulla sed odio purus. Phasellus cursus id sapien ut feugiat. Duis et ipsum vel dui tempus porta. Curabitur non tortor consectetur, sollicitudin tellus mollis, sollicitudin lorem.
Ut quis ornare justo. Nunc aliquam, leo sit amet placerat placerat, dui nulla luctus dui, ac iaculis nisl orci id metus. Vestibulum nunc nunc, porta nec dictum id, feugiat et ante. Quisque lobortis, lacus tristique vestibulum rhoncus, massa nulla dignissim massa, at scelerisque massa nunc at velit. Donec eu ipsum nec dui luctus pulvinar et et turpis. Quisque eu neque erat. Donec varius egestas nunc, ut pretium libero tempor ac. Vestibulum pellentesque mi erat, et semper dolor semper vitae. Morbi enim dui, laoreet non venenatis sit amet, dignissim a orci. Sed id odio turpis. Phasellus non rutrum magna. Maecenas placerat arcu ultricies ultrices congue. Nulla quis neque ligula. Etiam in diam commodo, pharetra mauris ac, pretium nisi. Praesent sed nibh nec odio condimentum commodo vel vel lacus.
Pellentesque id libero vitae ex egestas pharetra placerat nec augue. Ut eget lobortis lorem, at vehicula sapien. Aliquam eu tincidunt ligula. Aenean et vestibulum dui, quis porttitor dui. Nullam quis dolor libero. Sed accumsan eros vitae nisi ornare congue. Aliquam nisi sapien, sollicitudin quis odio vel, pharetra maximus urna.
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi accumsan felis id urna malesuada, non vulputate velit maximus. Proin urna velit, viverra a metus vel, sollicitudin faucibus nulla. Vivamus ipsum lectus, pharetra sit amet varius vel, ullamcorper nec neque. Suspendisse potenti. Vivamus sit amet justo ac augue placerat hendrerit in eu felis. Integer luctus ex quam, in pellentesque eros interdum sed. Fusce finibus quis neque vitae efficitur. Pellentesque vulputate consectetur egestas. Integer a neque fermentum, tincidunt ligula id, gravida urna. Pellentesque ultrices, leo et suscipit accumsan, lorem nunc porta dui, non congue ligula leo ut urna. Duis vehicula risus in mi eleifend luctus. Duis convallis, mi faucibus pellentesque cursus, libero mauris varius sem, sit amet fermentum massa metus nec tellus. In tortor ligula, faucibus eu nibh id, lobortis viverra erat. Morbi non nisi suscipit, mollis enim at, convallis velit.
Pellentesque consequat imperdiet felis quis scelerisque. Duis aliquam mollis nibh quis tincidunt. Vivamus elit odio, blandit quis volutpat at, blandit nec tortor. Cras maximus ex at odio maximus, dapibus condimentum risus malesuada. Pellentesque viverra orci at ante commodo, quis posuere sapien efficitur. Nunc tristique imperdiet diam elementum lobortis. Fusce velit dui, ultrices id ante pharetra, fermentum egestas augue. Curabitur ante augue, vestibulum non magna quis, feugiat pulvinar diam. Suspendisse sagittis dui a tellus scelerisque, ut tincidunt neque accumsan. Cras pharetra metus vel eros tincidunt, vel tincidunt lacus egestas. Donec eget pellentesque sapien. Cras condimentum in justo pulvinar feugiat. Quisque malesuada ac odio eget rhoncus. Fusce posuere justo sed finibus ornare.
Duis a auctor tortor. Pellentesque lobortis auctor risus, ultrices varius mi cursus quis. Sed dui nulla, mattis sit amet justo mattis, condimentum commodo justo. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque hendrerit diam vel lacinia volutpat. Sed et luctus nunc, vitae congue arcu. Aenean placerat tincidunt ipsum. Ut molestie orci eu dapibus viverra. Cras sodales ullamcorper augue, at aliquam ex. Sed et justo augue.
Nullam feugiat risus et turpis faucibus, vel tincidunt nulla consequat. Aliquam libero erat, pellentesque sit amet tellus in, tempor ornare nisl. Donec viverra eget magna non pharetra. Cras sollicitudin, justo ut porttitor venenatis, risus nulla auctor est, at commodo sem urna in mi. Vestibulum mattis sapien vel nibh pulvinar, vel dignissim lacus cursus. Maecenas vel dictum tortor, ac ultricies justo. Praesent quis venenatis nisl. Morbi a diam fringilla, auctor lectus sed, varius est. Praesent faucibus auctor dolor, a commodo nisi mattis id. Fusce porta molestie ultrices. Sed pulvinar, leo ac consectetur hendrerit, erat velit gravida enim, eu blandit ligula justo sit amet neque.
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean libero risus, porttitor in congue vel, consequat sed felis. Vivamus eget blandit ante. Vivamus vitae mattis massa. In ut dolor sit amet tellus sollicitudin mattis. Mauris iaculis nisl neque, in gravida lectus ultricies nec. Fusce vehicula vehicula lacinia. Fusce viverra sed nisl id rhoncus. Donec sed porta mi. Nam tempus purus non massa tincidunt iaculis. Morbi viverra massa ut gravida vestibulum.
Proin dapibus mi a libero sagittis, id vehicula nulla iaculis. Proin a enim in tortor tincidunt egestas. Integer finibus neque eu nibh pretium, et pellentesque urna finibus. Integer a sollicitudin mauris, at convallis erat. Nullam sit amet lectus sed turpis commodo efficitur. Nulla nec turpis dapibus, suscipit diam quis, vulputate urna. Aenean sed posuere justo.
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed luctus purus nibh, id imperdiet risus pulvinar in. Quisque auctor sem lacinia turpis mollis, nec pretium ipsum suscipit. Aliquam sed metus sagittis, mollis nisi eget, hendrerit libero. Nulla sodales erat semper nisl condimentum, ultricies rhoncus lacus commodo. Ut suscipit libero augue, non vulputate dui tristique vel. Praesent convallis efficitur est, sed tincidunt mauris aliquet in. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Etiam a vehicula purus. Curabitur lacus erat, ultrices et dictum id, posuere ut risus. Etiam at auctor leo. Pellentesque eleifend est a metus lacinia rutrum. Nullam justo ligula, tempor non facilisis vel, volutpat sed nulla. Integer non dolor consequat, dapibus lectus eu, luctus turpis. Aenean enim erat, sagittis vitae ornare bibendum, faucibus sit amet magna. Phasellus suscipit ultrices faucibus. Sed at risus molestie, viverra ipsum vel, bibendum lectus. Pellentesque molestie vitae risus non viverra. Phasellus eleifend massa id odio sagittis mattis. Nullam velit mauris, viverra quis fermentum sit amet, vestibulum ut ex. Suspendisse imperdiet, sapien sed sagittis pharetra, nisi nibh vulputate metus, quis mattis dolor nisi ut lorem. Praesent vestibulum nibh vulputate lacus pulvinar tempor. Vestibulum vulputate diam ligula, vitae efficitur enim dapibus non. Etiam at ornare enim.
Curabitur aliquet velit enim, euismod faucibus urna euismod sit amet. Vivamus viverra vulputate nulla, ut gravida neque rutrum in. Suspendisse potenti. Nulla vitae neque felis. Etiam eu erat ac nulla ornare volutpat. Quisque ut diam dui. Sed ut massa quis dolor volutpat eleifend. Duis posuere dolor sit amet varius auctor. Donec mollis malesuada erat, eu luctus libero viverra feugiat. Curabitur fermentum velit eu purus fringilla, consequat tincidunt diam rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc volutpat vel est in vehicula. Morbi euismod, tortor ut posuere ullamcorper, velit justo ultricies lorem, vitae tincidunt erat ante vitae sem. Praesent semper, quam eget condimentum finibus, metus leo imperdiet augue, nec fringilla sem nunc id sapien.
Aliquam vestibulum ante porta sem finibus, ut rhoncus elit sodales. Phasellus a lacus congue, sagittis nulla eget, cursus libero. Duis laoreet fringilla faucibus. Aenean gravida lorem sed fringilla facilisis. Pellentesque sodales urna lorem, non rutrum tortor vulputate eget. Duis a enim semper, iaculis sem ac, facilisis urna. Donec iaculis nulla sit amet dignissim volutpat. Mauris cursus dui id feugiat suscipit. Fusce tempor placerat nulla vitae vestibulum. Vivamus imperdiet blandit nulla, in aliquet justo viverra in. Cras malesuada molestie ligula sit amet volutpat. Praesent ornare orci sit amet rutrum eleifend. Ut placerat metus felis, id malesuada justo mollis eget. Etiam mi turpis, pulvinar in pulvinar in, tincidunt in neque.
In venenatis euismod neque, eu convallis diam semper ac. Mauris auctor mi non massa vestibulum viverra. Aenean non turpis sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent gravida feugiat interdum. Vivamus sit amet consequat ligula. Praesent dictum nunc ac sapien ullamcorper consectetur. Ut malesuada blandit neque.
Duis tincidunt mauris sit amet odio dictum accumsan. Suspendisse efficitur nibh magna, ac dapibus augue vulputate nec. Etiam lectus neque, sollicitudin quis sapien vitae, aliquet fermentum mi. Aenean interdum interdum rhoncus. Donec eu libero urna. Donec semper lacus eu nunc scelerisque, vitae viverra quam consectetur. Integer sagittis, nulla congue venenatis auctor, purus justo mollis metus, at rutrum magna arcu nec est. Maecenas felis lorem, consequat non cursus vitae, lobortis vitae nisi. Cras eget magna justo. Nullam sagittis tellus id luctus ultricies. Etiam a arcu efficitur, consectetur libero non, imperdiet turpis. Donec ac velit et nisl semper semper. Duis iaculis interdum nunc sed tempor.
Sed diam odio, sagittis non dignissim nec, accumsan ac diam. Fusce sit amet dui sit amet justo ultrices viverra. Sed vel massa suscipit nibh porttitor laoreet in id nunc. Nam quis libero vitae nunc blandit sollicitudin et a lorem. Duis urna arcu, accumsan sed dignissim sit amet, vulputate at ex. Mauris porttitor libero mauris, vel fringilla diam euismod quis. Sed varius placerat tellus vel efficitur. Phasellus pulvinar gravida magna. Nulla dignissim consectetur finibus.
Nunc quis aliquet nisi. Cras luctus bibendum eros ac dignissim. Aenean suscipit felis vitae elementum eleifend. Proin commodo nunc non diam dignissim, in tincidunt nulla ultrices. Vivamus faucibus quam scelerisque interdum finibus. Sed porttitor vehicula urna, in laoreet arcu condimentum non. Praesent ac lacus diam. Vivamus aliquam euismod risus, luctus dictum lectus sodales et. Proin quis velit ac massa tristique scelerisque.
Sed non dolor efficitur, tincidunt mi eget, sagittis tortor. Quisque at varius felis, at finibus sem. Vestibulum vel lectus tincidunt, pharetra diam sit amet, interdum nulla. Sed ut elit tortor. Nam tincidunt tempus aliquam. Vivamus rhoncus faucibus sapien eget facilisis. Aliquam erat volutpat. Phasellus placerat aliquam lacus, eget ultrices orci pretium hendrerit. Fusce vitae dolor sit amet ante condimentum placerat. Nunc varius risus id tellus mollis, euismod luctus sapien viverra. Donec sodales est vel massa suscipit, eu sollicitudin ante convallis. Curabitur eu condimentum velit. Sed pharetra euismod tincidunt.
Nam at libero eros. Quisque bibendum, ligula quis sagittis ullamcorper, eros leo consectetur ex, quis elementum dolor justo vitae mi. Maecenas et elementum erat, et auctor enim. Nunc at nibh fermentum, ullamcorper mi elementum, facilisis erat. Vestibulum vestibulum leo ut pellentesque placerat. Suspendisse imperdiet nisl vitae justo sodales pellentesque. Interdum et malesuada fames ac ante ipsum primis in faucibus. In faucibus pretium nunc, sed interdum lectus vestibulum quis. Vestibulum luctus viverra ex at efficitur. Etiam ac est lorem. Maecenas mollis, orci at rhoncus congue, nulla leo rutrum dui, et pellentesque orci ligula eget ipsum. Suspendisse fermentum nisi turpis, ut sollicitudin purus imperdiet non. Maecenas vitae quam ornare, porta sem quis, rhoncus neque. Donec mattis purus a erat tristique, ac mollis est convallis. Duis vitae ipsum viverra, condimentum ante vel, sagittis ex. Maecenas placerat odio libero, id interdum turpis fermentum at.
Praesent faucibus nulla eget vehicula accumsan. Nulla elementum ante a nibh venenatis hendrerit. Proin nec nunc mattis, imperdiet nisl rutrum, sollicitudin libero. Nullam bibendum dignissim faucibus. Sed eget tortor vitae sapien cursus faucibus nec et lectus. Duis pharetra non odio id consectetur. Suspendisse at est sem. Nunc mauris ligula, ultrices id ante non, venenatis mattis erat. Vivamus sit amet viverra nisl.
Sed cursus vel nisi in mattis. Nunc porttitor dictum leo ac euismod. Sed blandit ornare nunc id lobortis. Aenean convallis ligula at volutpat commodo. Vestibulum sit amet laoreet urna. Donec et pellentesque orci, ac egestas nulla. In accumsan venenatis porta. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec scelerisque, metus nec viverra pharetra, dolor libero dictum velit, id ullamcorper enim nisi eu nibh. Aliquam nec dapibus quam. Curabitur vulputate, libero sit amet tempor ullamcorper, libero purus congue quam, nec sollicitudin orci erat non massa. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
Aenean faucibus mollis placerat. Praesent lacinia venenatis turpis eu scelerisque. Nam tempus tortor a varius posuere. Cras ut viverra tortor. Cras facilisis mauris ut ante imperdiet, a malesuada justo luctus. Ut eu enim sit amet arcu porttitor pulvinar ac ac odio. Ut odio neque, molestie vitae ligula quis, dignissim viverra erat. In ullamcorper erat sed elementum varius. Sed in lacus maximus, euismod nisi vitae, tempus mi. Nunc tellus justo, auctor at luctus ac, feugiat sit amet dui.
Donec imperdiet purus lorem, sed venenatis dolor finibus non. Aenean lacus nunc, elementum nec arcu eget, faucibus elementum turpis. Aliquam lacinia massa ac quam efficitur, et tincidunt eros pretium. Fusce condimentum mi vel pharetra egestas. Quisque consectetur nibh vel leo dignissim sollicitudin. Duis ultrices felis ipsum, sed maximus arcu ornare vitae. Curabitur porttitor ligula in turpis facilisis, id venenatis augue ultricies. Phasellus vel dolor id tellus finibus sodales ut quis nisi. Integer id orci cursus erat tincidunt sagittis non in nunc. Pellentesque ligula lacus, vestibulum eu ante vel, facilisis viverra massa. Sed ut tincidunt metus, vel tristique est. Ut et cursus justo. Ut ac porttitor eros, at dictum felis. Phasellus ornare nisi sit amet risus varius, sed sollicitudin nulla ornare. Donec aliquam ipsum urna. Aliquam id bibendum magna, quis venenatis diam.
Duis tempor odio id iaculis egestas. Cras consequat neque ac posuere iaculis. Nulla tempus et nisi eu auctor. Vestibulum metus massa, dignissim ut metus eget, ullamcorper consectetur turpis. Integer vel est tellus. Ut ac vestibulum massa. Pellentesque nec venenatis erat. Nam vel pellentesque lectus.
Morbi a placerat est. Ut eleifend ante ut placerat porta. Donec sagittis semper leo, ut scelerisque nisi imperdiet feugiat. Mauris purus turpis, consequat ut fringilla ac, cursus eget augue. Fusce arcu dolor, sagittis et facilisis ut, scelerisque non lacus. Aliquam sit amet eleifend tellus. Mauris id est luctus, iaculis tortor eget, gravida justo. Suspendisse at tellus nisl. Nullam felis erat, vehicula eu porttitor bibendum, pulvinar et dui. Sed molestie lacus nec sagittis rutrum. Aliquam erat volutpat. Nullam ut aliquet eros. Sed feugiat, massa id pharetra auctor, leo turpis condimentum purus, sit amet volutpat sem nunc sed nisi.
Pellentesque feugiat ipsum at accumsan iaculis. Morbi et dui in lorem commodo hendrerit. Mauris tempor ex mollis mollis blandit. Cras eu turpis feugiat, suscipit velit quis, volutpat magna. Vestibulum varius ligula ut quam mollis, a volutpat nibh lobortis. Sed sodales euismod leo non suscipit. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam ultricies sagittis justo, sit amet lacinia eros tincidunt tristique. Etiam faucibus turpis in lacus efficitur, vitae rutrum magna porttitor. Cras finibus eros vel ante semper, facilisis vestibulum erat accumsan. Ut mollis dui ut commodo varius. In et sem malesuada erat egestas pellentesque. Maecenas pulvinar sodales risus, at euismod ligula aliquet in. Quisque fringilla malesuada dui vel cursus. Curabitur eu ex vulputate, pretium elit non, sodales sapien. Integer egestas facilisis odio et pretium.
Integer eleifend, felis vitae faucibus tempus, tellus lectus placerat nunc, eu efficitur lacus mi vel nisl. Mauris commodo pretium feugiat. In aliquet nibh diam, ac egestas mauris consequat ut. Integer cursus, tortor pharetra pellentesque pulvinar, neque risus ultricies felis, et consequat felis eros at eros. Pellentesque fermentum velit ac sodales facilisis. Suspendisse vestibulum metus quis convallis lacinia. Donec in pharetra magna. Proin gravida dolor eget ligula lobortis sagittis.
Nullam consectetur ut massa id ultrices. Fusce consectetur at eros at mollis. Donec nec nibh fringilla, porttitor ipsum eget, aliquam neque. Quisque suscipit tortor in dui commodo, sed venenatis augue cursus. Etiam feugiat purus id justo elementum placerat. Sed interdum dictum nibh at sodales. Maecenas lobortis, metus ac sagittis lacinia, elit arcu varius felis, quis facilisis magna elit in leo. Proin condimentum orci sit amet dignissim imperdiet. Fusce sed iaculis felis. Maecenas sodales non magna vitae rutrum.
Nulla id ex massa. Sed vehicula sed quam non elementum. Aliquam luctus, enim vel molestie posuere, sem arcu laoreet justo, quis finibus nisl justo et magna. Vivamus malesuada elit in aliquam dignissim. Sed at tellus in orci vulputate ullamcorper. Integer magna sem, mattis id hendrerit non, tincidunt in est. Praesent posuere aliquet lobortis. Quisque euismod leo ut nisl pellentesque, et imperdiet dui dapibus.
Sed a erat nec risus pulvinar venenatis. Integer ultrices eros at aliquet efficitur. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque ac maximus ante. Sed sodales, nisi et sagittis accumsan, diam odio consequat elit, et mollis dolor turpis tincidunt metus. Proin justo quam, tincidunt id convallis a, molestie hendrerit massa. Etiam pharetra turpis eu ultrices mollis.
Proin fermentum libero in purus cursus molestie. In varius magna eu ante maximus, eget rutrum felis iaculis. Maecenas hendrerit, diam eget vestibulum vehicula, est quam porta magna, a dictum urna mi ut libero. Vestibulum dictum quis lacus vitae eleifend. Integer sapien libero, pretium vitae euismod eget, semper eget ante. Vivamus mollis elementum odio vel hendrerit. Phasellus tristique, metus eget luctus tincidunt, ex enim faucibus ipsum, at dictum eros urna ac mi. Aenean imperdiet felis eu ultricies egestas. Vivamus fermentum convallis nisi, non sollicitudin felis posuere nec. Ut commodo sit amet felis semper dictum. Aenean accumsan, tellus id blandit aliquet, lorem nibh pellentesque mi, sit amet volutpat erat ligula ut est. Vivamus non posuere velit. Sed vel rutrum diam, non pulvinar nulla. Suspendisse quis gravida lectus, ac accumsan justo. Sed lobortis neque ante, a imperdiet nisl iaculis nec.
+186 -7
View File
@@ -138,10 +138,186 @@ qualifies only if every record it writes lands in `text`, `strings` or
permission. Anything else and it is an ordinary content mod that happens to permission. Anything else and it is an ordinary content mod that happens to
ship text. ship text.
### 4. `mod.card` ### 4. `games` (and the legacy `gen2compat`)
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
VM and save format. The mod API is shared across both generations (same hook
names, same event names, same registry names) but Gold cannot serve all of it
yet, so Gen 2 is opt-in. Say which games the mod is for:
```json
"games": ["gen1", "gen2"]
```
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
generation (`"gen1"`, `"gen2"`) or `"all"`;
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
gen1,gen2` writes the key for you. The mod still installs to one directory,
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
Absent means Gen 1 only, which is what every mod written before the key existed
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
run your mod on Gold.
Every token is enforced, per game: the loader gates on the same
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
really does not load on Red and the skip line is the launcher's line, `For
Blue, not Red`, and `"games": ["gold"]` alone does not load on Red either. A
manifest with neither key still covers every Gen 1 game, so nothing written
before the key existed changes behavior; list both generations or say `"all"`
when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
writes with a report, and which hooks and events are still to come.
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
Gen 1 mod, and it is the one to start from.
Two consequences worth knowing before you claim Gen 2.
**Dependencies are contagious.** A mod whose hard dependency does not run here
is left out too, with the dependency's own wording (`depends on X, which does
not run here (For Blue, not Red)`). It is reported as a skip, not as a failure,
and neither mod lands on the boot error list, but the mod does not run, so
every hard dependency has to cover the same games.
**The player can override you.** The claim is yours, and a mod written before
the key existed can never carry one, so the manager's detail pane offers
`TRY HERE ANYWAY` for any mod that does not claim the game being played. It
persists per game in `options.modsGen2[id][version]` and takes effect on the
next boot; forcing a mod onto Red does not force it onto Gold. A forced mod
loads normally and keeps a note saying its author never verified it here.
**Prefer the API on Gold, but the Gen 1 names still work.** Gen 2 is a
parallel module tree behind `src/core/Game2.lua`. In new code take the live
game from `mod.game` (or the `game.ready` payload, or any `ui.*` hook's first
argument) and the world from `mod.world`; both resolve per generation, and
neither needs `engine_internals`.
For the mods written before Gold existed, a require made from a mod's own file
is answered on a Gold boot by an adapter presenting the Gen 1 API over Gen 2
internals. Fifteen names are served -- `src.core.Game`,
`src.world.OverworldController`, `src.world.Map`, `src.world.NPC`,
`src.world.Collision`, `src.world.WorldAPI`, `src.world.PikachuFollower`,
`src.world.FieldDefaults`, `src.pokemon.Boxes`, `src.script.ScriptRunner`,
`src.ui.PartyMenu`, `src.ui.StartMenu`, `src.ui.OptionsMenu`, `src.ui.BoxMenu`
and `src.battle.BattleState`. `src/mods/Gen2Compat.lua` is the full table and
publishes what it covers through `Gen2Compat.coverage(name)`, whose members are
`backed`, `warned` or `absent`. A name with no adapter (`src.script.Commands`,
`src.ui.OptionRows`) is reported against the mod that required it, and a member
an adapter cannot back is absent or logs once rather than answering wrongly.
Things no adapter can fix, all mod-side: a hardcoded version allow-list
(`GameVersion.get() == "red" or ...`) excludes you from Gold by construction;
Gold's builtin screen ids carry a `Gen2` prefix, so a string match on
`"BoxMenu"` matches nothing there; a write to a field on a live Gen 2 menu
instance is inert; and `map.warpAt` is a table on Gen 1 and a method on Gold,
so indexing it raises. Each has a route that works on both generations, in
`docs/preparing-your-mod-for-gen2.md`.
Check it statically, then load it headless:
```sh
python3 tools/modkit.py gen2check mods/my_mod
```
```lua
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
T.eq(run.mod and run.mod.state, "loaded",
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
T.eq(#run.errors, 0, "and loads with no boot errors")
```
Assert the state, not only the error count: a gate skip is deliberately not an
error, so `#run.errors == 0` passes for a mod that never ran a line.
`gen2check` answers `will load`, `will load but degrade` or `will not work`,
with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
line, for every reach a static scan could not follow. Neither substitutes for a
real Gold boot.
### 5. What a mod's code can reach
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself.
The globals the sandbox took away are still *reachable*, as compat
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
underneath. A mod written before the sandbox keeps working; it logs one
warning per call it should migrate, and the mod manager lists them. What
each stand-in actually does:
| Pre-sandbox call | What it does now | Migrate to |
| --- | --- | --- |
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
What has no stand-in, because there is nothing honest to reroute it to:
| Still refused | Why |
| --- | --- |
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach. Use `mod.fetch` for background HTTP (`network`) or `mod.job` for background compute (`background`) — both run your code inside the sandbox instead of outside it |
| `require("ffi")` | arbitrary C |
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
| `io.popen`, `os.execute` | spawning a process |
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have.
Three consequences worth knowing before you write against it:
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
mods no longer share a namespace, and neither can reach the engine's. To
publish something to another mod, put it on `mod.exports` and let them
`mod.find("your_id").exports` — the channel that was always the intended
one. The same goes for the standard library: `string`, `table` and `math`
are per-mod copies, so patching one is a local decision.
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
and `mod.assets:image` join to your own directory, and `..`, absolute paths
and drive letters are refused. So are `entry` and `options_schema` in your
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
folder you shipped; `mod:info` tells file from directory so a walk can
recurse.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player. `network` gates `require("socket")` and friends plus `mod.fetch`
(non-blocking HTTP), and `background` gates `mod.job` (compute on a worker
thread). Those two are the sanctioned ways to work off the main thread now
that `love.thread` is refused. There is no
permission that grants raw filesystem access, because no mod needs one:
everything a mod legitimately writes is already scoped by
`mod.storage` or the asset-transform derived root.
If your mod used one of the rerouted globals, the fix is almost always
`mod.storage`. The overlay is a compatibility floor, not a second storage
system: it is not scoped per playthrough, it does not migrate, and it is
the first thing that will be dropped once the mods on the index have
moved off it. Open an issue if you have a case `mod.storage` does not
cover.
### 6. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies, The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this, permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
The card is the *human-facing* one: who made this,
what it changes, what it does not do yet. It is never read by the loader's what it changes, what it does not do yet. It is never read by the loader's
merge — only by tooling and the manager's detail pane — so an absent or merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load. malformed card can never break a load.
@@ -159,7 +335,7 @@ Two fields deserve their own note:
distributed mod never carries ROM-derived bytes, not even in its preview distributed mod never carries ROM-derived bytes, not even in its preview
images. images.
### 5. Tags ### 7. Tags
Lowercase kebab strings, open vocabulary. The showcase generator Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`, lowercases and de-dupes. A recommended starting set: `beginner`,
@@ -217,12 +393,15 @@ registry or a new schema field lands with its catalog entry in the same PR
and the generator runs clean: and the generator runs clean:
```sh ```sh
luajit tools/gen_registry_docs.lua # in-repo default luajit tools/gen_registry_docs.lua # docs/modding/reference/registries.md
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout luajit tools/gen_registry_docs.lua ../project.wiki # Reference-Registries.md in a wiki checkout
``` ```
The prose reference lives in the GitHub wiki; the generated pages are With no argument it writes inside the repo, which is the copy `python3
written into a checkout of it, so they cannot drift from the engine. tools/modkit.py docs` regenerates and `--out` copies from. Pass a directory
(or set `POKEPORT_DOCS_DIR`) to write the wiki's flat page name into a wiki
checkout instead. The prose reference lives in the GitHub wiki; both copies
come off `src/mods/Schemas.lua`, so neither can drift from the engine.
### 5. Deprecation etiquette ### 5. Deprecation etiquette
+89 -25
View File
@@ -4,6 +4,10 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player. supplied by the player.
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION] > [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. > **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
@@ -53,13 +57,14 @@ supplied by the player.
This project does not include a ROM, emulate the Game Boy, transpile assembly, This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
only game content input. is the only game content input.
The ROM is verified, used during import, and then released from memory. It is The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
played side by side. side by side. Gold is Gen 2 Phase 1 (import + launcher; see
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
## Quick Start ## Quick Start
@@ -67,12 +72,13 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the `.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically. game starts automatically.
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
importer verifies SHA-1 before creating any game data: accepted. The importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
The packaged app contains neither a ROM nor pre-extracted game data. Music, The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact sound effects, and cries are synthesized while the game runs from compact
@@ -205,9 +211,42 @@ even on a different computer, as long as the same folder comes along.
already written to either location is touched automatically, so copy files already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch. over yourself if you want to carry existing progress across the switch.
## Launch Options
By default the app opens the launcher so you can pick a game. Launch options
skip it and start one game directly, which is what you want for a one-click
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| Option | Effect |
| --- | --- |
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
| `--slot=2` | load that save slot; takes a slot number or a slot id |
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
## Linux on arm64 (Raspberry Pi)
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
```sh
chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage
```
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
dlopened, so the same image runs on a full desktop, a Wayland-only session or
a KMSDRM handheld with no X server. Build instructions and the reasoning are
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
## iOS ## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To (Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md). [docs/ios-install.md](docs/ios-install.md).
@@ -222,6 +261,40 @@ build and install from source on a Mac instead, see
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a> <a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
</div> </div>
## Xbox Dev Mode
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
consoles in Developer Mode. It cannot be installed in retail mode.
Extract the archive, then use Xbox Device Portal to install the `.msix` and
the x64 package under `Dependencies`.
### External setup
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
Mod ZIPs can go on the same drive.
2. Connect the drive to the Xbox and open Gen1Recomp.
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
file picker.
4. Repeat the ROM import for each version you want to use.
### Internal setup
1. Create a folder named `baseroms` on your PC and place your legally obtained
Red, Blue, or Yellow ROMs inside it.
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
3. Launch Gen1Recomp once, then close it.
4. Open Xbox Device Portal and upload the ZIP to
`Gen1Recomp/LocalState/pokemon-love2d/`.
5. Choose **Yes** when Device Portal asks whether to extract the archive.
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that games tab shows ROM FOUND and an Import detected ROM button.
ROMs, generated game data, saves, and mods remain in LocalState and are not
included in the app.
Source builds and package details are covered in
[the Xbox UWP build notes](ports/uwp/BUILD.md).
## Handhelds ## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
@@ -231,24 +304,18 @@ Install steps, controls, and troubleshooting live in
## Nintendo Switch ## Nintendo Switch
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. console that can run Switch homebrew.
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
(author) and **V1 / Erista** boot (community).
- Players: [docs/switch-install.md](docs/switch-install.md) — download the - Players: [docs/switch-install.md](docs/switch-install.md). Download the
zip, extract at the microSD root (install or update), title-override zip, extract at the microSD root (install or update), title-override
launch, import your own legal ROM, Joy-Con controls and shortcuts. launch, import your own legal ROM, Joy-Con controls and shortcuts.
- Builders: [docs/switch-build.md](docs/switch-build.md) `--fetch` / - Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` /
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release** `--loose` / `--fused`, toolchain, Docker fallback, and CI vs release
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail). (path-gated ubuntu selftest, fused PR artifact on the main repo, release
hard-fail).
Limitations, Dusklight-derived method, and how we tested: - File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
[docs/switch-development.md](docs/switch-development.md) and
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
help — especially HOS / love-nx packaging and broader hardware coverage — is
welcome.
## Modding ## Modding
@@ -297,7 +364,4 @@ This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly. [pokered](https://github.com/pret/pokered) disassembly.
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p> <p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Executable → Regular
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 96 KiB

+10
View File
@@ -0,0 +1,10 @@
# gb_anim -- bundled touch skin
Bezel art and overlay layout from libretro's `common-overlays`
(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0:
https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the
reference skin for the RetroArch-overlay loader in
`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a
screen viewport, and page switching between the DMG and Color shells.
Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+89
View File
@@ -0,0 +1,89 @@
overlays = 2
overlay0_name = "GameBoy"
overlay0_overlay = img/gb_back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_range_mod = 1.0
overlay0_alpha_mod = 0.001
overlay0_viewport = "0.0,0.0,1.0,0.505"
overlay0_viewport_fill = true
overlay1_name = "GameBoyColor"
overlay1_overlay = img/gbc_back.png
overlay1_full_screen = true
overlay1_normalized = true
overlay1_range_mod = 1.0
overlay1_alpha_mod = 0.001
overlay1_viewport = "0.0,0.0,1.0,0.505"
overlay1_viewport_fill = true
# GameBoy
overlay0_descs = 18
overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635"
overlay0_desc0_overlay = img/gb_left.png
overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635"
overlay0_desc1_overlay = img/gb_right.png
overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417"
overlay0_desc2_overlay = img/gb_up.png
overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417"
overlay0_desc3_overlay = img/gb_down.png
overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay0_desc8_overlay = img/gb_a_b.png
overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay0_desc9_overlay = img/gb_a_b.png
overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay0_desc12_overlay = img/gb_start_select.png
overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay0_desc13_overlay = img/gb_start_select.png
overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay0_desc14_overlay = img/menu.png
overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay0_desc15_overlay = img/rotate.png
overlay0_desc15_next_target = "GameBoyColor"
overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay0_desc16_overlay =
overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay0_desc17_overlay =
# GameBoyColor
overlay1_descs = 18
overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635"
overlay1_desc0_overlay = img/gbc_left.png
overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635"
overlay1_desc1_overlay = img/gbc_right.png
overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617"
overlay1_desc2_overlay = img/gbc_up.png
overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617"
overlay1_desc3_overlay = img/gbc_down.png
overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay1_desc8_overlay = img/gbc_a.png
overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay1_desc9_overlay = img/gbc_b.png
overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay1_desc12_overlay = img/gbc_start_select.png
overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay1_desc13_overlay = img/gbc_start_select.png
overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay1_desc14_overlay = img/menu.png
overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay1_desc15_overlay = img/rotate.png
overlay1_desc15_next_target = "GameBoy"
overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay1_desc16_overlay =
overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay1_desc17_overlay =
+10
View File
@@ -0,0 +1,10 @@
# tv_crt -- bundled desktop bezel
CRT television border from libretro's `common-overlays`
(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed
CC-BY-4.0: https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream file, unmodified. It is the reference
DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons),
and a `viewport` naming the transparent screen hole, so the Game Boy
picture is fitted into the TV's tube instead of the whole window.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+6
View File
@@ -0,0 +1,6 @@
overlays = 1
overlay0_overlay = img/tv-integer.png
overlay0_full_screen = true
overlay0_descs = 0
overlay0_viewport = "0.2335,0.0855,0.5335,0.830"
overlay0_viewport_fill = true
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env bash
# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds.
# The package uses PortMaster control hooks and a self-contained LÖVE runtime,
# while keeping paths relative to the launcher for broad CFW compatibility.
#
# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64
# runtime so the device does not need a separate runtime download on first launch.
#
# Usage:
# ./build-linux-arm-sbc.sh [--version X.Y.Z]
# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z
# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z
#
# Output:
# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip
#
# Install on device:
# 1. Install PortMaster for the handheld firmware.
# 2. Unzip into the device's PortMaster ports folder so you have:
# Roms/Ports (PORTS)/gen1recomp-sbc.sh
# Roms/Ports (PORTS)/gen1recomp-sbc/...
# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/
# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that
# folder when zenity is missing).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/linux-arm-sbc"
WORK="$HERE/work/linux-arm-sbc"
DIST="$ROOT/dist/linux-arm-sbc"
APP_NAME="gen1recomp-sbc"
# Artifact suffix identifies this as the generic PortMaster SBC package.
# Release uploads stage it as gen1recomp-<ver>-sbc-portmaster.zip.
ARTIFACT_SUFFIX="portmaster"
PORT_DIR_NAME="gen1recomp-sbc"
LAUNCHER_NAME="gen1recomp-sbc.sh"
LOVE_VERSION="11.5"
# By default the pack is reproducible from the latest published GitHub release,
# not whatever happens to be in the caller's checkout. Development builds can
# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo.
SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}"
SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}"
VERSION="${GEN1RECOMP_VERSION:-}"
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest"
RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;;
--source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;;
--release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
command -v curl >/dev/null || fail "curl is required"
command -v zip >/dev/null || fail "zip is required"
command -v unzip >/dev/null || fail "unzip is required"
command -v tar >/dev/null || fail "tar is required"
mkdir -p "$CACHE" "$WORK" "$DIST"
download() {
local url="$1" dest="$2"
if [ -f "$dest" ] && [ -s "$dest" ]; then
return 0
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|| fail "download failed: $url"
mv "$dest.tmp" "$dest"
}
# --------------------------------------------------------------- source + game tree
# Release builds use the latest published source archive. A local checkout is
# an explicit override for development and for CI's just-built release source.
if [ -n "$SOURCE_DIR_OVERRIDE" ]; then
SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \
|| fail "source directory does not exist: $SOURCE_DIR_OVERRIDE"
SOURCE_DIR="$SOURCE_DIR_OVERRIDE"
SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}"
if [ "$SOURCE_TAG" != "local" ]; then
printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG"
fi
if [ -z "$VERSION" ]; then
VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)"
fi
else
if [ -z "$SOURCE_TAG_OVERRIDE" ]; then
latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \
| awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \
|| fail "could not resolve latest published release"
SOURCE_TAG_OVERRIDE="${latest_location##*/}"
fi
printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE"
SOURCE_TAG="$SOURCE_TAG_OVERRIDE"
SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz"
download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE"
SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG"
rm -rf "$SOURCE_EXTRACT"
mkdir -p "$SOURCE_EXTRACT"
tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT"
SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
[ -n "$SOURCE_DIR" ] || fail "release archive had no source directory"
if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi
fi
say "staging lovegame/ from $SOURCE_TAG"
GAME_SRC="$WORK/lovegame"
rm -rf "$GAME_SRC"
mkdir -p "$GAME_SRC"
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
# Stamp release version into the staged tree only (never the working tree).
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
"$GAME_SRC/src/core/Version.lua" \
|| fail "version stamp failed"
else
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
fi
# Portable marker: saves + ROM cache live next to the game on the SD card.
: > "$GAME_SRC/portable.txt"
# --------------------------------------------------------------- love runtime
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
LOVE_BIN="$CACHE/love.aarch64"
LOVE_LIB="$CACHE/liblove-11.5.so"
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
MODPLUG_LIB="$CACHE/libmodplug.so.1"
OGG_LIB="$CACHE/libogg.so.0"
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
# Sanity: love stub must be an aarch64 ELF.
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
# --------------------------------------------------------------- port tree
say "assembling port package"
PORT_ROOT="$WORK/port"
rm -rf "$PORT_ROOT"
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
"$PORT_ROOT/$PORT_DIR_NAME/conf"
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
# Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
see https://love2d.org/ for full terms.
EOF
# --------------------------------------------------------------- launcher
# Resolve the game directory from the launcher so this works with both
# PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
SHDIR="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Data/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
fi
if [ ! -f "$controlfolder/control.txt" ]; then
echo "PortMaster control.txt not found under $controlfolder" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
source "${controlfolder}/mod_${CFW_NAME}.txt"
fi
GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# GLES is the common path on ARM SBC handhelds; firmware may override it.
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
fi
if type pm_platform_helper >/dev/null 2>&1; then
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi
./bin/love.aarch64 "$GAMEDIR/lovegame"
if type pm_finish >/dev/null 2>&1; then
pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi
EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
# --------------------------------------------------------------- metadata
cat > "$PORT_ROOT/port.json" <<EOF
{
"version": 2,
"name": "gen1recomp-sbc.zip",
"items": [
"$LAUNCHER_NAME",
"$PORT_DIR_NAME"
],
"items_opt": null,
"attr": {
"title": "gen1recomp-sbc",
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
"source": "https://github.com/bryanthaboi/gen1recomp/releases/tag/$SOURCE_TAG",
"inst": "Requires a 64-bit Linux ARM handheld with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp-sbc/lovegame/, then launch and press Choose ROM.",
"genres": ["adventure", "rpg"],
"porter": ["gen1recomp-sbc"],
"image": {},
"rtr": true,
"runtime": null,
"reqs": [],
"arch": ["aarch64"]
}
}
EOF
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./$LAUNCHER_NAME</path>
<name>gen1recomp-sbc</name>
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
<releasedate>20250101T000000</releasedate>
<developer>the bois club</developer>
<publisher>the bois club</publisher>
<genre>RPG</genre>
</game>
</gameList>
EOF
cat > "$PORT_ROOT/README.md" <<'EOF'
## gen1recomp-sbc (Linux ARM SBC / PortMaster)
Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**.
### Install
1. Install PortMaster for your handheld firmware.
2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory.
3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`.
4. Refresh the launcher and launch **gen1recomp-sbc** from Ports.
### Controls
| Input | Action |
|--|--|
| D-pad | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Start / Select | Play or choose ROM |
Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime.
### First run
Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`).
### Thanks
LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team.
EOF
sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md"
rm -f "$PORT_ROOT/README.md.bak"
# --------------------------------------------------------------- zip
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
rm -f "$ZIP_OUT"
say "packing $ZIP_OUT"
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
say "done."
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/"
+7 -4
View File
@@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC"
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \ (cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src libs data assets tools/save-editor \ main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \ payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then printf '%s\n' "$payload_list" \
fail "payload unexpectedly contains generated ROM data" | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
fi && fail "payload unexpectedly contains generated ROM data"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing tools/rom_manifest_gold.json"
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC" unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip" rm -f "$WORK/game-payload.zip"
+15 -2
View File
@@ -6,18 +6,30 @@ function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1" local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1" local developer = os.getenv("POKEPORT_DEV") == "1"
local companion = nil
if arg then if arg then
for _, a in ipairs(arg) do for _, a in ipairs(arg) do
if a == "--editor" then editor = true end if a == "--editor" then editor = true end
if a == "--developer" then developer = true end if a == "--developer" then developer = true end
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
if port then companion = { port = tonumber(port), token = token } end
end end
end end
-- main.lua runs in the same Lua state right after conf.lua; stash the -- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`. -- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer _G.POKEPORT_DEV_MODE = developer
_G.POKEPORT_DISPLAY_COMPANION = companion
if editor then if companion then
t.identity = "pokemon-love2d-companion"
t.window.title = "gen1recomp Secondary Display"
t.window.width = 640
t.window.height = 576
t.window.minwidth = 160
t.window.minheight = 144
t.window.resizable = true
elseif editor then
-- Same identity as the game, deliberately: the editor edits the game's -- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this -- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an -- folder. A private editor identity would point love.filesystem at an
@@ -51,7 +63,8 @@ function love.conf(t)
end end
t.version = love._os == "iOS" and "12.0" or "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 t.window.vsync = 1
t.modules.joystick = true t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false t.modules.physics = false
-- love.system is not loaded during love.conf; love._os is set by the -- love.system is not loaded during love.conf; love._os is set by the
+1 -1
View File
@@ -92,7 +92,7 @@ return {
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE", { "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle) { "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11 { "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12 { "show_text", "_BoxIsFullText" }, -- 12
+6 -5
View File
@@ -10,10 +10,10 @@
local function coinGiver(opts) local function coinGiver(opts)
return function(game, ow, npc, done) return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local t = game.data.text local t = game.data.text
local function push(label, fallback, onDone) local function push(label, fallback, onDone, popts)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done)) game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
popts))
end end
if game.save.flags[opts.event] then if game.save.flags[opts.event] then
push(opts.alreadyGotLabel, opts.alreadyGotFallback) push(opts.alreadyGotLabel, opts.alreadyGotFallback)
@@ -30,9 +30,10 @@ local function coinGiver(opts)
end end
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount) game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
game.save.flags[opts.event] = true game.save.flags[opts.event] = true
Sound.play(game.data, "Get_Item1") -- the ReceivedNCoinsText strings carry sound_get_item_1
push(opts.receivedLabel, push(opts.receivedLabel,
("{PLAYER} received\n%d coins!"):format(opts.amount)) ("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
TextBox.soundOpts(game, "Get_Item1"))
end) end)
end end
end end
+5 -4
View File
@@ -22,8 +22,8 @@ return {
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local t = game.data.text local t = game.data.text
local function say(label, cb) local function say(label, cb, sopts)
game.stack:push(TextBox.new(game, t[label] or label, cb)) game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
end end
if game.save.flags.EVENT_GOT_OLD_AMBER then if game.save.flags.EVENT_GOT_OLD_AMBER then
@@ -39,8 +39,9 @@ return {
game.save.flags.EVENT_GOT_OLD_AMBER = true game.save.flags.EVENT_GOT_OLD_AMBER = true
Commands.hide_object({ save = game.save, overworld = ow, game = game }, Commands.hide_object({ save = game.save, overworld = ow, game = game },
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER") "MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
require("src.core.Sound").play(game.data, "Get_Item1") -- .ReceivedOldAmberText carries sound_get_item_1
say("_Museum1FScientist2ReceivedOldAmberText", done) say("_Museum1FScientist2ReceivedOldAmberText", done,
TextBox.soundOpts(game, "Get_Item1"))
end) end)
end, end,
+33 -7
View File
@@ -1,13 +1,39 @@
-- Hand-ported flavor text for OaksLab (registry id OAKS_LAB). -- Hand-ported OAKS_LAB flavor: the simple talk texts (scripts/OaksLab.asm;
-- Source: pokered/scripts/OaksLab.asm. These five text_asm bodies are -- OAK1, the starter balls and RIVAL live in data/scripts/oaks_lab.lua).
-- all simple "PrintText; jp TextScriptEnd" -- no flag branches, no
-- YES/NO menu -- so a one-row talk script showing the real extracted local TextBox = require("src.render.TextBox")
-- text is a faithful port. (The rest of OaksLab.asm's TEXT_OAKSLAB_*
-- constants -- OAK1, the three starter poke balls, RIVAL -- are already
-- ported with full branching logic in data/scripts/oaks_lab.lua.)
return { return {
OAKS_LAB = { OAKS_LAB = {
-- data/events/hidden_events.asm:147
onInteract = function(game, ow, fx, fy)
local t = game.data.text or {}
-- engine/events/hidden_events/oaks_lab_posters.asm:1
if fy == 0 and fx == 4 then
game.stack:push(TextBox.new(game,
t._PushStartText or "Push START to\nopen the MENU!"))
return true
end
if fy == 0 and fx == 5 then
local owned = 0
for _ in pairs(game.save.pokedex.owned or {}) do owned = owned + 1 end
game.stack:push(TextBox.new(game,
owned >= 2
and (t._StrengthsAndWeaknessesText
or "All POKéMON types\nhave strong and\vweak points\vagainst others.")
or (t._SaveOptionText
or "The SAVE option is\non the MENU\vscreen.")))
return true
end
-- engine/events/hidden_events/oaks_lab_email.asm:1
if fy == 1 and (fx == 0 or fx == 1) then
if ow.player.facing ~= "up" then return false end
game.stack:push(TextBox.new(game,
t._OakLabEmailText or "There's an e-mail\nmessage here!"))
return true
end
return false
end,
talk = { talk = {
-- OaksLabGirlText (scripts/OaksLab.asm) -- OaksLabGirlText (scripts/OaksLab.asm)
TEXT_OAKSLAB_GIRL = { TEXT_OAKSLAB_GIRL = {
+142 -9
View File
@@ -3,9 +3,8 @@
-- guide and SUPER_NERD2 garden nerd. -- guide and SUPER_NERD2 garden nerd.
-- --
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in -- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this -- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the -- is below.
-- YES/NO-branched flavor text is here.
local M = {} local M = {}
@@ -16,16 +15,147 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done))
end end
-- PrintText on a text_end string returns with the box still drawn and
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
-- engine/menus/text_box.asm); no A press clears the question first. Ride
-- TextBox's opts.choice, the same as Commands.ask (#854).
local function ask(game, s, cb) local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox") local TextBox = require("src.render.TextBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
local museumGuySteps = {
"up", "up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left",
"up", "up", "up",
"left",
}
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
local museumPlayerRle = {
"NO",
"up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left",
"up", "up", "up", "up", "up", "up",
}
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
local museumPreambles = {
["27,18"] = { "up", "up" },
["27,16"] = { "right", "left" },
["26,17"] = { "up", "right" },
["28,17"] = { "up", "left" },
}
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
-- pewterEscort.playerPlan in story5.lua
local function museumPlan(x, y)
local pre = museumPreambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
local function museumEscortWalk(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
if not plan then return false end
local Music = require("src.core.Music")
local t = text(game)
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
local head = plan.guyHeadStart
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
local function walkOut()
if not guy then return end
local i = 0
local function tick()
i = i + 1
if i > 4 then
guy.cellX, guy.cellY = 27, 17
guy.px, guy.py = 27 * 16, 17 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "down"
return
end
ow:scriptMove(guy, "down", 1, tick)
end
tick()
end
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
local function afterWalk()
if guy then
guy.stepFrames = nil
guy.cellX, guy.cellY = 13, 8
guy.px, guy.py = 13 * 16, 8 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "up"
end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCitySuperNerd1ItsRightHereText
or "It's right here!", walkOut)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = museumGuySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
if guy then
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
return true
end end
M.PEWTER_CITY = { M.PEWTER_CITY = {
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
talk = { talk = {
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you -- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
-- checked out the museum; YES -> fossils comment, NO -> "you have -- fossils comment, NO -> "you have to go" and the museum escort
-- to go" (which in pokered also kicks off the escort script).
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done) TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
local t = text(game) local t = text(game)
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
@@ -35,7 +165,10 @@ M.PEWTER_CITY = {
or "Weren't those\nfossils from MT.\nMOON amazing?", done) or "Weren't those\nfossils from MT.\nMOON amazing?", done)
else else
push(game, t._PewterCitySuperNerd1YouHaveToGoText push(game, t._PewterCitySuperNerd1YouHaveToGoText
or "Really?\nYou absolutely\nhave to go!", done) or "Really?\nYou absolutely\nhave to go!", function()
museumEscortWalk(game, ow)
if done then done() end
end)
end end
end) end)
end, end,
+6 -2
View File
@@ -23,9 +23,13 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done))
end end
-- PrintText on a text_end string returns with the box still drawn and
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
-- engine/menus/text_box.asm); no A press clears the question first. Ride
-- TextBox's opts.choice, the same as Commands.ask (#854).
local function ask(game, s, cb) local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox") local TextBox = require("src.render.TextBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end end
M.VIRIDIAN_CITY = { M.VIRIDIAN_CITY = {
+37 -14
View File
@@ -18,6 +18,26 @@ local M = {
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" }, VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
} }
-- The originals' middle branch shared by every leader's text_asm: beaten
-- but EVENT_GOT_TM* unset means the bag was full when the victory script
-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script.
-- Returns true when the retry took over the talk. A save from before
-- #797 already holds the TM without the flag; treat the owned TM as
-- received so those saves fall through to the advice text instead of
-- collecting a second copy.
local function retryTmGive(game, ow, victoryKey, done)
local reward = require("data.scripts.victories")[victoryKey]
if not (reward and reward.gotFlag) then return false end
if game.save.flags[reward.gotFlag] then return false end
local owned = game.save.inventory and game.save.inventory[reward.item] or 0
if owned > 0 then
game.save.flags[reward.gotFlag] = true
return false
end
ow:offerGymTm(reward, done)
return true
end
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent -- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints -- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
-- _PewterGymBrockPreBattleText and engages the leader battle -- _PewterGymBrockPreBattleText and engages the leader battle
@@ -25,12 +45,14 @@ local M = {
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from -- badge/TM34 rewards and EVENT_BEAT_BROCK come from
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his -- data/scripts/victories.lua OPP_BROCK#1). After the badge his
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to -- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
-- the GYM in CERULEAN..."). The original's middle branch (beat but -- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet
-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is -- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call
-- unreachable in the port: the TM is granted with the victory. -- PewterGymScriptReceiveTM34) retries the TM give when the bag was full
-- at the victory (#797).
M.PEWTER_GYM.talk = { M.PEWTER_GYM.talk = {
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done) TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
if game.save.flags.EVENT_BEAT_BROCK then if game.save.flags.EVENT_BEAT_BROCK then
if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text._PewterGymBrockPostBattleAdviceText game.data.text._PewterGymBrockPostBattleAdviceText
@@ -48,16 +70,17 @@ M.PEWTER_GYM.talk = {
-- (engageTrainer shows that same pre-battle text via resolveText; the -- (engageTrainer shows that same pre-battle text via resolveText; the
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua) -- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
-- -- and once beaten print the post-battle advice text. As with Brock, -- -- and once beaten print the post-battle advice text. As with Brock,
-- the originals' middle branch (beaten but the TM not yet handed over, -- the middle branch (beaten but the TM not yet handed over,
-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is -- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was
-- granted with the victory. -- full at the victory.
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc, -- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
-- done) and must call done() itself once whatever it's doing (e.g. a fade -- done) and must call done() itself once whatever it's doing (e.g. a fade
-- around a HideObject) finishes, rather than having it invoked -- around a HideObject) finishes, rather than having it invoked
-- automatically. Only Giovanni's farewell uses this. -- automatically. Only Giovanni's farewell uses this.
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice) local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey)
return function(game, ow, npc, done) return function(game, ow, npc, done)
if game.save.flags[beatFlag] then if game.save.flags[beatFlag] then
if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local finish = done local finish = done
if afterAdvice then if afterAdvice then
@@ -79,42 +102,42 @@ end
M.CERULEAN_GYM.talk = { M.CERULEAN_GYM.talk = {
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY", TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
"_CeruleanGymMistyTM11ExplanationText", "_CeruleanGymMistyTM11ExplanationText",
"TM11 teaches\nBUBBLEBEAM!"), "TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"),
} }
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already -- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
M.VERMILION_GYM.talk = { M.VERMILION_GYM.talk = {
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE", TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
"_VermilionGymLTSurgePostBattleAdviceText", "_VermilionGymLTSurgePostBattleAdviceText",
"A little word of\nadvice, kid!"), "A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"),
} }
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat -- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
M.CELADON_GYM.talk = { M.CELADON_GYM.talk = {
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA", TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
"_CeladonGymErikaPostBattleAdviceText", "_CeladonGymErikaPostBattleAdviceText",
"You are cataloging\nPOKéMON? I must\nsay I'm impressed."), "You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"),
} }
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat -- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
M.FUCHSIA_GYM.talk = { M.FUCHSIA_GYM.talk = {
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA", TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
"_FuchsiaGymKogaPostBattleAdviceText", "_FuchsiaGymKogaPostBattleAdviceText",
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"), "When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"),
} }
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat -- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
M.SAFFRON_GYM.talk = { M.SAFFRON_GYM.talk = {
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA", TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
"_SaffronGymSabrinaPostBattleAdviceText", "_SaffronGymSabrinaPostBattleAdviceText",
"Everyone has\npsychic power!\nPeople just don't\nrealize it!"), "Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"),
} }
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat -- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
M.CINNABAR_GYM.talk = { M.CINNABAR_GYM.talk = {
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE", TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
"_CinnabarGymBlainePostBattleAdviceText", "_CinnabarGymBlainePostBattleAdviceText",
"FIRE BLAST is the\nultimate fire\ntechnique!"), "FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"),
} }
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the -- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
@@ -142,7 +165,7 @@ M.VIRIDIAN_GYM.talk = {
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI") "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
end end
end, done)) end, done))
end), end, "OPP_GIOVANNI#3"),
} }
return M return M
+44 -49
View File
@@ -22,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall) rivalBallX, rivalBall)
return { return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1 { "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 22 }, -- 2 { "jump_if_true", 23 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating) -- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 25 }, -- 4 { "jump_if_false", 26 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef -- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily -- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text; -- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -34,44 +34,38 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ species = species, forceOwned = true } }, -- 5 { species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6 { "ask", askText }, -- 6
{ "jump_if_false", "end" }, -- 7 { "jump_if_false", "end" }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives -- scripts/OaksLab.asm:919
-- inside give_pokemon). Show the received text first so the { "show_text", "_OaksLabMonEnergeticText" }, -- 8
-- nickname prompt follows "you got X", matching Gen1. -- OaksLab.asm: ReceivedMon (sound_get_key_item) then AddPartyMon; the
-- The received text carries sound_get_key_item (OaksLab.asm -- jingle fires once the box has typed and holds it (#668)
-- OaksLabReceivedMonText); the jingle plays as the box opens { "text_sound", "Get_Key_Item" }, -- 9
-- (same beat as the Yellow port's starter, #668). { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
{ "play_sound", "Get_Key_Item" }, -- 8 { "give_pokemon", species, 5 }, -- 11
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9 { "set_flag", "EVENT_GOT_STARTER" }, -- 12
{ "give_pokemon", species, 5 }, -- 10 { "set_flag", choseFlag }, -- 13
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11 -- POKé BALLs come later, at OaksLabOak1Text's .give_poke_balls beat
{ "set_flag", choseFlag }, -- 12 -- once the Route 22 rival is beaten (see TEXT_OAKSLAB_OAK1 below)
-- POKé BALLs are not handed out here in the original -- Oak gives { "hide_object", "OAKS_LAB", ownBall }, -- 14
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "hide_object", "OAKS_LAB", ownBall }, -- 13
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14 { "move_npc_to", 1, rivalBallX, 4 }, -- 15
{ "face_object", 1, "up" }, -- 15 { "face_object", 1, "up" }, -- 16
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 17
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17 { "hide_object", "OAKS_LAB", rivalBall }, -- 18
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item) { "text_sound", "Get_Key_Item" }, -- 19 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText", { "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 19 or "BULBASAUR" } }, -- 20
{ "jump", "end" }, -- 20 { "jump", "end" }, -- 21
{ "jump", "end" }, -- 21 (spacer) { "jump", "end" }, -- 22 (spacer)
-- a leftover ball after the player's pick: Oak turns to face the -- leftover ball: Oak reads the last-mon line (scripts/OaksLab.asm
-- player and reads the last-mon line instead of re-offering the -- OaksLabSelectedPokeBallScript -> OaksLabLastMonScript, #601)
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript -> { "face_object", 5, "down" }, -- 23
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled { "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 24
-- out as Pokémon here.
{ "face_object", 5, "down" }, -- 22
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall -- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
-- through into the pre-pick line below (#601 remnant, reported on #600) -- through into the pre-pick line below (#601 remnant, reported on #600)
{ "jump", "end" }, -- 24 { "jump", "end" }, -- 25
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25 { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 26
} }
end end
@@ -106,8 +100,8 @@ return {
{ "check_item", "OAKS_PARCEL" }, { "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" }, { "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" }, { "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 }, { "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" }, { "stop_music" },
@@ -128,8 +122,8 @@ return {
{ "face_object", 1, "up" }, { "face_object", 1, "up" },
{ "face_object", 5, "down" }, { "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" }, { "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" }, { "face_object", 1, "up" },
@@ -246,9 +240,17 @@ return {
-- the table sprites; re-entering the lab applies the same HideObject -- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex). -- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow) onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then local flags = game.save.flags or {}
return if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
local rival = ow:npcByIndex(1)
if rival then
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
rival.cellY = 4
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
end
end end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow } local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -286,6 +288,8 @@ return {
-- fanfare for the taunt/challenge exchange, same as the Yellow port -- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596). -- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = { local rows = {
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
{ "face_player_dir", "up" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1 { "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
@@ -309,28 +313,19 @@ return {
local base = #rows local base = #rows
local party = flags.EVENT_CHOSE_BULBASAUR and 3 local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1 or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party }) table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout -- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" }) table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 }) table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player. The fanfare was dropped here (#683) -- the -- walk out past the player. The fanfare was dropped here (#683) -- the
-- parcel scene above already plays Music_MeetRival on both arrival and -- parcel scene above already plays Music_MeetRival on both arrival and
-- departure (lines 144-146), and this exit should match (#596). -- departure (lines 144-146), and this exit should match (#596).
table.insert(rows, { "stop_music" }) table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" }) table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" }) table.insert(rows, { "play_music", "Music_OaksLab" })
+27 -15
View File
@@ -38,13 +38,15 @@ return {
{ "jump_if_false", "raise_young" }, { "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene -- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript) -- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" }, { "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 }, { "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" }, { "show_text", "_OaksLabRivalGrampsText" },
-- callfar OaksLabPikachuMovementScript, before ShowObject (#1021)
{ "pikachu_make_way" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, { "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "place_npc", RIVAL, 4, 7, "up" }, { "place_npc", RIVAL, 4, 7, "up" },
{ "move_npc_to", RIVAL, 4, 3 }, { "move_npc_to", RIVAL, 4, 3 },
@@ -60,8 +62,8 @@ return {
{ "face_object", RIVAL, "up" }, { "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" }, { "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" }, { "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" }, { "face_object", RIVAL, "up" },
@@ -111,8 +113,8 @@ return {
{ "jump_if_true", "come_see" }, { "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false }, { "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, { "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" }, { "jump", "end" },
@@ -174,7 +176,7 @@ return {
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time -- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 } rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" } rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
@@ -192,15 +194,17 @@ return {
end end
rows[#rows + 1] = { "face_player_dir", "up" } rows[#rows + 1] = { "face_player_dir", "up" }
rows[#rows + 1] = { "face_object", OAK1, "down" } rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter -- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
-- Pikachu keeps its species name
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" } rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" } rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } } rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true } rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" } rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" } rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
ow.runner:run(rows, { npc = npc, onDone = done }) ow.runner:run(rows, { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end, end,
TEXT_OAKSLAB_RIVAL = { TEXT_OAKSLAB_RIVAL = {
@@ -222,9 +226,15 @@ return {
}, },
onEnter = function(game, ow) onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then local flags = game.save.flags or {}
return if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
local rival = ow:npcByIndex(RIVAL)
if rival then
rival.cellX, rival.cellY = 7, 4
rival.px, rival.py = 7 * 16, 4 * 16
end
end end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow } local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -253,6 +263,7 @@ return {
local rival = ow:npcByIndex(RIVAL) local rival = ow:npcByIndex(RIVAL)
if not rival then return false end if not rival then return false end
local rows = { local rows = {
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
{ "face_player_dir", "up" }, { "face_player_dir", "up" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
@@ -292,13 +303,14 @@ return {
table.insert(rows, { "wait", 20 }) table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "stop_music" }) table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" }) table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 }) table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" }) table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball. -- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
-- The overworld follower itself is still an open port table.insert(rows, { "face_player_dir", "up" })
-- (docs/yellow-version.md runtime backlog); the story beat plays. table.insert(rows, { "set_field", "pikachuInBall", false })
table.insert(rows, { "spawn_pikachu_follower" })
table.insert(rows, { "play_cry", "PIKACHU" }) table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
+79 -57
View File
@@ -140,22 +140,29 @@ M.VIRIDIAN_CITY = {
-- Daisy hands over the TOWN MAP once Oak's errand is under way -- Daisy hands over the TOWN MAP once Oak's errand is under way
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText) -- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
M.BLUES_HOUSE = { M.BLUES_HOUSE = {
-- scripts/BluesHouse.asm:12-16
onEnter = function(game, ow)
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
end,
talk = { talk = {
TEXT_BLUESHOUSE_DAISY_SITTING = { TEXT_BLUESHOUSE_DAISY_SITTING = {
{ "face_player" }, -- 1 { "face_player" },
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2 { "check_flag", "EVENT_GOT_TOWN_MAP" },
{ "jump_if_true", 10 }, -- 3 { "jump_if_true", "got_map" },
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4 { "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_false", 12 }, -- 5 { "jump_if_false", "too_early" },
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6 { "show_text", "_BluesHouseDaisyOfferMapText" },
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the -- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText) -- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7 { "give_item", "TOWN_MAP", 1, "_GotMapText" },
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8 { "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
{ "jump", 13 }, -- 9 { "set_flag", "EVENT_GOT_TOWN_MAP" },
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10 { "jump", "end" },
{ "jump", 13 }, -- 11 { "label", "got_map" },
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12 { "show_text", "_BluesHouseDaisyUseMapText" },
{ "jump", "end" },
{ "label", "too_early" },
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
}, },
}, },
} }
@@ -294,6 +301,7 @@ M.BILLS_HOUSE = {
M.ROUTE_25 = { M.ROUTE_25 = {
onEnter = function(game, ow) onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local flags = game.save.flags local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
@@ -326,6 +334,7 @@ M.VERMILION_CITY = {
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only -- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
-- reachable through this map, so a fresh visit always re-rolls). -- reachable through this map, so a fresh visit always re-rolls).
onEnter = function(game, ow) onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local puz = game.save.trashPuzzle or {} local puz = game.save.trashPuzzle or {}
game.save.trashPuzzle = puz game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2 puz.first = love.math.random(0, 7) * 2
@@ -800,9 +809,13 @@ M.SILPH_CO_11F = {
-- line) would touch, and the whole Silph ending -- the flag, the Master -- line) would touch, and the whole Silph ending -- the flag, the Master
-- Ball, the Saffron streets clearing -- silently never happened. -- Ball, the Saffron streets clearing -- silently never happened.
-- --
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and, -- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss -- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
-- sets nothing, so the trigger re-arms exactly as vanilla does. -- and only then walks the three tiles down. Moving him before the box made
-- him cross the room in silence and deliver the speech point-blank (#869),
-- so the box comes first here and engageTrainer skips its own battle text.
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
-- nothing, so the trigger re-arms exactly as vanilla does.
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
@@ -811,21 +824,28 @@ M.SILPH_CO_11F = {
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
end end
if not gio or ow:trainerDefeated(gio) then return false end if not gio or ow:trainerDefeated(gio) then return false end
ow:scriptMove(gio, "down", 3, function() local TextBox = require("src.render.TextBox")
gio:facePlayer(ow.player) game.stack:push(TextBox.new(game,
ow:engageTrainer(gio, function() game.data.text._SilphCo11FGiovanniText
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech, or "Ah {PLAYER}!\nSo we meet again!",
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph function()
-- rocket leaves off-screen (the street rockets are handled by ow:scriptMove(gio, "down", 3, function()
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the gio:facePlayer(ow.player)
-- battle's own callbacks are still unwinding, so queueScript starts ow:engageTrainer(gio, function()
-- it on the first idle overworld frame -- after the end-battle -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722). -- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then -- every Silph rocket leaves off-screen (the street rockets are
ow:queueScript(silphAftermathRows()) -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
end -- run here: the battle's own callbacks are still unwinding, so
end) -- queueScript starts it on the first idle overworld frame --
end) -- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
-- pushes (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
ow:queueScript(silphAftermathRows())
end
end, nil, true)
end)
end))
return true return true
end, end,
onEnter = function(game, ow) onEnter = function(game, ow)
@@ -1031,38 +1051,39 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10 { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo -- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after -- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in -- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
{ "play_music", "Music_Cities1", { keep = true } }, -- 11 -- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
{ "show_text", "_ChampionsRoomOakText" }, -- 12 -- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13 -- waits 100 frames for the fade, then restarts Cities1 with channel 1
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement -- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
-- reading of the town theme this scene is known for (#847).
{ "fade_music", 10 }, -- 11
{ "wait", 100 }, -- 12
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
{ "show_text", "_ChampionsRoomOakText" }, -- 14
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
{ "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down -- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 15 { "face_object", 1, "left" }, -- 17
{ "face_object", 2, "down" }, -- 16 { "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17 { "load_player_starter_name" },
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
-- OakDisappointedWithRivalScript: Oak turns to the rival (right) -- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 18 { "face_object", 2, "right" }, -- 20
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19 { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
-- OakComeWithMeScript: Oak faces down again, then exits up -- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 20 { "face_object", 2, "down" }, -- 22
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21 { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement { "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23 { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement -- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the { "move_player", "left", 1 },
-- screen just fading on the spot (#704). The entrance walk leaves the { "move_player", "up", 3 }, -- 27
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip.
{ "move_player", "up", 3 }, -- 24
-- hand the induction off to the HALL_OF_FAME room (consumed by its -- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 25 { "set_field", "pendingHallOfFame", true }, -- 28
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26 { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
} }
M.CHAMPIONS_ROOM = { M.CHAMPIONS_ROOM = {
@@ -1222,6 +1243,7 @@ local function pokemonTower2FRivalScript(playerX)
{ "jump_if_false", "end" }, -- 6 loss: stay { "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9 { "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10 { "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11 { "jump", "end" }, -- 11
+99 -31
View File
@@ -87,6 +87,18 @@ end
M.PALLET_TOWN = { M.PALLET_TOWN = {
talk = require("data.scripts.pallet_town").talk, talk = require("data.scripts.pallet_town").talk,
escort = escort, escort = escort,
-- scripts/PalletTown.asm:133-144
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
and not f.EVENT_DAISY_WALKING then
f.EVENT_DAISY_WALKING = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
end
end,
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4), -- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
-- then a wild Pikachu battle before the lab escort (pokeyellow -- then a wild Pikachu battle before the lab escort (pokeyellow
-- PalletTownPikachuBattleScript). -- PalletTownPikachuBattleScript).
@@ -178,7 +190,8 @@ M.PALLET_TOWN = {
end end
end end
local function enterLab() local function enterLab(oak)
if oak then oak.stepFrames = nil end
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK") Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
ow.doorWarp = true ow.doorWarp = true
@@ -187,12 +200,17 @@ M.PALLET_TOWN = {
end end
local function walkToLab(oak) local function walkToLab(oak)
-- lockstep half runs Oak on the player's own frames per cell
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
local i = 0 local i = 0
if oak then
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
local function tick() local function tick()
i = i + 1 i = i + 1
local playerStep = escort.playerSteps[i] local playerStep = escort.playerSteps[i]
if not playerStep then if not playerStep then
enterLab() enterLab(oak)
return return
end end
if oak and escort.oakSteps[i] then if oak and escort.oakSteps[i] then
@@ -206,6 +224,13 @@ M.PALLET_TOWN = {
end end
local function escortToLab(oak) local function escortToLab(oak)
-- PalletMovementScript_OakMoveLeft
-- (engine/overworld/auto_movement.asm) starts MUSIC_MUSEUM_GUY
-- when the escort begins in Yellow. Until then, Pallet Town plays
-- after the battle; Red/Blue leave MUSIC_MEET_PROF_OAK playing.
if yellow then
Music.play(game.data, "Music_MuseumGuy")
end
local numSteps = x - 10 local numSteps = x - 10
if oak and numSteps > 0 then if oak and numSteps > 0 then
ow:scriptMove(oak, "left", numSteps, function() ow:scriptMove(oak, "left", numSteps, function()
@@ -249,14 +274,24 @@ M.PALLET_TOWN = {
function() function()
-- Oak turns toward the horizontally adjacent grass (left exit -- Oak turns toward the horizontally adjacent grass (left exit
-- looks right, right exit looks left -- the -- looks right, right exit looks left -- the
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch) -- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch).
-- In pokeyellow, PalletTownOakGreetsPlayerScript turns Oak and
-- PalletTownPikachuBattleScript arms the battle on the next
-- overworld iteration. OverworldLoopLessDelay
-- (home/overworld.asm) burns two DelayFrame calls at the top
-- of each iteration and calls RunMapScript before checking
-- wCurOpponent, so those two DelayFrame calls are what keep
-- Oak's turn on screen before the battle check fires.
if oak then oak.facing = x == 10 and "right" or "left" end if oak then oak.facing = x == 10 and "right" or "left" end
local battle = BattleState.newWild(game, "PIKACHU", 5) hold(2, nil, function()
battle:makeOldManDemo("PROF.OAK") local battle = BattleState.newWild(game, "PIKACHU", 5)
battle.onFinish = function() battle:makeOldManDemo("PROF.OAK")
afterPikaBattle() battle.onFinish = function()
end afterPikaBattle()
game.stack:push(battle) end
-- Use the standard wild-battle entry transition.
Commands.pushBattle(ctx, battle)
end)
end)) end))
end end
@@ -396,19 +431,31 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
M.POKEMON_FAN_CLUB = { M.POKEMON_FAN_CLUB = {
onEnter = function(game, ow)
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
end,
talk = { talk = {
TEXT_POKEMONFANCLUB_CHAIRMAN = { TEXT_POKEMONFANCLUB_CHAIRMAN = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2 { "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2
{ "jump_if_true", 9 }, -- 3 { "jump_if_true", "nothing_left" }, -- 3
{ "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4 -- YesNoChoice (scripts/PokemonFanClub.asm): NO forfeits the voucher (#1050)
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5 { "ask", "_PokemonFanClubChairmanIntroText" }, -- 4
{ "jump_if_false", "no_story" }, -- 5
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6
-- give-then-print like scripts/PokemonFanClub.asm (GiveItem -- give-then-print like scripts/PokemonFanClub.asm (GiveItem
-- fills wStringBuffer; the received text reads it) -- fills wStringBuffer; the received text reads it)
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 6 { "give_item", "BIKE_VOUCHER", 1, false }, -- 7
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7 { "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8 { "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9 { "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10
{ "jump", "end" }, -- 11
{ "label", "no_story" }, -- 12
{ "show_text", "_PokemonFanClubNoStoryText" }, -- 13
{ "jump", "end" }, -- 14
-- .nothingleft: the gift is done, he only reminisces now
{ "label", "nothing_left" }, -- 15
{ "show_text", "_PokemonFanClubChairFinalText" }, -- 16
}, },
}, },
} }
@@ -619,8 +666,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
end end
local idef = game.data.items[itemId] local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId) local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
-- text_waitbutton -- the jingle plays after the box has typed and
-- the button wait comes after it
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
@@ -633,11 +682,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
ow.runner:run({ ow.runner:run({
{ "walk_npc", 1, dirs }, { "walk_npc", 1, dirs },
{ "text_opts", { auto = true } }, { "text_opts", { auto = true } },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName }, { "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done }) }, { onDone = done })
end)) end, TextBox.soundOpts(game, "Get_Key_Item")))
end })) end }))
end end
end end
@@ -653,6 +702,23 @@ M.MT_MOON_B2F = {
return false return false
end, end,
talk = { talk = {
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
local flags = game.save.flags
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
or (t._MtMoonB2fSuperNerdEachTakeOneText
or "We'll each take\none!\nNo being greedy!")
game.stack:push(TextBox.new(game, line, done))
end,
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil( TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"), "DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil( TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
@@ -660,35 +726,37 @@ M.MT_MOON_B2F = {
}, },
} }
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): -- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): Y50, once.
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off -- Declining at the rope shoves the player one tile south (#151)
-- the exhibit rope they crossed heading north (#151); the museum floor has no
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
local function museumClerk(game, ow, done, onDecline) local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local t = game.data.text or {}
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Take your time,\nand enjoy it all!", done)) "Take your time,\nand enjoy it all!", done))
return return
end end
-- scripts/Museum1F.asm:72
local money = function() return game.save.money end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?", function() t._Museum1FScientist1WouldYouLikeToComeInText
game.stack:push(ChoiceBox.new(game, function(yes) or "It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?",
nil, { money = money, choice = function(yes)
if yes and game.save.money >= 50 then if yes and game.save.money >= 50 then
game.save.money = game.save.money - 50 game.save.money = game.save.money - 50
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
-- scripts/Museum1F.asm:106
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Right, ¥50!\nThank you!", done)) t._Museum1FScientist1ThankYouText or "Right, ¥50!\nThank you!", done,
{ money = money }))
elseif yes then elseif yes then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"You don't have\nenough money.", onDecline or done)) "You don't have\nenough money.", onDecline or done, { money = money }))
else else
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Come again!", onDecline or done)) "Come again!", onDecline or done, { money = money }))
end end
end)) end }))
end))
end end
M.MUSEUM_1F = { M.MUSEUM_1F = {
+67 -21
View File
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
-- trick, and the speedrun route this bot follows depends on it. -- trick, and the speedrun route this bot follows depends on it.
if result == "win" or battle.pokeDollEscape then if result == "win" or battle.pokeDollEscape then
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
game.stack:push(TextBox.new(game, -- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
t._PokemonTower6FSoulWasCalmedText -- is two texts, not one: the CUBONE's-mother line first, then
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!")) -- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
-- calmed line; the port dropped the first text and the cry
-- (#867). play_cry arms the next show_text, so the cry rides
-- the calmed box's open with the button prompt kept, and the
-- wait row stands in for the asm's 30-frame gap.
local rows = {
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
{ "play_cry", "MAROWAK", true },
{ "wait", 30 },
{ "show_text", t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
}
if ow.runner then
ow.runner:run(rows)
elseif ow.queueScript then
ow:queueScript(rows)
end
elseif result ~= "lose" then elseif result ~= "lose" then
-- .did_not_defeat: one simulated step right, off the trigger, -- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that -- so fleeing does not leave you standing on a cell that
@@ -517,6 +535,14 @@ M.GAME_CORNER = {
done() done()
return return
end end
-- GameCornerRocketText hands the battle its own loss line through
-- SaveEndBattleTextPointers (.BattleEndText ->
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
-- prints it ON the battle screen between TrainerDefeatedText and
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
-- He is a text_asm trainer with no def_trainers header, so there is no
-- header.won for engageTrainer to find and the line has to be handed
-- over here or it never shows at all (#862).
ow:engageTrainer(npc, function() ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then if not ow:trainerDefeated(npc) then
done() done()
@@ -527,19 +553,44 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function() function()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm) -- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster -- picks the exit walk from where the player is standing, because
-- (the hideout's secret entrance at 9,4) before HideObject, so -- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
-- he leaves the floor rather than popping out of existence on -- from the south) or wXCoord == 8 (from the west) leaves the row
-- (9,5). scriptMove locks player input (#scriptMoves>0) and -- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
-- ignores collision, so we despawn + unfreeze (done) only once -- RIGHT; otherwise the player is east of him on (10,5) and
-- the step lands. -- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
ow:scriptMove(npc, "up", 1, function() -- and right again to go AROUND him. pokeyellow's copy of the
hideRocket() -- around-path takes one extra RIGHT on the lower row before coming
done() -- back up (it also has to clear Pikachu); both versions end on
end) -- (15,5). He never steps UP: (9,4) is the poster wall, which is
-- where the old single UP step sent him.
local px = ow.player and ow.player.cellX
local py = ow.player and ow.player.cellY
local path
if py == 6 or px == 8 then
path = { { "right", 5 } }
elseif require("src.core.GameVersion").isYellow() then
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
else
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
end
-- GameCornerRocketExitScript only HideObjects him once
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
-- scriptMove locks player input (#scriptMoves>0) and ignores
-- collision, so the despawn + unfreeze (done) ride the final step.
local function step(i)
if i > #path then
hideRocket()
done()
return
end
ow:scriptMove(npc, path[i][1], path[i][2],
function() step(i + 1) end)
end
step(1)
end)) end))
end) end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
end, end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a -- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the -- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
@@ -886,15 +937,10 @@ M.VERMILION_DOCK = {
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end end
puff(3, 15) puff(3, 15)
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the -- scripts/VermilionDock.asm:182-203
-- player alone ("south of the player and won't be redrawn"), so skip
-- his own block: he must not spend the walk-out standing on water
local pbx = math.floor(ow.player.cellX / 2)
local pby = math.floor(ow.player.cellY / 2)
local rows = {} local rows = {}
local function setBlock(bx, by, block) local function setBlock(bx, by, block)
if bx < 1 or bx > 8 then return end if bx < 1 or bx > 8 then return end
if bx == pbx and by == pby then return end
rows[#rows + 1] = { "replace_block", bx, by, block } rows[#rows + 1] = { "replace_block", bx, by, block }
end end
rows[#rows + 1] = { "wait", 120 } rows[#rows + 1] = { "wait", 120 }
+119 -86
View File
@@ -8,21 +8,35 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- The question stays on screen under the YES/NO menu. The dojo prize
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
-- rather than replacing it. Ride TextBox's opts.choice, the same as
-- Commands.ask, instead of popping the box with an A press and leaving a
-- bare ChoiceBox over the overworld (#854).
local function ask(game, s, cb) local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox") local TextBox = require("src.render.TextBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end end
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER}) -- fill text placeholders; key on the hram/wram symbol first, since one
-- string can carry two different NUM slots (#1006)
local function fill(s, subs) local function fill(s, subs)
s = s:gsub("{PLAYER}", subs.player or "") s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end) s = s:gsub("{NUM:([%w_]*)[^}]*}", function(name)
s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end) return tostring(subs[name] or subs.num or "")
end)
s = s:gsub("{RAM:([%w_]*)[^}]*}", function(name)
return subs[name] or subs.ram or ""
end)
return s return s
end end
@@ -73,9 +87,12 @@ local function oaksAide(threshold, itemId, repeatText)
{ ram = itemName, player = game.save.player.name }), done) { ram = itemName, player = game.save.player.name }), done)
end) end)
else else
-- .notEnoughOwnedMons prints owned then requirement, two counts
push(game, fill(t._OaksAideUhOhText or push(game, fill(t._OaksAideUhOhText or
"You have only\ncaught {NUM:}!", "You have only\ncaught {NUM:}!",
{ num = owned, ram = itemName }), done) { num = owned, ram = itemName,
hOaksAideNumMonsOwned = owned,
hOaksAideRequirement = threshold }), done)
end end
end) end)
end end
@@ -101,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
M.MT_MOON_POKECENTER = { M.MT_MOON_POKECENTER = {
talk = { talk = {
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done) -- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
local t = text(game) TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
if game.save.flags.EVENT_BOUGHT_MAGIKARP then { "check_flag", "EVENT_BOUGHT_MAGIKARP" },
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText { "jump_if_true", "no_refunds" },
or "Well, I don't\ngive refunds!", done) -- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
return { "text_opts", { money = true } },
end { "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText { "jump_if_false", "declined" },
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes) { "check_money", 500 },
if not yes then { "jump_if_false", "no_money" },
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText { "give_pokemon", "MAGIKARP", 5 },
or "No? I'm only\nselling today!", done) -- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
return { "jump_if_false", "box_full" },
end { "take_money", 500 },
if game.save.money < 500 then { "set_flag", "EVENT_BOUGHT_MAGIKARP" },
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText { "text_sound", "Get_Item1" },
or "You'll need more\nmoney than that!", done) { "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
return { "jump", "end" },
end { "label", "box_full" },
game.save.money = game.save.money - 500 { "show_text", "_BoxIsFullText" },
game.save.flags.EVENT_BOUGHT_MAGIKARP = true { "jump", "end" },
local Commands = require("src.script.Commands") { "label", "declined" },
Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
"MAGIKARP", 5) { "jump", "end" },
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done) { "label", "no_money" },
end) { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
end, { "jump", "end" },
{ "label", "no_refunds" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
},
}, },
} }
@@ -155,19 +175,26 @@ local function dojoBall(species, ownBall, otherBall, askKey)
push(game, "You'll have to\nbeat the master\nfirst!", done) push(game, "You'll have to\nbeat the master\nfirst!", done)
return return
end end
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) -- Examining a ball shows that species' POKéDEX entry first
if not yes then done() return end -- (DisplayPokedex in FightingDojo.asm, which also marks it seen),
flags["EVENT_GOT_" .. species] = true -- then the yes/no take-it prompt (#853).
flags.EVENT_DEFEATED_FIGHTING_DOJO = true local Commands = require("src.script.Commands")
local Commands = require("src.script.Commands") local ctx = { save = game.save, game = game, overworld = ow }
local ctx = { save = game.save, game = game, overworld = ow } Commands.mark_seen(ctx, species)
Commands.give_pokemon(ctx, species, 30) local DexEntryMenu = require("src.ui.DexEntryMenu")
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides game.stack:push(DexEntryMenu.new(game, species, function()
-- just the picked object's index) and routes to the greedy line above ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
-- when talked to (#197). if not yes then done() return end
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall) flags["EVENT_GOT_" .. species] = true
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) flags.EVENT_DEFEATED_FIGHTING_DOJO = true
end) Commands.give_pokemon(ctx, species, 30)
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
-- just the picked object's index) and routes to the greedy line above
-- when talked to (#197).
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end)
end))
end end
end end
@@ -229,27 +256,32 @@ M.FIGHTING_DOJO = {
M.SILPH_CO_7F = { M.SILPH_CO_7F = {
talk = { talk = {
TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done) -- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1049)
local t = text(game) TEXT_SILPHCO7F_SILPH_WORKER_M1 = {
if game.save.flags.EVENT_GOT_LAPRAS then { "face_player" },
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText { "check_flag", "EVENT_GOT_LAPRAS" },
or "How is LAPRAS\ndoing?", done) { "jump_if_true", "has_lapras" },
return { "show_text", "_SilphCo7FSilphWorkerM1HaveThisPokemonText" },
end { "give_pokemon", "LAPRAS", 15 },
push(game, t._SilphCo7FSilphWorkerM1ThankYouText { "jump_if_false", "box_full" },
or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!", -- flag ahead of the jingle, like the Celadon EEVEE (#426)
function() { "set_flag", "EVENT_GOT_LAPRAS" },
game.save.flags.EVENT_GOT_LAPRAS = true { "text_sound", "Get_Item1" },
local Commands = require("src.script.Commands") { "show_text", "_GotMonText", { RAM = "LAPRAS" } },
Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, { "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
"LAPRAS", 15) { "jump", "end" },
push(game, ("%s got\nLAPRAS!"):format(game.save.player.name), { "label", "box_full" },
function() { "show_text", "_BoxIsFullText" },
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText { "jump", "end" },
or "It's a good\nswimmer!", done) -- SilphCo7F.asm .saved_silph gates the thanks on Giovanni
end) { "label", "has_lapras" },
end) { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
end, { "jump_if_true", "saved" },
{ "show_text", "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
{ "jump", "end" },
{ "label", "saved" },
{ "show_text", "_SilphCo7FSilphWorkerM1SavedText" },
},
}, },
} }
@@ -278,12 +310,11 @@ M.COPYCATS_HOUSE_2F = {
return return
end end
game.stringBuffer = game.data.items.TM_MIMIC.name game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1) Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end)
end) end)
end, end,
@@ -449,7 +480,6 @@ M.CELADON_MART_ROOF = {
return return
end end
game.save.flags[g.flag] = true game.save.flags[g.flag] = true
require("src.core.Sound").play(game.data, "Get_Item1")
local subs = { player = game.save.player.name, local subs = { player = game.save.player.name,
ram = game.data.items[g.tm].name } ram = game.data.items[g.tm].name }
local explain = fill(t[g.explain] or "", subs) local explain = fill(t[g.explain] or "", subs)
@@ -461,7 +491,7 @@ M.CELADON_MART_ROOF = {
else else
done() done()
end end
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end)
end, end,
onCancel = done, onCancel = done,
@@ -483,25 +513,28 @@ M.ROUTE_24 = {
local flags = game.save.flags local flags = game.save.flags
local function battleOrDone() local function battleOrDone()
if ow:trainerDefeated(npc) then if ow:trainerDefeated(npc) then
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done) push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else else
ow:engageTrainer(npc, done) ow:engageTrainer(npc, done)
end end
end end
if not flags.EVENT_GOT_NUGGET then if not flags.EVENT_GOT_NUGGET then
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f" local t = text(game)
.. "You just earned a\nfabulous prize!", function() push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done)
return
end
flags.EVENT_GOT_NUGGET = true flags.EVENT_GOT_NUGGET = true
require("src.inventory.Bag").add(game.save, "NUGGET", 1) game.stringBuffer = game.data.items.NUGGET.name
push(game, ("%s received\na NUGGET!"):format(game.save.player.name), push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
function() push(game, t._Route24CooltrainerM1JoinTeamRocketText,
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?", battleOrDone)
function() end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n" end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
.. "you my power!", battleOrDone)
end)
end)
end)
return return
end end
battleOrDone() battleOrDone()
+48 -26
View File
@@ -4,9 +4,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- fill the extracted text placeholders ({RAM:...}, {PLAYER}) -- fill the extracted text placeholders ({RAM:...}, {PLAYER})
@@ -25,8 +25,8 @@ local function gift(opts)
local t = text(game) local t = text(game)
local itemName = game.data.items[opts.item].name local itemName = game.data.items[opts.item].name
local subs = { ram = itemName, player = game.save.player.name } local subs = { ram = itemName, player = game.save.player.name }
local function say(label, fallback, cb) local function say(label, fallback, cb, sopts)
push(game, fill(t[label] or fallback, subs), cb) push(game, fill(t[label] or fallback, subs), cb, sopts)
end end
if game.save.flags[opts.flag] then if game.save.flags[opts.flag] then
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
@@ -39,15 +39,16 @@ local function gift(opts)
end end
game.save.flags[opts.flag] = true game.save.flags[opts.flag] = true
local idef = game.data.items[opts.item] local idef = game.data.items[opts.item]
require("src.core.Sound").play(game.data, -- the received texts carry sound_get_item_1 / sound_get_key_item, so
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1") -- the jingle only fires once that box has typed out
say(opts.received, "{PLAYER} received\n{RAM:}!", function() say(opts.received, "{PLAYER} received\n{RAM:}!", function()
if opts.explain then if opts.explain then
say(opts.explain, "", done) say(opts.explain, "", done)
else else
done() done()
end end
end) end, require("src.render.TextBox").soundOpts(game,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
end end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end end
@@ -445,7 +446,7 @@ local function pewterGymEscort(game, ow)
end end
local function afterWalk() local function afterWalk()
if guy then guy.facing = "left" end if guy then guy.stepFrames, guy.facing = nil, "left" end
Music.playMap(game.data, "PEWTER_CITY") Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome) or "Go take on BROCK\nat the GYM first!", walkHome)
@@ -468,6 +469,11 @@ local function pewterGymEscort(game, ow)
end end
local function beginWalk() local function beginWalk()
-- the escort runs the youngster on the player's own frames per cell
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
if guy then
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
Music.play(game.data, "Music_MuseumGuy") Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then if guy and head > 0 then
local h = 0 local h = 0
@@ -508,12 +514,12 @@ M.PEWTER_CITY = {
-- Rival ambush: show the hidden rival, walk him up to the player, run -- Rival ambush: show the hidden rival, walk him up to the player, run
-- the battle rows, march him back and hide him. On a loss the walk is -- the battle rows, march him back and hide him. On a loss the walk is
-- skipped (the blackout rebuilds the map mid-script). -- skipped (the blackout rebuilds the map mid-script).
local function runAmbush(game, ow, rows, playerFacing) local function runAmbush(game, ow, rows, playerFacing, musicOpts)
if ow.runner:isRunning() then return false end if ow.runner:isRunning() then return false end
ow.player.facing = playerFacing ow.player.facing = playerFacing
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music -- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
-- takes over and the map theme returns after the victory jingle -- takes over and the map theme returns after the victory jingle
require("src.core.Music").play(game.data, "Music_MeetRival") require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
ow.runner:run(rows) ow.runner:run(rows)
return true return true
end end
@@ -567,12 +573,15 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "face_object", objIndex, rivalFacing }, -- 3 { "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4 { "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5 { "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 13 }, -- 6
{ "set_flag", beatFlag }, -- 7 { "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 { "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10 { "play_music", "Music_MeetRival", { start = "rival",
{ "hide_object", "ROUTE_22", objName }, -- 11 tempo = n == 2 and 100 or nil } }, -- 10
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 11
{ "play_default_music" }, -- scripts/Route22.asm:230
{ "hide_object", "ROUTE_22", objName }, -- 13
} }
end end
@@ -594,7 +603,8 @@ M.ROUTE_22 = {
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow, return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10, route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing) "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
{ tempo = 100 })
end end
return false return false
end, end,
@@ -618,12 +628,14 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3 { "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 13 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10 { "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 11
{ "play_default_music" }, -- scripts/CeruleanCity.asm:230
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 13
} }
end end
@@ -730,7 +742,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
-- Built as a TextBox `auto` table: auto.sound fires the frame the last -- Built as a TextBox `auto` table: auto.sound fires the frame the last
-- page has typed out (PrintText returning), and auto.tick then runs once -- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing. -- per frame while the gate it returns still reads as playing.
local function jigglypuffDance(game, npc) local function jigglypuffDance(game, npc, ow)
local Music = require("src.core.Music") local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry -- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it -- matching the sprite's current facing (showMapText has just turned it
@@ -776,7 +788,13 @@ local function jigglypuffDance(game, npc)
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return return
end end
if frames >= JIGGLYPUFF_TAIL then phase = "done" end if frames >= JIGGLYPUFF_TAIL then
phase = "done"
if require("src.core.GameVersion").isYellow()
and require("src.world.PikachuFollower").starterInParty(game.save) then
ow.pikachuPewterSleepScene = true
end
end
end, end,
} }
end end
@@ -789,7 +807,7 @@ M.PEWTER_POKECENTER = {
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!", text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) })) done, { auto = jigglypuffDance(game, npc, ow) }))
end, end,
}, },
} }
@@ -864,12 +882,14 @@ M.SILPH_CO_7F = {
{ "face_object", 9, "up" }, -- 4 { "face_object", 9, "up" }, -- 4
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5 { "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6 { "rival_battle", "OPP_RIVAL2", 7 }, -- 6
{ "jump_if_false", 12 }, -- 7 { "jump_if_false", 14 }, -- 7
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8 { "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9 { "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10 { "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
{ "move_npc_to", 9, 5, y + 1 }, -- 11 { "play_music", "Music_MeetRival", { start = "rival" } }, -- 11
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12 { "move_npc_to", 9, 5, y + 1 }, -- 12
{ "play_default_music" }, -- scripts/SilphCo7F.asm:261
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 14
}, "down") }, "down")
end, end,
} }
@@ -899,12 +919,14 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3 { "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4 { "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5 { "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 13 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10 { "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11 { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
{ "play_default_music" }, -- scripts/SSAnne2F.asm:175
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 13
}, onLeft and "up" or "left") }, onLeft and "up" or "left")
end, end,
} }
+6 -2
View File
@@ -12,9 +12,13 @@ local function push(game, s, done)
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done))
end end
-- PrintText on a text_end string returns with the box still drawn and
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
-- engine/menus/text_box.asm); no A press clears the question first. Ride
-- TextBox's opts.choice, the same as Commands.ask (#854).
local function ask(game, s, cb) local function ask(game, s, cb)
local ChoiceBox = require("src.ui.ChoiceBox") local TextBox = require("src.render.TextBox")
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end end
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
+71 -8
View File
@@ -18,6 +18,24 @@
-- script). Leaders are not def_trainers entries, so engageTrainer has -- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won -- checkVictoryRewards shows this chain instead of a -- no header.won -- checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub. -- synthetic "received badge/TM" stub.
--
-- Gym entries split the TM hand-over out of `dialogue`, mirroring the
-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`):
-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take
-- this!"), shown at the victory and again when a beaten leader retries
-- the hand-over; `tmDialogue` shows only when the TM actually goes in
-- the bag; `noRoom` is the "make room" line shown instead when the bag
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
-- successful give, which is what makes the leader's talk script retry
-- later (gyms.lua).
--
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
-- plays it once that page has typed out and then blocks on
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
-- under them. macros/scripts/text.asm defines sound_level_up as
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
local function range(prefix, first, last) local function range(prefix, first, last)
local t = {} local t = {}
@@ -33,77 +51,122 @@ return {
-- escort NPC and the first Route 22 rival stay gone after the badge. -- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK", ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE", item = "TM_BIDE",
gotFlag = "EVENT_GOT_TM34",
noRoom = "_PewterGymTM34NoRoomText",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = { hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" }, { "ROUTE_22", "ROUTE22_RIVAL1" },
}, },
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText", "_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText", },
tmPre = { "_PewterGymBrockWaitTakeThisText" },
tmDialogue = {
"_PewterGymReceivedTM34Text", "_PewterGymReceivedTM34Text",
"_TM34ExplanationText", "_TM34ExplanationText",
} }, } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM", item = "TM_BUBBLEBEAM",
gotFlag = "EVENT_GOT_TM11",
noRoom = "_CeruleanGymMistyTM11NoRoomText",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText", "_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText", },
tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" },
tmDialogue = {
"_CeruleanGymMistyReceivedTM11Text", "_CeruleanGymMistyReceivedTM11Text",
} }, } },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT", item = "TM_THUNDERBOLT",
gotFlag = "EVENT_GOT_TM24",
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
tmSound = "Get_Key_Item",
dialogue = { dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText", "_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText", },
tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" },
tmDialogue = {
"_VermilionGymLTSurgeReceivedTM24Text", "_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText", "_TM24ExplanationText",
} }, } },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN", item = "TM_MEGA_DRAIN",
gotFlag = "EVENT_GOT_TM21",
noRoom = "_CeladonGymTM21NoRoomText",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText", "_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText", },
tmPre = { "_CeladonGymRainbowBadgeInfoText" },
tmDialogue = {
"_CeladonGymReceivedTM21Text", "_CeladonGymReceivedTM21Text",
"_TM21ExplanationText", "_TM21ExplanationText",
} }, } },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC", item = "TM_TOXIC",
gotFlag = "EVENT_GOT_TM06",
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
tmSound = "Get_Key_Item",
dialogue = { dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText", "_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText", },
tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" },
tmDialogue = {
"_FuchsiaGymKogaReceivedTM06Text", "_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText", "_FuchsiaGymKogaTM06ExplanationText",
} }, } },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE", item = "TM_PSYWAVE",
gotFlag = "EVENT_GOT_TM46",
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText", "_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText", },
tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" },
tmDialogue = {
"_SaffronGymSabrinaReceivedTM46Text", "_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText", "_TM46ExplanationText",
} }, } },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST", item = "TM_FIRE_BLAST",
gotFlag = "EVENT_GOT_TM38",
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText", "_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText", },
tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" },
tmDialogue = {
"_CinnabarGymBlaineReceivedTM38Text", "_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText", "_CinnabarGymBlaineTM38ExplanationText",
} }, } },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE", item = "TM_FISSURE",
gotFlag = "EVENT_GOT_TM27",
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText", "_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText", },
tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" },
tmDialogue = {
"_ViridianGymGiovanniReceivedTM27Text", "_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText", "_ViridianGymGiovanniTM27ExplanationText",
} }, } },
+4 -2
View File
@@ -36,7 +36,8 @@ M.CERULEAN_MELANIES_HOUSE = {
rows[#rows + 1] = { "label", "declined" } rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" } rows[#rows + 1] = { "show_text", "MelanieText5" }
end end
ow.runner:run(rows, { npc = npc, onDone = done }) ow.runner:run(rows, { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end, end,
-- pet flavor: the text with the species' cry over it -- pet flavor: the text with the species' cry over it
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = { TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
@@ -105,7 +106,8 @@ M.VERMILION_CITY = {
rows[#rows + 1] = { "label", "declined" } rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" } rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
end end
ow.runner:run(rows, { npc = npc, onDone = done }) ow.runner:run(rows, { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end, end,
}, },
} }
+45 -12
View File
@@ -62,10 +62,15 @@ M.MT_MOON_B2F = {
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } }, { "walk_npc", 6, { "left", "left", "left", "left", "left" } },
{ "face_object", 6, "left" }, { "face_object", 6, "left" },
{ "show_text", "_MtMoonJessieJamesText2" }, { "show_text", "_MtMoonJessieJamesText2" },
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
-- Its one-word first line only reads right behind that tag (#866).
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 42 }, { "start_battle", "trainer", "OPP_ROCKET", 42 },
{ "check_battle_result", "win" }, { "check_battle_result", "win" },
{ "jump_if_false", "end" }, { "jump_if_false", "end" },
{ "show_text", "_MtMoonJessieJamesText3" },
{ "show_text", "_MtMoonJessieJamesText4" }, { "show_text", "_MtMoonJessieJamesText4" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetJessieJames" }, { "play_music", "Music_MeetJessieJames" },
@@ -85,7 +90,8 @@ M.MT_MOON_B2F = {
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) / -- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
-- (24,10) and whichever of them shares the player's column ($18=24 or -- (24,10) and whichever of them shares the player's column ($18=24 or
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three -- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
-- tiles down to loom over the player while the other steps one. A loss -- tiles down to loom over the player while the other walks four and ends
-- up beside him. A loss
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the -- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
-- trigger re-arms clean. -- trigger re-arms clean.
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = {
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
-- ON_LEFT: player under James's column (25); movement data pairs -- ON_LEFT: player under James's column (25); movement data pairs
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the -- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
-- column-mate walks 3, the other 1. -- column-mate walks 3, the other 4.
local onLeft = (x == 25) local onLeft = (x == 25)
ow.runner:run({ ow.runner:run({
{ "stop_music" }, { "stop_music" },
@@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = {
{ "emote", "player", "shock", 30 }, { "emote", "player", "shock", 30 },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
-- James (object 2) then Jessie (object 3), Script4..Script9 order -- James (object 2) then Jessie (object 3), Script4..Script9 order.
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } }, -- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
-- collision test. From (25,10)/(24,10) against a player on y=14 the
-- column-mate stops three down, right above him, and the other walks the
-- full four to stand alongside -- which is what the facings below assume.
-- Reading _45605 as a single step stranded whoever was off-column three
-- tiles away, so James never reached the player (#865).
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
or { "down", "down", "down", "down" } },
{ "face_object", 2, onLeft and "down" or "left" }, { "face_object", 2, onLeft and "down" or "left" },
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } }, { "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
or { "down", "down", "down" } },
{ "face_object", 3, onLeft and "right" or "down" }, { "face_object", 3, onLeft and "right" or "down" },
{ "show_text", "_RocketHideoutJessieJamesText2" }, { "show_text", "_RocketHideoutJessieJamesText2" },
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
-- the battle screen ahead of MoneyForWinningText (#866).
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 43 }, { "start_battle", "trainer", "OPP_ROCKET", 43 },
{ "check_battle_result", "win" }, { "check_battle_result", "win" },
{ "jump_if_false", "lost" }, { "jump_if_false", "lost" },
{ "show_text", "_RocketHideoutJessieJamesText3" },
{ "show_text", "_RocketHideoutJessieJamesText4" }, { "show_text", "_RocketHideoutJessieJamesText4" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetJessieJames" }, { "play_music", "Music_MeetJessieJames" },
@@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = {
{ "show_text", "_PokemonTowerJessieJamesText1" }, { "show_text", "_PokemonTowerJessieJamesText1" },
{ "face_player_dir", "up" }, { "face_player_dir", "up" },
{ "emote", "player", "shock", 30 }, { "emote", "player", "shock", 30 },
-- Jessie (object 1) then James (object 2), Script1..Script6 order -- Jessie (object 1) then James (object 2), Script1..Script6 order.
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } }, -- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
-- y=12 the column-mate halts one tile above him and the other closes the
-- full four to his side; the single-step reading is why James only
-- "moved a bit" here (#865).
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
or { "down", "down", "down" } },
{ "face_object", 1, onLeft and "right" or "down" }, { "face_object", 1, onLeft and "right" or "down" },
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } }, { "walk_npc", 2, onLeft and { "down", "down", "down" }
or { "down", "down", "down", "down" } },
{ "face_object", 2, onLeft and "down" or "left" }, { "face_object", 2, onLeft and "down" or "left" },
{ "show_text", "_PokemonTowerJessieJamesText2" }, { "show_text", "_PokemonTowerJessieJamesText2" },
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
-- before the prize money (#866).
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 44 }, { "start_battle", "trainer", "OPP_ROCKET", 44 },
{ "check_battle_result", "win" }, { "check_battle_result", "win" },
{ "jump_if_false", "end" }, { "jump_if_false", "end" },
{ "show_text", "_PokemonTowerJessieJamesText3" },
{ "show_text", "_PokemonTowerJessieJamesText4" }, { "show_text", "_PokemonTowerJessieJamesText4" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetJessieJames" }, { "play_music", "Music_MeetJessieJames" },
@@ -254,10 +285,12 @@ M.SILPH_CO_11F = {
{ "walk_npc", 6, jessieDirs }, { "walk_npc", 6, jessieDirs },
{ "face_object", 6, jessieFace }, { "face_object", 6, jessieFace },
{ "show_text", "_SilphCoJessieJamesText2" }, { "show_text", "_SilphCoJessieJamesText2" },
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
{ "start_battle", "trainer", "OPP_ROCKET", 45 }, { "start_battle", "trainer", "OPP_ROCKET", 45 },
{ "check_battle_result", "win" }, { "check_battle_result", "win" },
{ "jump_if_false", "end" }, { "jump_if_false", "end" },
{ "show_text", "_SilphCoJessieJamesText3" },
{ "show_text", "_SilphCoJessieJamesText4" }, { "show_text", "_SilphCoJessieJamesText4" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetJessieJames" }, { "play_music", "Music_MeetJessieJames" },
+2 -1
View File
@@ -100,7 +100,8 @@ local function oldMan2Talk(game, ow, npc, done)
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done)) game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
return return
end end
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done }) ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done,
checkpointOnDone = "release_npc" })
end end
M.VIRIDIAN_CITY = { M.VIRIDIAN_CITY = {
+1 -1
View File
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir | | | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling | | render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring | | | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing | | | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) | | | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page | | | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades | | | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
-515
View File
@@ -1,515 +0,0 @@
# Behavior porting notes
What was ported from pokered's engine code and where it came from.
## Overworld
- **Collision rule** (`home/overworld.asm` tile-in-front checks): a 16x16
cell is passable when its bottom-left 8x8 tile is in the tileset's
`coll_tiles` list. Verified against Pallet Town's fences/houses/water
and Oak's Lab furniture.
- **Warp activation** (`home/overworld.asm` CheckWarpsNoCollision /
ExtraWarpCheck): a warp fires when arriving on a warp whose standing
tile is in the tileset's door or warp tile list, or when standing on a
warp and walking off the map edge (interior exit mats). Both paths are
data-driven from `door_tile_ids.asm` / `warp_tile_ids.asm`.
- **LAST_MAP warps** return to the remembered outdoor map/position, like
`wLastMap`.
- **Connections** (`map_header` connection directives): crossing an edge
places the player at `destCoord = curCoord - offset*2` cells on the
destination's opposite edge.
- **Movement**: tile-by-tile, 1 px/frame at 60 fps (16 frames per step),
tap-to-turn without stepping, hold-to-walk, input locked mid-step.
- **Wild encounters** (`engine/battle/wild_encounters.asm`): per grass
step, encounter iff `rand(0..255) < rate`; slot picked via the
cumulative buckets 51/102/141/166/191/216/229/242/253/256.
- **Initial object visibility** from `toggleable_objects.asm` (e.g. Oak
hidden in his lab), with `show_object`/`hide_object` script commands
persisting to the save like the missable-object bits.
## Pokémon math (`engine/pokemon/calc_stats.asm`, `experience.asm`)
- `stat = floor(((base + DV)*2 + floor(sqrt(statExp)/4)) * L / 100) + 5`
(HP: `+ L + 10`); HP DV from the low bits of the other four DVs.
- Growth curves use the exact cubic coefficients (MEDIUM_SLOW =
1.2n^3 - 15n^2 + 100n - 140, etc).
- Exp gain = `floor(baseExp * level / 7)` (x1.5 for trainer battles);
defeated species' base stats accumulate as stat experience.
## Battle core (`engine/battle/core.asm`)
- Damage: `floor(floor(2L(x2 crit)/5 + 2) * power * atk / def / 50)`
capped at 997, `+2`, STAB x1.5, per-matchup type multipliers applied
sequentially (x10 fixed point), then `rand(217..255)/255` when
damage > 1.
- Critical hits: `rand(0..255) < baseSpeed/2` (x4 for Karate Chop, Razor
Leaf, Crabhammer, Slash, capped 255); crits double level and ignore
stat stages (gen1_faithful ruleset).
- Accuracy: `rand(0..255) < floor(acc*255/100)` after accuracy/evasion
stages, including the 1/256 miss at 100% accuracy (toggleable via the
`modern_clean` ruleset).
- Stat stages use the 25/28/33/40/50/66/100/150/.../400 multiplier table
(`data/battle/stat_modifiers.asm`).
- Physical/special split by type (special = Water/Grass/Fire/Ice/
Electric/Psychic/Dragon).
- Status: paralysis speed/4 and 25% full para, burn halves physical
attack, poison/burn residual = maxHP/16, sleep 1-7 turns waking on the
lost turn, freeze permanent (as in Gen 1).
- Turn order: effective speed, coin-flip ties; Quick Attack first,
Counter last (Gen 1's only priorities).
- Run formula (`TryRunningFromBattle`): always escape if faster,
otherwise `floor(pSpd*32 / (eSpd/4)) + 30*attempts` vs `rand(0..255)`.
- Catching (`ItemUseBall`): ball-specific rand ranges (255/200/150),
status bonus 25/12, second roll `floor(maxHP*255/ballFactor) /
floor(HP/4)` capped 255.
- Prize money: class base money x last defeated mon's level
(`pic_pointers_money.asm`).
## Battle move effects (engine/battle/core.asm, move_effects/*)
- Mimic via Metronome (effects.asm:1203-1273): MimicEffect's
.letPlayerChooseMove branch snapshots wCurrentMenuItem before the
copy-picker menu opens and restores it afterward as the write index
into wBattleMonMoves. Since SelectMenuItem always writes
wCurrentMenuItem/wPlayerMoveListIndex together at the FIGHT-menu
confirm and nothing (including MetronomePickMove) touches either
variable during mid-move resolution, the reused value is always the
calling move's own slot, BattleState.lua's applyMimic fallback uses
self.moveIndex, frozen the same way, so a called Mimic (e.g. from
METRONOME in slot 3) overwrites the calling move's own slot, keeping
its PP, matching the Gen 1 quirk exactly.
- Multi-hit distribution 2/2/2/3/3/3/4/5 over rand(0..7); all hits reuse
the first damage roll (faithful).
- Recoil = damage/4 (Struggle /2); drain/Dream Eater heal = damage/2;
Dream Eater requires sleep.
- Fixed damage: SonicBoom 20, Dragon Rage 40, Seismic Toss/Night Shade =
level, Psywave rand(1 .. 1.5xlevel-1).
- OHKO deals 65535, fails against faster targets; Swift skips accuracy;
Jump Kick crash = 1 damage on miss; Explosion halves defense and
faints the user even on a miss; Hyper Beam skips recharge if it KOs.
- Charge moves (incl. Fly's invulnerable turn), trapping moves locking
the victim out of its turns, Thrash's 3-4 turn lock ending in
confusion, Bide's 2-3 turn store-and-double, Rage's permanent lock
with attack-up on being hit, Counter/Quick Attack priority.
- Side-effect chances: 26/256 (10%), 77/256 (30%), stat-down side
effects 85/256; Twineedle 20% poison.
- Substitute costs 1/4 max HP, absorbs damage, blocks status/stat/side
effects; screens double effective defense (bypassed by crits); Focus
Energy keeps the Gen 1 quarter-rate bug under gen1_faithful.
- Status: sleep 1-7 turns (wake turn is lost), freeze permanent, burn
halves physical attack, paralysis speed/4 + 25% full para, Toxic's
rising counter, Leech Seed transfer, confusion 2-5 turns with 50%
40-power typeless self-hit.
- Trainer Pokémon use fixed DVs 9/8/8/8 (TrainerAI.asm convention).
## Items (engine/items/item_effects.asm)
- Potion family 20/50/200/full; drinks 50/60/80; status heals per item;
Revive half HP; Rare Candy = exact next-level exp with HP delta kept;
evolution stones use the extracted evos data; TMs single-use / HMs
reusable, gated by the species' real tmhm list; Repel 100/200/250
steps blocking wilds below the lead's level; Escape Rope returns to
the last heal point.
- Snorlax (Route 12/16) only wakes via `ItemUsePokeFlute` (item-use
menu, adjacent to it, not yet beaten), talking to it with the POKé
FLUTE merely in the bag has no effect (`engine/items/item_effects.asm`,
`scripts/Route12.asm`/`Route16.asm`).
- Mart inventories come from the script_mart lists per clerk; selling
pays half price; TM prices from tm_prices.asm.
## Overworld field systems
- Ledges from ledge_tiles.asm (facing + standing tile + ledge tile +
input direction -> two-cell hop).
- Counter talk-through uses the tileset's counter tiles
(tileset_headers.asm), which is how mart clerks and nurses work.
- Trainer sight (`home/trainers.asm` CheckFightingMapTrainers +
`engine/overworld/trainer_sight.asm`): extracted per-trainer range,
inclusive tiles along the facing line; detection runs only on
tile-aligned frames, before input handling, so on detection the d-pad
is dead (wJoyIgnore) and the player freezes on the spotted tile; the
"!" holds 60 frames (EmotionBubble), then the trainer walks
distance1 steps to the adjacent tile (none if already adjacent) and
uses the real battle/won/after dialogue from the trainer headers.
Sight is a pure screen-coordinate comparison with no line-of-sight
obstruction check (TrainerEngage / CheckSpriteCanSeePlayer): an
aligned in-range trainer engages through interposed NPCs and
unwalkable tiles, and the walk-up (TrainerWalkUpToPlayer, a fixed
distance1 MoveSprite_ script) has no collision either, so the
trainer simply walks/overlaps through anything on the line, as OAM
sprites overlap on hardware.
- Elevator rides (`engine/overworld/elevator.asm` ShakeElevator →
`src/world/ElevatorShake.lua`): choosing a floor stops the music,
bounces the BG scroll ±1 px around rest for 100 two-frame cycles with
SFX_COLLISION retriggered every cycle, restores the scroll, plays
SFX_SAFARI_ZONE_PA to completion, and restarts the map theme before
the floor warp. Lead-in delays kept per script: 9 frames of Delay3s
inside ShakeElevator (Celadon farjps in), 12 with the Silph/Rocket
scripts' extra Delay3. The offset applies to the BG layer only,
sprites are OAM and stay put. After the ride the port no longer
jump-cuts: choosing a floor rewrites the car's own exit-warp entries
to that floor (`engine/events/elevator.asm` DisplayElevatorFloorMenu
.UpdateWarp, per scripts/SilphCoElevator.asm /
CeladonMartElevator.asm / RocketHideoutElevator.asm), then the player
is walked out through the doorway onto that warp (ow:scriptMove →
ow:takeWarp), like the original.
- Field-move gates (engine/overworld/field_move_messages.asm +
start_sub_menus.asm): IsSurfingAllowed ported exactly, SURF refuses
with _CyclingIsFunText while the Cycling Road's BIT_ALWAYS_ON_BIKE is
armed (save.forcedBike: set on the Route 16/18 forced-bike tiles,
cleared by the gates, Fly, dungeon/blackout warps; the forced mount
itself is silent, as in CheckForceBikeOrSurf) and with
_CurrentTooFastText on Seafoam B4F's stairs square (7,11) until both
EVENT_SEAFOAM4 boulders are down. Re-selecting SURF while surfing is
ItemUseSurfboard's dismount attempt: steps ashore silently if the
facing tile is land-passable and unoccupied, else "There's no place
to get off!", and the menu closes either way (wActionResult stays 1).
STRENGTH's first page auto-advances after the cry + Delay3 (no
prompt); "can move boulders." prompts. The GBPalWhiteOutWithDelay3
white blink plays on every .goBackToMap closer: Strength, surf
mount/dismount/no-place, Flash (after its text), and Dig/Teleport
(Cut closes without a blink, per the asm).
- Wild slot table + rate per map; water encounter tables used while
surfing.
- Cut-tree block swaps from cut_tree_blocks.asm; surfable tilesets from
water_tilesets.asm (water tile $14, plus $32 on SHIP_PORT).
## Story events (data/scripts/story.lua and friends)
- Every hand-ported script cites its scripts/*.asm source and reuses the
real extracted text and event-flag names.
- Custom flag names (audited equivalent): three port-internal flag
families have no pokered EVENT constant but mirror the original's
state exactly. EVENT_TRADED_* are per-trade names for
wCompletedInGameTradeFlags bits (engine/events/in_game_trades.asm:
FLAG_TEST before the offer → after-trade text, FLAG_SET on completion;
dialogset text families, party-menu pick, the received mon joins the
end of the party, ConnectCable→anim→TradedFor→Thanks all ported).
EVENT_GOT_EEVEE is bookkeeping alongside the real guard, the hidden
ball object (scripts/CeladonMansionRoofHouse.asm HideObject, ≡
save.objectToggles), and self-heals older saves; a full party+box
keeps the ball claimable (_BoxIsFullText). EVENT_BEAT_SS_ANNE_RIVAL
stands in for scripts/SSAnne2F.asm's saved wSSAnne2FCurScript NOOP
progression, including the lose-and-retrigger path (flag only set on
victory). Names are kept for save compatibility. Coverage:
tests/parity_trade_gift.lua.
- The Pallet Town intro follows pokered exactly: the trigger is
PalletTownDefaultScript's wYCoord==1 check, Oak appears at (8,5) and
takes FindPathToPlayer's zigzag to one tile below the player, and the
escort is RLEList_ProfOakWalkToLab against the reverse-order playback
of RLEList_PlayerWalkToLab (the 17th simulated press is eaten by the
door-warp frame), followed by the OaksLab walk-in and choose-mon
exchange with map music deferred like BIT_NO_MAP_MUSIC. Oak's speech
ends with the real shrink: RedPicFront collapses through the extracted
ShrinkPic1/ShrinkPic2 into the overworld walking sprite on
OakSpeech.asm's frame timings (SFX_SHRINK, 4/4/20/50-frame beats, fade
to white), with the closing text box held on screen. The escort's
scripted steps run 16 frames/tile (chained single-tile scriptMoves
start back-to-back, no idle frame); Oak marches in place on the door
mat for RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat
(movement.asm ChangeFacingDirection → zero-delta TryWalking); the "!"
EmotionBubble overlaps the still-shown "Hey! Wait!" box
(PalletTownOakText prints without a button wait, then DelayFrames 10 →
EmotionBubble before the box clears); and the shrink beat ramps the
music to silence over ~70 frames (wAudioFadeOutControl = 10;
home/fade_audio.asm FadeOutAudio steps rAUDVOL 7→0) rather than
hard-stopping.
- The 12 disguised static wild battles (Power Plant Voltorb/Electrode +
Zapdos, Articuno, Moltres, Mewtwo) follow TalkToTrainer/
EndTrainerBattle exactly: cry + battle text, after-battle text without
a rematch once EVENT_BEAT_* is set, and the flag/HideObject on any
non-blackout result (fleeing loses the legendary, as in Gen 1).
Snorlax hides before its battle and only shows the calmed-down/
returned line when not caught. Zapdos/Articuno/Moltres/Mewtwo's
battle text is a text_far string ending in a bare "...@" terminator
(no <DONE>/<PROMPT>) followed by text_asm PlayCry + WaitForSoundToFinish:
the box types with no ▼ prompt and auto-closes only once the cry
finishes, never on a button press, ported via `Commands.play_cry`
stashing the pending cry for the following `Commands.show_text` to
consume as the TextBox's auto-close sound. Voltorb/Electrode's battle
text has no PlayCry call in the ROM at all and keeps the ordinary
button-wait close.
- Gym leader repeat dialogue (data/scripts/gyms.lua): each leader's
text_asm branches on EVENT_BEAT_<LEADER>, pre-badge talk prints the
pre-battle text and engages the leader battle (badge/TM via
data/scripts/victories.lua); post-badge talk prints the leader's
post-battle advice text (Misty's is her TM11 explanation). The
originals' middle branch (beaten but TM not handed over) is
unreachable since the TM is granted with the victory. Giovanni's
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
fade-to-black/fade-in Transition matching ViridianGym.asm's
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
permanently via TOGGLE_VIRIDIAN_GYM_GIOVANNI in save.objectToggles.
- Cable Club receptionists (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST →
CableClubNPC, all 12 Pokémon Centers): welcome, pre-Pokédex "making
preparations" brush-off, and the apply/save YES-NO are ported;
accepting saves the game and opens the link menu, declining prints
"Please come again!".
- Cinnabar fossil deposit follows GiveFossilToCinnabarLab: a menu of
carried fossils (FossilsList order), SeesFossilText with a Yes/No
confirm, ComeAgainText on either cancel.
- Hall of Fame induction: each party mon's front sprite scrolls in from
the left at 4px/frame, matching HoFShowMonOrPlayer's .ScrollPic
front-pic phase (engine/movie/hall_of_fame.asm); the back-pic's
enlarged/blurred pre-wipe is a VRAM-scroll-register trick not
replicated in this sprite-based renderer. The finale
(HoFDisplayPlayerStats) shows trainer name, play time, money, POKéDEX
seen/owned, and Prof. Oak's rating text (engine/events/
pokedex_rating.asm DexRatingsTable) from real save data.
- End credits + post-game reset (engine/movie/credits.asm,
scripts/HallOfFame.asm): screen-by-screen CreditsOrder pages (hlcoord
9,6 + signed columns), FadeInCredits' 4x5-frame ramp, 90/110/120/140-
frame holds, DisplayCreditsMon's 27-frame 8px/frame silhouette wipe,
LoadCopyrightTiles' three-row block, THE END at (4,8). While THE END
is up the HoF script autosaves (wLastBlackoutMap := PALLET_TOWN; the
player is saved in the HALL_OF_FAME room), waits 600 frames, then A/B
triggers `jp Init`, the boot sequence replays into the title screen.
- Victory Road's boulder switches replicate the original's
ReplaceTileBlock data: 1F boulder at (17,13) -> block $1D at (4,6);
2F boulders at (1,16)/(9,16) -> $15 at (3,4) and $1D at (11,7); 3F
boulder at (3,5) -> $1D at (3,5), and the (23,15) hole drops the
boulder to 2F (hide/show toggle). Barriers are re-applied from flags
on map entry, exactly like the originals' map-load scripts.
- Item balls, static legendary encounters and trainer rewards
(badges + gym TMs, the Silph Giovanni flag) are generic systems driven
by the extracted object args and a hand-ported reward table
(data/scripts/victories.lua).
- In-game trades use the real data/events/trades.asm table (species in,
species out, original nickname).
## Safari game (engine/events/hidden_events/safari_game.asm + engine/battle)
- ¥500 buys 30 SAFARI BALLs and 502 steps (scripts/SafariZoneGate.asm
sets `wSafariSteps = 502`); steps count down on the four outdoor zone
maps and hitting 0 (or throwing the last ball) ends the game at the
gate.
- Safari battles offer BALL / BAIT / ROCK / RUN; no player Pokémon
acts. The working catch rate starts at the species rate; BAIT halves
it and adds 1-5 to the bait factor (zeroing the escape factor); ROCK
doubles it (cap 255) and adds 1-5 to the escape factor (zeroing bait)
-- ItemUseBait/ItemUseRock in engine/items/item_effects.asm.
- Each turn one factor decays ("is eating!" / "is angry!"); when the
escape factor decays to 0 the catch rate resets to the species rate
(PrintSafariZoneBattleText, engine/battle/safari_zone.asm).
- Flee check (engine/battle/core.asm): `b = 2 * (speed % 256)`; the mon
always flees when speed > 127; while eating `b /= 4`, while angry
`b = min(255, 2b)`; it flees when `rand(0,255) < b`.
- The SAFARI BALL rolls the ULTRA_BALL rand range (0-150) in the Gen 1
catch formula, against the BAIT/ROCK-modified rate.
## Slot machines (engine/slots/slot_machine.asm)
- The three reels are the extracted 18-symbol wheel sequences
(data/events/slot_machine_wheels.asm); bet 1 plays the middle row,
bet 2 adds top+bottom, bet 3 adds both diagonals.
- Payouts: 7-7-7 = 300, BAR = 100, CHERRY = 8, MOUSE/FISH/BIRD = 15
(SlotRewardPointers).
- Per-wheel stop/slip rules ported exactly: wheel 1 spends up to 4 slip
charges, slipping past a centred CHERRY (in seven-and-bar mode it
always slips all 4 via pokered's `cp HIGH(SLOTS7)` bug); wheel 2 stops
as soon as wheels 1+2 line up any potential match (pairs checked b/b,
b/m, m/m, t/m, t/t) or, in seven-and-bar mode, on 7/BAR; wheel 3 rolls
past forbidden matches free and burns wSlotMachineRerollCounter
charges on winnable no-match spins, animated tile-by-tile. Luck flags
(SetFlags): seven-and-bar mode is sticky across spins; r==0 arms 60
allow-matches charges; a BAR win clears flags; a 300 win zeroes the
counter and clears flags with probability 128/256; 8/15 wins burn one
charge. Lines are checked in asm order with the first match taken;
A-presses are ignored while a prior wheel's slip counter is nonzero.
Machine and COIN CASE texts are byte-identical
(_GameCorner*Text; AbleToPlaySlotsCheck's no-coins gate included).
- Flow brackets: PromptUserToPlaySlots "A slot machine! Want to play?"
(YesNoChoice) and MainSlotMachineLoop's "One more go?" (TwoOptionMenu);
the x3/x2/x1 coin menu (CoinMultiplierSlotMachineText) defaults its
cursor to x3, bet = 3 - menu item. Static frame: the real
SlotMachineMap (gfx/slots/slots.tilemap, 20x12 tile ids < $25) blitted
from red_slots_1.png, extracted as field.slotSymbols.tilemap
(tools/extract/gfx.py extract_slots). Win flash:
SlotMachine_CheckForMatches.flashScreenLoop flips rBGP (shade 3->2) b
times at 5 frames each, b = 20/8/4/2 for the 300/100/15/8 rewards
(SlotReward{300,100,8,15}Func). Payout drip:
SlotMachine_PayCoinsToPlayer credits one coin every 8 frames (4 for a
7/BAR), SFX_SLOTS_REWARD per coin, rOBP0 symbol flicker every 5 coins.
## Spinner arrow tiles (scripts/*.asm arrow movement tables)
- Viridian Gym and Rocket Hideout B2F/B3F keep per-coordinate RLE
movement lists (map_coord_movement); each list executes backwards
from its terminator (DecodeArrowMovementRLE), sliding the player and
chaining onto further arrows.
## Cries (data/pokemon/cries.asm, audio/engine_1.asm)
- Each species = a base cry (one of 38 SFX_CryXX streams) + a frequency
modifier added to every note's frequency register
(Audio1_ApplyFrequencyModifier) + a tempo modifier
(`sfx tempo = $80 + length`, Audio1_SetSfxTempo). All 151 cries are
rendered offline with those modifiers applied and play on battle
entry and Pokédex pages.
## Hidden events & facility puzzles
- Card key doors (engine/events/card_key.asm): door tiles $18/$24
(SILPH_CO_11F: $5e) replaced with block $0e ($03 on 11F).
- Vermilion trash cans
(engine/events/hidden_events/vermilion_gym_trash.asm): the first-lock
can re-rolls on every Vermilion City map load (VermilionCity_Script's
Random & $e, even cans) and after every failed second-can guess; the
second lock uses the GymTrashCans table verbatim, including the
underflow bug that can place it in can 0 regardless of adjacency; a
wrong pick resets EVENT_1ST_LOCK_OPENED and re-rolls immediately; only
SuccessText3 prints on completion; the gym door block at (2,2) is
$24 closed / $5 open (scripts/VermilionGym.asm). SuccessText1/
SuccessText3/FailText play SFX_SWITCH/GO_INSIDE/DENIED from each
text's text_asm tail after the text prints (DisplayTextID's
WaitForTextScrollButtonPress then holds the box), so the port fires
them from an onDone on the TextBox, landing the beep as the box
closes rather than as it opens.
- Menu close-keys follow pokered's per-menu wMenuWatchedKeys mask, not
a single global rule: the shared Menu base (src/ui/Menu.lua) closes
on B only, and START-close is opt-in via opts.startCloses. Only the
start menu sets it, matching engine/menus/draw_start_menu.asm's
PAD_DOWN|PAD_UP|PAD_START|PAD_B|PAD_A; OptionsMenu also closes on
START via its own loop, matching engine/menus/main_menu.asm
DisplayOptionMenu's explicit B_PAD_B/B_PAD_START checks. Every other
menu (bag/PC item lists PAD_A|PAD_B|PAD_SELECT, party menu /
BUY-SELL-QUIT / USE-TOSS submenu / PC menus / Pokedex side menu
PAD_A|PAD_B) leaves PAD_START unwatched, so START does not close
them. START never replays SFX_PRESS_AB (HandleMenuInput_ beeps only
for the PAD_A|PAD_B branch).
- Old man tutorial hollow cursor: the item list is itself scripted in
pokered (DisplayListMenuID's old-man branch, home/list_menu.asm:65-91)
, no input is read; the filled '▶' hovers POKé BALL for 80 frames,
auto-presses A, then PlaceUnfilledArrowMenuCursor leaves the hollow
'▷' on that row until ItemUseBall tears the list down for the throw.
Ported via ListMenu's opts.script hook (src/ui/ListMenu.lua) and
BattleState:openOldManBag driving the same beats. The MissingNo./
wGrassRate side effects of the OLD MAN name swap are not modeled,
see docs/gameboy-hardware-limitations.md.
- Gym statues (gym_statues.asm): plaque with the city/leader from each
gym's script; the player joins WINNING TRAINERS with the badge.
- Route 22 gate / Route 23 guards: real trigger rows, badge order
(EARTH down to CASCADE) and EVENT_PASSED_*_CHECK skip flags.
- Game Corner poster (scripts/GameCorner.asm): block (8,2) $2a -> $43
on EVENT_FOUND_ROCKET_HIDEOUT.
- Seafoam Islands (scripts/SeafoamIslandsB3F/B4F.asm): reversed-RLE
current paths, Seafoam4HolesCoords boulder holes setting the
EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE pairs, the forced pool exit rows.
- Rock Tunnel darkness: wMapPalOffset = 6 on entry, cleared by Flash
(BOULDERBADGE) or leaving (home/overworld.asm).
## Battle extras
- GROWL/ROAR (GetMoveSound/IsCryMove, engine/battle/animations.asm
~2196): the move's own MoveSoundTable tempo byte (Growl $c0, Roar
$40, both pitch $00) layers onto the cry via `Sound.playMoveCry`'s
`Source:setPitch(256/(128+tempoMod))`. Transform (engine/gfx/
palettes.asm DeterminePaletteID, bit TRANSFORMED): the swapped-in pic
is tinted PAL_GRAYMON via `PaletteFX.monPal(data, species,
transformed)`, not the copied species' own palette, in
`BattleState:speciesSprite`. Growl (DoGrowlSpecialEffects,
animations.asm ~928): AnimPlayer's GROWL frame-block branch keeps a
`growlNoteTrail` snapshot so each block's emitted sprites include the
previous block's note copy alongside the current one (GROWL skips
AnimationCleanOAM between blocks per the `cp GROWL` check ~line 145);
ROAR is unaffected since the asm never applies this quirk to it.
- Master/Ultra ball tosses flicker the OBJ palette: DoBallTossSpecial
Effects (engine/battle/animations.asm:685) XORs rOBP0 with %00111100
after every frame block while wCurItem <= ULTRA_BALL, so the 11 toss
blocks alternate the $F0/$CC shade maps starting normal; PlayAnimation
pushes/pops rOBP0 around each subanimation row, so the ambient
palette returns when the toss ends. GREAT/POKE/SAFARI balls never
flicker, and the toss arc always follows wCurItem via
TossBallAnimation, including the ghost-dodge throw.
- Anim-layer OBJ colorization is per 8x8 attribute cell: the SGB's
ATTR_BLK regions color the composited DMG picture per cell, not per
OAM entry, so an anim sprite overlapping a zone boundary takes each
cell's palette on the pixels inside it, AnimPlayer samples the zone
under every cell an 8x8 tile touches and repaints differing cells
through a cell-clipped scissor (aligned tiles stay one draw).
- Ball wobbles (ItemUseBall): Z = X*Y/255 + status2 with
Y = rate*100/ballFactor2; <10/<30/<70 -> 0/1/2 shakes, else 3, with
the matching ItemUseBallText01-04 lines.
- Trainer class AI (data/trainers/ai_pointers.asm +
engine/battle/trainer_ai.asm): per-class item/switch routines with
wAICount uses per Pokémon, ported to data/scripts/ai_classes.lua.
- Exp (engine/battle/experience.asm): baseExp*level/7 divided by the
participant count, x1.5 for trainers, x1.5 for traded mons; stat exp
in full to each participant.
- Move sounds: data/moves/sfx.asm (sound + pitch/tempo per move). The
pitch/tempo modifiers are applied at synthesis time
(Audio2_ApplyFrequencyModifier adds pitch to every frequency write;
Audio2_SetSfxTempo scales tone-channel note lengths, noise skips it),
128 variant WAVs keyed "<sfx>@<pitch><tempo>" that Sound.playMove
selects, exact rather than a playback-rate approximation. Per-row
sounds fire as PlayAnimation does; GROWL/ROAR (IsCryMove) play the
attacker's cry. Hit sounds by effectiveness (Damage/Super/NotVery).
- Screen-effect animations (engine/battle/animations.asm +
engine/gfx/screen_effects.asm): every SE_* is implemented per-routine,
FlashScreen/FlashScreenLong (the FlashScreenLongSGB 12-entry table),
Dark/Light/DarkenMon/Reset palette ops (shade-map permutations of the
SGB zone palettes), all SlideMon variants, ShakeBackAndForth,
BoundUpAndDown, SquishMonPic, Minimize (real MinimizedMonSprite),
spiral/shoot-balls/water-droplets/leaves emitters compiled from the
asm trajectories, per-animation-id frame-block flashes (Explosion,
Rock Slide's rumbles, Blizzard's cadence...), AnimationWavyScreen with
true per-scanline offsets, PredefShakeScreenHorizontally/Vertically
and ShakeEnemyHUD. SE rows carry the faithful blocking durations.
- SGB battle colorization (SetPal_Battle, BlkPacket_Battle,
SetAnimationPalette): the battle screen is colorized by zone, player
HUD, enemy HUD, player mon + message box, enemy mon; trainer front
pics and the player/old-man back pics take PAL_MEWMON (both species
IDs are zero at the intro, so MonsterPalettes[0]); the ghost keeps the
disguised species' palette; attack animation sprites and thrown balls
are colored through the OBJ palettes (wAnimPalette $F0 on SGB, ambient
$E4, OBP1 $6C). Headless/no-shader environments fall back to the flat
pipeline.
- Mimic resolves mid-move (MimicEffect): accuracy first, then the
player's copy menu (enemy/link copy a random slot); the copy
overwrites only the slot's move ID, PP is shared with Mimic's slot,
and reverts on switch/battle end.
- Old man tutorial (DisplayBattleMenu's BATTLE_TYPE_OLD_MAN branch): the
real scripted cursor, ▶ beside FIGHT for 80 frames, beside ITEM for
50, ITEM force-selected into the POKé BALL x50 list; the throw always
catches at full HP (item_effects.asm jumps straight to .captured, 3
shakes, no party/dex add, no ball consumed); backing out of the bag
replays the script. The old man never attacks, the original tutorial
is menu navigation + a guaranteed catch, nothing more.
## Link battles (lockstep)
- Both sides simulate with a shared Park-Miller RNG stream (host deals
the seed), identical pack/unpack-clamped party copies, no badge
boosts, and a mirrored speed-tie roll (the guest inverts it); a
canonical host-side-first state hash is exchanged per turn and any
mismatch ends the match as a draw.
## Music (audio/engine_1.asm)
- Note duration: `frames = length * speed * tempo / 0x100` with
fractional carry, at 60 fps (Audio1_note_length / CalculateDelay).
- Frequency: `reg = pitches[note] asr (octave - 1)` (CalculateFrequency;
the octave byte stores `8 - octave`), `f = 131072/(2048 - reg)` for
squares, halved for channel 3.
- note_type volume/fade renders as an NRx2-style envelope (step every
`fade/64` s); duty_cycle maps to 12.5/25/50/75% pulse widths;
sound_call/sound_loop honor the engine's one-level call stack and
loop counters.
## Text & font
- The Pokédex height row uses the real /″ tiles: gfx/pokedex/pokedex.png
tiles 0/1 are patched over font-extra slots $60/$61 exactly as
engine/gfx/load_pokedex_tiles.asm loads them over vChars2 (they replace
glyphs charmap.asm marks unused); ASCII `"` aliases to the closing-
quote glyph $73 so stray hand-written quotes render.
## Validation against the original
- `tests/run_tests.lua` pins hand-checked values: L5 Bulbasaur 19 HP /
9 Atk at 0 DVs, L100 Mewtwo 415 HP / 406 Spc at max DVs+statExp,
MEDIUM_SLOW(5) = 135, type chart spot checks, deterministic damage
rolls, Route 1 slot 1 = L3 Pidgey.
- The autopilot run reproduces the original's early flow on real map
data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by
walking off the mat, connection into Route 1 at matching x.
-50
View File
@@ -1,50 +0,0 @@
# ROM Extraction Notes
There are two ROM-only extraction paths:
- The packaged app uses `src/import/RomImporter.lua` and
`src/import/RomExtractor.lua` on first boot.
- Developers can run `tools/build_data.py --rom <path> [--clean]` to generate
data in the source tree for audit and parity work.
Both paths read only the supplied ROM and the checked-in
`tools/rom_manifest.json`. Neither invokes RGBDS, Git, or a disassembly.
## Validation
Only the canonical US Pokemon Red ROM is supported. SHA-1 is checked before
any cached output is removed or written.
## Decoded Data
| Area | ROM data |
| --- | --- |
| world | map headers, block maps, connections, warps, signs, objects |
| tiles | tileset graphics, blocksets, collision, door and warp tile lists |
| text | 2,584 text command streams and RAM/number substitutions |
| Pokemon | names, stats, evolutions, learnsets, Dex data, compressed pictures |
| battle | moves, detailed animations, OAM frames/tiles, effects, type chart, palettes, trainer parties/AI/pictures |
| inventory | item names, prices, key-item flags, TM/HM data |
| encounters | grass and water wild tables |
| UI | fonts, icons, title/intro, trainer card, town map, slots, field effects |
| audio | music, SFX and cry headers, channel programs, wave instruments |
The Python and Lua picture decompressors implement the Gen 1 `pic` format.
Graphics are converted to RGBA PNGs. OAM artwork uses transparent color 0;
battle pictures use edge-connected white matting so white interior details
remain visible.
The in-app importer stores three audio ROM banks as a 48 KiB
`programs.bin`. `src/core/ChipAudio.lua` interprets the channel bytecode and
synthesizes music as a queueable stream; SFX and cries are synthesized on
demand. This avoids shipping or generating a large WAV/OGG tree.
## Metadata Boundary
Names, dimensions, enum ordering, Lua script hooks, and hand-ported field
behavior do not survive compilation in a form the Lua runtime can infer.
Those relationships are bundled in `rom_manifest.json`. The manifest stores
no dialogue strings, images, audio samples, or ROM bytes.
`tools/make_rom_manifest.py` and `tools/verify_rom_data.py` are developer audit
tools. They are not used by the packaged game.
-48
View File
@@ -1,48 +0,0 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode.
## 1. Install AltStore
Follow the official guide for your computer:
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
You will install **AltServer** on the computer, then use it to put AltStore
on the phone. What AltServer is and why it needs to stay running:
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
Stuck? Start here:
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer).
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
16+), and Settings → **General → VPN & Device Management** → Trust your
Apple ID if asked.
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
play.
## Refresh / 7-day limit
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
AltServer running so AltStore can refresh them, or open AltStore and refresh
manually before they expire. Saves on the phone are kept across refreshes.
## Prefer building it yourself?
Building from source on a Mac (no AltStore) is covered in
[ios-install.md](ios-install.md).
-42
View File
@@ -1,42 +0,0 @@
# Known differences from the original game
Only genuine remaining divergences live here: behavior that is still
**missing, wrong, or approximated for convenience** and would need more
work for true parity. Faithfully-ported behavior is documented in
docs/behavior-porting-notes.md; deliberate additions beyond the original
are in docs/new-features.md.
## Reimplemented unused Prof. Oak and Rocket Chief battles
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
both battles are unreachable in the real game. This project makes them
fightable after the Hall of Fame:
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
is set, using `ProfOakData`'s three starter-matched teams (the team is
picked by the type that counters your starter, mirroring the rival).
- The Celadon Game Corner Chief battles you in his house post-game.
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
reconstructed party.
This is an intentional divergence: neither battle can be triggered in the
original game.
## Reimplemented unused Silph Co. card-key doors
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
door block at those coordinates, so the card key check is dead code in
the original game. This project stamps the closed door block (`$54`/`$5f`
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
map load, and swaps it for the open block once that door's
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
Rocket grunt, as in the original's unused design).
This is an intentional divergence: the doors are not visible or
functional in the original game. The door layout lives in
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
it straight through on ROM import.
-294
View File
@@ -1,294 +0,0 @@
# Launcher
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM / mod / save import
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
save directory as:
| `kind` | Destination |
| --- | --- |
| nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
for the launcher to acknowledge on refocus.
`RomImporter` then imports on refocus / Choose:
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
matches the open game tab, so a shared `imports/` folder with Red+Yellow
cannot jump Yellow → Red.
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
centered pill in a reserved band just above the footer, on every tab. That
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
## Save slot model
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
abstraction (`persistFs`) every other save/options call uses, so portable
mode (an `io.*` filesystem used when `portable.txt` marks the install)
keeps working unchanged.
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
`slot%d+`; `createSlot` allocates one past the highest existing number so a
reused id can never collide with a lingering file.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
Custom slot labels (#205) live alongside them in the same registry:
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
each `listSlots` row as `label` (the launcher row shows `label`, falling
back to the player name). `deleteSlot` drops the label with the slot.
Renaming never touches the save file, so an empty slot can be labeled.
On desktop, right-clicking a slot row opens the inline rename modal
(Enter commits, Esc cancels); touch has no secondary button, so the
affordance is desktop-only.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
Resolved once per version per process (`ensureVersionSlots`, cached in
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
legacy migration may create one; otherwise the flat legacy path is used
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
- **Legacy migration.** One-time per version, lazy on first
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
file exists and no `saves/<version>/` registry does, its main + `.bak` are
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
originals removed and `slot1` registered as active. A copy that fails to
verify leaves the originals in place; migration never loses data.
The launcher-facing API:
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
every registered slot. `name` is the save's player name, or `nil` for an
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
title screen's `ContinueInfo` shows) or `nil`. The pure part,
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
it as active, and updates the process cache so the very next save/load
lands there. The launcher calls this the moment a slot row is clicked
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
before `Game:load`, so **it never loads a mod's entry chunk**; only
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
`Loader:_discover` finds mods without running them. The real loader
(`src/mods/Loader.lua`) still owns the actual load at boot.
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
the zip root, or inside one top-level folder), validates its manifest, and
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
duplicate of an already-installed mod id, and accepts either an external
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
- A mod that declares `github` shows its total GitHub downloads (every
release's summed asset `download_count`, from the same cached release
fetch the update check uses) as a highlighted body line like "12,345
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
(first and latest `published_at`). Old cache entries written before the
counts existed show no line rather than a wrong zero; a manual check
refreshes them.
- The MODS panel sorts its rows by Name, Popularity (downloads),
Release date (first release), or Last updated, chosen by chips under the
header and persisted in `options.modSort`. Mods without release data
(no `github` field, or a stale cache) sink to the bottom of data sorts.
## Import / Export save
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable).
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs.
On **NX (Switch)** there is no picker: copy a `.sav` into
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
(one folder per game), then press **Import save** on that games tab to
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected. On **NX**, a successful inbox
import retires the file to `*.sav.imported` and records a content hash in
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
bytes under a new name) does not clone slots; failures leave the original
`.sav`. Only that games folder is scanned.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
root `persistFs` writes slots to: the portable game folder when `portable.txt`
marks the install, otherwise the save directory (`exports/` and
`exports/<version>/` are created as needed; #752). On desktop it returns the
absolute path (`SaveData.portableBaseDir()` when portable, else
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
On **NX**, export success sets a notice with the `exports/<game>/` path and an
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
SD / FTP instead).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
nothing to export, ROM not imported yet) surfaces as a red notice line on the
card. Nothing raises and nothing silently no-ops.
`SaveFileIO` is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
## Responsiveness
Every measurement derives from `love.graphics.getDimensions()` each frame
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`),
under which the cards stop being readable at all. Mobile ignores it: those
windows are fullscreen.
### Page scroll
Two columns fit any window the launcher is likely to open in; one stacked
column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and
SAVE SLOT together run past the bottom, and a footer pinned to the window
bottom painted over them with the overflow unreachable.
So the whole column under the tab bar -- panel, updater banner, footer --
scrolls as one page whenever it is taller than the room below the tab bar:
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
Everything else draws at `contentTop - pageScroll` inside a scissor, and the
footer is laid out downward from `footerTop` right after the content instead
of upward from the window bottom.
- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole
decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A
window that grows back drags the offset down with it, so the page can never
stay parked past its own end.
- The panels report their natural height as they draw (`_drawGamePanel` and
`_drawModsPanel` return it), so the decision reads the previous frame's
measurement -- the same one-frame settle the two lists already rely on.
- **One scroll axis at a time.** While the page scrolls, the panels draw
`paged`: the slot and mod lists take their natural height, keep no inner
scroll region and report a max of 0, so the wheel, the right stick and a drag
all move the page and never fight a list for the same gesture. Two-column
layouts do not overflow, `paged` stays false, and every one of these behaves
exactly as it did before.
- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a
rect that scrolled out of the viewport, so a control that slid under the tab
bar cannot be clicked through it. Tab chips carry `pinned = true` and are
exempt. `pageScroll` resets on a tab change, each tab being a different
length.
- A press on empty background pans the page, resolved in `_updateSlotDrag` like
every other drag here.
### Dragging on Android
The launcher is handed no move events on any platform: `main.lua` forwards
neither `touchmoved` nor `mousemoved` while it is up, which is why every drag
here is resolved by polling inside `draw` instead. Desktop polls the mouse;
Android used to poll nothing at all ("no reliable pointer polling" meant its
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
scroll region was an inner list with a wheel alternative, useless the moment
the page itself became the thing that scrolls, since a phone is exactly where
it overflows.
`love.touch` is pollable, so `_pointerHold` reads the first active touch there
and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop.
Consequences:
- Slot rows and mod toggles ARM on press and commit on release on Android too,
matching desktop, so a swipe that starts on a card scrolls instead of
selecting the row it started on.
- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is
missing, every Android path is exactly what it was: act on press, never arm,
no drag.
+54
View File
@@ -0,0 +1,54 @@
# Linux ARM SBC Handhelds (PortMaster)
Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices.
## Install
1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder.
2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`.
3. Install PortMaster for your firmware and refresh the Ports list.
4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`.
5. Launch **gen1recomp-sbc** from Ports and choose the ROM.
The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes.
Canonical US cart SHA-1 values:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
## Controls
| Input | Action |
| --- | --- |
| D-pad | Move cursor |
| A | Click / confirm |
| L1 / R1 | Switch tabs |
| Start / Select | Play or choose ROM |
In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**.
## Runtime and suspend
The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points.
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
## Building
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
```sh
./build-linux-arm-sbc.sh --version 0.1.75
```
For development, package a local checkout explicitly:
```sh
GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0
# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0
```
The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card.
PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem.
+202
View File
@@ -0,0 +1,202 @@
# Linux arm64 (aarch64) AppImage
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
aarch64 handhelds that run a full distro.
> The Anbernic RG34XXSP has its own PortMaster-style pack
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
> desktop-Linux artifact and shares nothing with it but the `game.love`.
## For players
```sh
chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage
```
Then use **Import ROM** in the launcher to point it at your own legal Red /
Blue / Yellow cartridge dump, exactly as on every other platform.
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
install it (`sudo apt install libfuse2`) or run without it:
```sh
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
```
### What the host has to provide
Very little, and this is enforced by an assertion in the build rather than by
good intentions. The only libraries the AppImage requires at startup are:
```
glibc 2.29+ libstdc++ libfreetype6 zlib
```
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
**dlopened**, so it is used when present and skipped when absent. That means
one image runs on a full desktop, on a Wayland-only session, on a
KMSDRM-only handheld with no X server, and on a box with ALSA but no
PulseAudio, without a different build for each.
That property does not come for free from Debian's packages, and getting it
is most of what the build below is doing; see
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
## For builders
```sh
scripts/build_linux_arm64.sh --version 0.1.0
```
Output:
```
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
```
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
this so every platform ships identical bytes), `--rebuild-image` forces the
builder container to rebuild, `--clean-cache` throws away the pinned
downloads and the compiled LÖVE prefix.
### Requirements
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
reference machine (a cold build takes about 10 minutes on one — six libraries
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
The script refuses to run on x86_64 rather than falling back to qemu-user
emulation: that path takes hours and has produced miscompiled LuaJIT.
### Why this is not just another `scripts/build.sh` target
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
unpacks its squashfs, drops `game.love` in, and glues it back together. That
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
and that is the entire list.
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
five libraries built alongside it, and the AppImage type-2 runtime — is
SHA-256 verified on the host before the container ever sees it, and the
container itself runs with no network access.
### Why the build happens in a Debian bullseye container
glibc is backward compatible but not forward compatible: a binary linked
against glibc 2.41 will not start on a system with 2.31, and there is no way
to fix that after the fact. Compiling on the oldest base we support is
therefore the only thing that makes one artifact work everywhere.
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
trixie.
This is a statement about the *compile environment*, not about where the
artifact runs — building on your own newer distro would silently raise that
floor and strand every user on an older one, with no symptom until they
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
required glibc symbol version climbs above 2.31.
### Why five libraries are built from source
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
than installed from bullseye. In every case the reason is *correctness*, not
a newer version number — Debian builds these for a system where every
dependency is installed and co-versioned, which is the opposite of an
AppImage's situation. Each one broke the build in a different way, and all
three failure modes are now assertions that fail the build instead of
shipping.
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED`
resolved by the loader at startup, not dlopened. An AppImage bundling it
refuses to start unless the host has *all four*. It appeared to work in
testing only because a desktop Pi has all four; a headless CI runner is what
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
hard-links `libasound`. Built from source with `--enable-*-shared` and
`ALSOFT_DLOPEN`, both dlopen their backends instead.
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
and freetype. `--disable-examples` produces a `libtheoradec` needing only
`libogg`.
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
to *our* copies:
```
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
```
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
config collapsed, and the game ran with **no audio device at all**. Not
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
host packages; building them current means our copies *satisfy* the host's
`libsndfile` rather than starving it.
The same collision is why the font stack — freetype, fontconfig, libpng,
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
2.11) and the game died at startup. Leaving the whole stack to the host keeps
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
asks for symbols every supported host already has.
The general rule this all reduces to: **never bundle a library the host's own
stack may also load, unless yours is at least as new as theirs.**
### CI
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
the pins are real digests on a dated tag rather than the moving
`continuous` one, that the Dockerfile still builds on bullseye, that the
exclude list still classifies known sonames correctly, that AppRun still
launches `game.love` with `--fused`, and that the host-arch guard actually
fires. Needs no container and no arm64 machine.
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
the artifact and asserts the layout, that every bundled object resolves
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
Uploads the AppImage for 7 days.
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
`game.love` from the `love-payload` job, and the AppImage is staged and
published like every other release asset.
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
it runs on fork PRs too.
### Updating the pins
Both pins live in `scripts/linux-arm64/common.sh`:
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
cached prefix automatically (its name is keyed by every source version at
once, so a partial rebuild cannot mix vintages). Check that bullseye still
has `-dev` packages new enough for the new release; `build_appimage.sh`
asserts every optional module actually linked, because LÖVE's `configure`
exits 0 and silently drops a module when one is missing.
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
source-built libraries. Bumping these is usually safe and occasionally
necessary: `libmpg123` in particular must stay at least as new as what a
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
The selftest fails the build if this ever points at `continuous`.
+862
View File
@@ -0,0 +1,862 @@
# Mods and Gen 2 (Gold)
The mod API is one API across both generations. Hook names, event names,
registry names and the `mod.*` facade are shared on purpose: a mod that runs on
Red should be able to run on Gold without learning a second vocabulary.
What differs is how much of it Gold can actually serve, and that is why Gen 2
support is something a mod **declares** rather than something it inherits.
## What you can rely on today
The short version, for an author deciding what to write:
- **Every registry name, hook name and event name means the same thing in both
games.** Nothing is prefixed, renamed or repurposed per generation. Where Gen
2 genuinely carries more, the record or the payload gains a *field*.
- **40 of the 46 registries are available on Gold.** 17 keep their Gen 1 target
outright (`commands`, `tokens`, `growth_rates`, `battle_sprite_scales` and
`render_pipelines` among them), 16 route to a Gen 2 table under the same
name, 6 are Gen 2-only systems Red has no counterpart for, and `migrations`
is a code registry with no data target in either game. The other 6 are gated,
and are listed below with the consumer change each one still needs.
- **A registry with no home in a generation is reported, never silently
merged.** The write is taken, dropped, and named once per mod in the same
error feed the mod manager shows -- in both directions, so a Red boot writing
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
- **40 event names and 43 hook names have a call site in both generations**, so
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
reads those names back out of the source and fails if a site is renamed or
deleted on either side, and fails again if a new shared site appears without
being listed here.
- **24 further names are Gen 2-only** (friendship, breeding, the Pokegear, the
radio, Pokerus, the roamers, Kurt, the Bug Contest, the Unown puzzle, mail,
held items, shininess, gender, and the five cards of the GS boot cinema).
They are plain names, not a `gen2.` namespace, so if Red ever grows the
system the name is already right.
- **Every Gen 2 seam is guarded** by `Runtime.wants` / `Runtime.wantsHook`, so
a boot with no mod subscribed allocates nothing at any of them.
- **A mod is loaded on Gold only if it says so.** See `gen2compat` below.
`src/mods/Schemas.lua` is authoritative for routing;
`tests/engine/gate_gen2_mod_api.lua` holds this document to it.
## Declaring which games a mod is for
```json
{
"id": "my_mod",
"name": "My Mod",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"games": ["gen1", "gen2"]
}
```
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
`GameVersion.generation`, so nothing anywhere restates the game list.
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads.
Nothing moves on disk for any of this. A mod is installed once, into
`mods/<id>/`, and that directory serves every game: there is no `mods/gen1/`
and no per-generation copy. Targeting is declared, not filed.
`"gen2compat": true` is the legacy spelling and is still accepted. It is purely
additive -- it *adds* the Gen 2 games to whatever `games` says -- so no shipped
manifest can lose a game it already ran on. A manifest with neither key is Gen
1 only, which is exactly what it always meant. An unknown token warns and is
dropped under `api` 1 and refuses the manifest under `api` 2; a `games` array
that names no game this engine knows falls back to the default rather than
orphaning the mod; a non-array `games` is a hard error.
Every token is enforced, per game. `Loader:_gateGeneration` gates on
`ModTargets.supports(manifest, version, generation)`, the same call both mod
surfaces make, so `"games": ["blue"]` really does not load on Red and the
loader's skip line is the launcher's line, `For Blue, not Red`. A manifest with
no `games` and no `gen2compat` still covers every Gen 1 game, so nothing
written before the key existed changes behavior.
On a Gold boot, a mod claiming no Gen 2 game is **not loaded at all**: no
registrations, no subscriptions, no entry chunk. The manager still lists it,
showing `ENABLED (NOT THIS GAME)` and the reason, and the player's enable flag
is left alone so it comes straight back on Red.
Both mod surfaces derive what they show from `ModTargets` rather than from
their own copy of the rule. The launcher's mod panel carries a `Show for:` game
chip row and a per-mod tag (`GEN 1`, `GEN 1+2`, `RED/GOLD`), greyed with `Not
for this game` and the detail `For Gen 1, not Gold` when the mod does not run
on the selected game; the in-game manager shows the same verdict as
`ENABLED (NOT THIS GAME)` plus an inert `FOR GEN 1+2` row on the detail screen.
The launcher asks the same question of a mod's dependencies: one whose hard
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
matching the loader's contagious skip.
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
for every installed mod, and the loader and in-game manager read the same
game-specific answer on the next boot. On the first launch after this feature,
the existing shared state is copied to every game, so a mod that was enabled
remains enabled everywhere; after that, changing one checkbox affects only
that game. New mods still default to enabled on every game (experimental mods
retain their explicit opt-in default).
That is deliberate. Gold reimplements the battle engine, the overworld, the
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
find a small fraction of its call sites live. A mod that half-applies reads to
a player as a broken mod. Not running is the honest state, and naming a Gen 2
game is the author saying "I have tested this there".
Adding a Gen 2 game does not opt out of anything on Gen 1, because `games` is a
union: `["gen1", "gen2"]` covers everything it covered before. What does change
is that the gate now runs on a Gen 1 boot too, so a manifest that names *only*
Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
generations if you want both.
Two riders. **A hard dependency that does not run here takes the dependent down
with it** (unless scoped to specific games, e.g.
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
failure and carrying the dependency's own wording (`depends on X, which does not
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
whose author never opted in, which is the only route for a mod written before
the field existed. The override is per game -- `options.modsGen2[id]` is a
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
Gold, and a legacy `options.modsGen2[id] = true` reads as "the Gen 2 games",
the only set it could ever have affected. It applies on the next boot; a forced
mod loads normally and keeps a note saying it was never verified here. Where
the choice cannot be persisted the manager says `COULD NOT SAVE` instead of
promising a restart.
If you are writing new code, still prefer the API: take the live game from
`mod.game` (or the `game.ready` payload, or a `ui.*` hook's first argument) and
the world from `mod.world`. Those are the names that mean the same thing in
both games. What follows is for the mods that were written before Gold existed
and reach past it.
## Gen 1 module facades
A mod with `engine_internals` reaches engine modules by name, and under Gold
those names used to resolve to Gen 1 modules nothing instantiates -- so the
patch landed on dead code and the mod was inert with no symptom but silence.
On a Gen 2 boot, **a require made from a mod's own chunk is answered by an
adapter**: the Gen 1 API, backed by Gen 2 internals. `src/mods/Gen2Compat.lua`
is the table, `src/mods/Loader.lua`'s require shim is where the swap happens,
and `tests/engine/gate_gen2_mod_facade.lua` holds both to it. Engine code is
not affected -- the shim only substitutes when the calling chunk is outside the
engine tree, so `src/render/PaletteFX.lua` still gets the real Gen 1 module on
both generations.
Fifteen names are served. **alias** means the adapter *is* the Gen 2 module, so
a monkey-patch, a `rawset` sentinel and a `getmetatable(x) == M` check all land
on the table Gold runs; **facade** means a translating wrapper over it.
| the Gen 1 name a mod requires | kind | what it gets on Gold |
| --- | --- | --- |
| `src.core.Game` | facade | a live proxy onto the Game2 instance |
| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua`; `World:step` / `:interact` / `:interactBody` dispatch through it |
| `src.world.Map` | alias | `src/world/gen2/Map.lua`, grown Gen 1's statics and instance methods |
| `src.world.NPC` | alias | `src/world/gen2/Npc.lua`; `NPC.new` sniffs the Gen 1 argument order |
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua`, plus Gen 1's `COUNT` / `CAPACITY` / `ensure` / `active` / `deposit` |
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua`, write-through |
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua`, write-through |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` |
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` |
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` |
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua`, write-through |
| `src.world.FieldDefaults` | facade | the `playerSprites` answer, and a named refusal for the rest |
| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` |
| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua`, write-through |
| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` |
Two entries in that table are not the pairing they look like.
`src.ui.BoxMenu` resolves to `PcMenu`, not to `src/ui/gen2/BoxMenu.lua`: Gen 1's
`BoxMenu` is Bill's PC *top menu*, whose Gold counterpart is `PcMenu`, while
Gold's `BoxMenu` is the withdraw/deposit *list* Gen 1 builds inline. And
`src.script.ScriptRunner` is served narrowly rather than fully: `scanLabels`
and `validate` forward verbatim, with the default verb lookup swapped to
`game.data.commands` so a script of Gen 1 built-ins cannot validate clean and
then run as nothing, while the lifecycle half is a thin handle onto the one
`world.vm` with `resume` and `update` refused rather than double-driving it.
The `script.started` / `script.ended` / `script.command` seams are the
supported route and already work on Gold.
`src.script.Commands` and `src.ui.OptionRows` have **no** adapter and are the
two names a require of which still lands in the boot error feed the manager
shows, with the module named. Both load fine under Gold and both are traps: the
first hands back 61 Gen 1 verbs none of which Gold can run, the second paints
Red's four-box options chrome over Gold's single 18x16 one.
`docs/preparing-your-mod-for-gen2.md` is the migration guide for an author
working through this, and `python3 tools/modkit.py gen2check <id>` reports a
mod's own findings against the coverage table below.
Three rules the adapters keep, because a plausible wrong answer is worse than
the module being missing:
- **Live, never a snapshot.** A mod captures `require("src.core.Game")` at file
scope, before a save or a world exists. The facade is a proxy that reads the
live instance on every touch, so `Game.save` is nil during the entry chunk
and correct forever after. It aliases the two names Gold spells differently
(`Game.overworld` is `Game2.world`, `Game.writeOptions` is
`Game2:persistOptions`) and the one data table that was renamed
(`game.data.sprites` is `data.gen2Sprites`).
- **A member with no backing says so.** `game.data.field` does not exist on
Gold, so it reads nil *and* logs once, naming the mods holding the facade.
`BattleState.newWild` is absent rather than invented, because a `newWild`
that took a species and a level would be a lie about what Gold's battle
screen is.
- **One stable table for the run.** Where the Gen 2 arm can serve the name
outright the adapter *is* that module, so a mod's monkey-patch, its
`rawset` sentinel and its `==` idempotency check all land on the table Gold
actually runs.
### What the adapter says it covers
The adapter publishes its own coverage, versioned by
`Gen2Compat.COVERAGE_VERSION` (1), and `modkit gen2check` consumes that table
rather than a second copy of the same knowledge:
```lua
Gen2Compat.modules() -- the 15 names, sorted
Gen2Compat.serves(name) -- boolean
Gen2Compat.memberStatus(name, member) -- "backed" | "warned" | "absent" | nil
Gen2Compat.coverage(name) -- a fresh table per call:
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
-- notes = { [name-or-topic] = "one line" } }
```
The status vocabulary is frozen at three values, and a member listed as both
resolves to the weaker claim:
| status | means |
| --- | --- |
| `backed` | present, and it does the Gen 1 job on Gold |
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
| `absent` | deliberately not served; a nil read is the honest failure |
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
`notes` keys are documentation topics rather than a member list -- dotted paths
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
the authoritative set, and a member it does not record is not a promise either
way: on an alias it resolves to whatever the Gen 2 module has, on a
write-through facade it falls to the Gen 2 class, on the `src.core.Game` facade
it reads nil and says so, and on the `src.world.OverworldController` facade it
reads nil silently.
**The follower.** Gold's cart has no trailing companion at all, so
`src/world/gen2/Follower.lua` is new Gen 2 code rather than a facade: the
entity, the trail loop, and a `shouldSpawn` a mod replaces. `World:step` calls
`Follower.update(game, world)` once per logic frame after the body, and
`World:setMap` calls `Follower.onMapEntered` before it emits `map.entered` --
the same two call sites `src/world/OverworldController.lua` gives the Gen 1
arm, which is what makes a Gen 1 follower mod's wrappers tick.
Vanilla never spawns one: `shouldSpawn` answers false until something replaces
it. `Follower.setShouldSpawn(fn)` is the supported way, and it writes the same
file-local the Gen 1 mods reach through `debug.setupvalue` on the upvalue named
`shouldSpawn`, so the two cannot disagree.
Two Gen 2 engine changes came with it, both general rather than follower-only:
an entity with `passable` set never blocks a step (the Gen 1 name and meaning,
`src/world/Collision.lua`), and `World:rebuildPeople` now preserves **guests** --
anything in the people list it did not put there. A rebuild runs on every zoom
and every time-of-day roll, so without that a follower vanished at the top of
the hour.
**What the facades cannot fix.** A mod that allow-lists version strings
(`GameVersion.get() == "red" or ...`) excludes itself from Gold by construction,
and no adapter should special-case it. Neither is a Gen 1 screen id: Gold's
builtins carry a `Gen2` prefix, so a mod matching `id == "BoxMenu"` matches
nothing. A write to a field on a live Gen 2 menu instance is inert where Gen 1
read it back (`menu.onSwitch`, `menu.swapFrom`, `StartMenu`'s box geometry),
and `map.warpAt` is a name collision rather than a rename -- Gen 1's is a table
keyed by cell, Gold's is a method, so indexing or iterating it raises. All of
these are mod-side edits, each with a route that works on both generations;
`docs/preparing-your-mod-for-gen2.md` walks through them.
## What works on Gold today
**Screens.** The `screens` registry serves both generations. Gold's screens
are registered under `Gen2`-prefixed ids so a mod that replaces Gold's party
menu does not also replace Red's; `Screens.GEN2_IDS` in `src/ui/Screens.lua`
is the full list. Every screen Gold opens goes through an id, including the
boot cinema and the START menu.
**Asset overrides.** `overrides/` shadowing and asset transforms work
unchanged: Gold's screens load art through `src/render/Assets.lua`, the same
choke point Gen 1 uses.
**Content registries at the shared path.** `pokemon`, `moves`, `items`,
`type_chart`, `strings`, `font`, `screens`, `commands`, `tokens`,
`growth_rates`, `battle_sprite_scales`, `render_pipelines`, and the audio
family (`audio`, `music`, `sfx`, `cries`, `map_songs`). These keep their Gen 1
target path, so one mod source targets both generations.
The last two are the newest and each carries one caveat worth stating before
you write against it:
- **`battle_sprite_scales`.** `src/ui/gen2/BattleState.lua:imageScale` walks
the merged table for a record whose `path` matches the pic being drawn,
skipping the registry's own `_owners` row, and `picScale` falls through to
the species record's `battleScaleFront` / `battleScaleBack` after it -- the
same image-then-species-then-default order Gen 1 resolves in. Because the key
is the asset path it also reaches the pics that are nobody's species: the
player's trainer back, the DUDE's, an opponent's frontpic. The **default**
differs and is not a registry record either side: Red's 32x32 back pics draw
at 2x, Gold's 48x48 ones fill their 6x6 box at 1x, so a scale that looks
right on Red is twice as large on Gold. At any scale the pic stays centred in
its box and standing on the same ground line.
- **`render_pipelines`.** `src/core/Game2.lua:load` installs
`src/render/Pipelines.lua` on Gold's dataset *after* `mods:load`, so the
merged table is the one it walks, and `Game2:draw` composites the
whole-frame half through `Pipelines.wantsPresent` / `Pipelines.present` with
the Gen 1 ctx keys (`width`, `height`, `scale`, `dpi`, `dpiX`, `dpiY`). The
**`drawWorld` half is inert on Gold**: its overworld draws straight to the
window rather than into a canvas the way `src/world/OverworldController.lua`
hands one to `Pipelines.drawWorld`. A drawWorld-only pipeline is not left
switched on and drawing nothing -- `Game2:load` retires a restored level for
one, leaving `options.pipelines` untouched so the mode comes back the day
Gold grows a world canvas. Gold also has no OPTION row for a pipeline
(`Pipelines.rows` is read only from `src/ui/OptionsMenu.lua`), so a Gold
player reaches one by its `hotkey`.
**Content registries at a Gen 2 path.** `maps`, `tilesets`, `sprites`, `text`,
`encounters`, `trainers`, `palettes`, `icons`, `battle_anims`, `constants`,
`statuses`, `move_effects`, `item_effects`, `balls`, `ai_classes` and
`evolution_methods`. Same registry name, same verbs, a Gen 2 table underneath
(`data.gen2Maps`, `data.gen2Encounters`, `data.gen2Statuses`, ...).
`src/core/Game2.lua` loads the extracted ones into `game.data` before it
calls `mods:load`, and every consumer takes them by reference and never
copies, so what a mod merges is what the game walks: a registered map is a map
Gold can warp into, a patched tileset is the one `Map.new` reads, a patched
encounter table is the one the grass rolls.
The battle-rule six are the newer half and work slightly differently: there is
no table on disk for them at all. They come into existence *as* the merge, and
each consumer reads a record through a lookup that falls back to its own module
records when no loader ran, so a mod-free Gold boot behaves identically:
| registry | who reads it |
| --- | --- |
| `statuses` | `Battle.statusRecordFor` / `statusPenaltyFor`, `Catching.statusBonus`, `ItemEffects.healClassOf` |
| `move_effects` | `Battle.moveEffectRecordFor` (`useMove`'s dispatch) |
| `balls` | `Catching.recordFor` |
| `ai_classes` | `Ai.layersFor` (the ten `scoring.asm` passes, plus mod layers) |
| `evolution_methods` | `Evolution.methodFor` |
| `item_effects` | `ItemEffects.recordFor` / `partyAction` |
`src/mods/Builtins.lua` seeds those six with **Gold's** records under Gen 2
rather than Red's. It has to: both games call it `GREAT_BALL`, and Red's record
carries no `multiplier`, so seeding Red's would leave Gold's x1.5 reading nil.
**Content registries that exist because Gold does.** Six systems Red has no
counterpart for, so there is no Gen 1 table to share and none of these carries
a Gen 1 target at all. The routed Gen 2 path is their only home, and
`Schemas.GEN1` gates them on a Red boot the way `Schemas.GEN2` gates
`map_scripts` on a Gold one -- reported, not silently merged.
| registry | id space | who reads it |
| --- | --- | --- |
| `held_items` | item ids | `ItemEffects.heldItemFor`; the merged rows are written back onto `data.items` for `Battle:itemDef` |
| `phone_contacts` | `PHONE_*` (`data.gen2Constants.phoneContactOrder`) | `Phone.useRegistry`, folded onto the contact table |
| `decorations` | `"deco:<n>"` | `Decorations.attributes`, the single read point for an attribute row |
| `apricorns` | apricorn item ids | `Apricorns.useRegistry`, which rebuilds all three lookups and Kurt's menu order |
| `landmarks` | `LANDMARK_*` | `Nests.landmarkId` / `Nests.landmark`, which resolve a map header's landmark byte |
| `radio_channels` | station ids | `MapRadio.channelRecord`, which puts a registered station on the dial |
`Game2:load` calls `Phone.useRegistry`, `Decorations.useRegistry`,
`Apricorns.useRegistry` and `ItemEffects.applyHeldItems` immediately after
`mods:load`, so the merge is live before the first frame. `landmarks` and
`radio_channels` need no such call: their consumers take `data` at call time.
`landmarks` merges onto the cache's own `gen2Landmarks.landmarks` and
`held_items` onto the view `Game2` builds from `data.items`, so both fold
against the vanilla row -- a `register` for an existing id collides, a
`patch` stacks. The other four come into existence as the merge, seeded from
their module's literals by `src/mods/Builtins.lua`.
Four honest limits on that surface:
- `held_items` reaches the battle by being written back onto `data.items`, so a
held row for an id with no `data.items` record lands nowhere. To invent a
held item, register the `items` record too. The write-back is a diff against
a pre-merge snapshot, which is what lets `items` and `held_items` compose
instead of one reverting the other.
- `decorations` ids are `"deco:<n>"`, not `DECO_*` names: the cart's decoration
constants are a bare `const_def` block with no name table behind them, so
there is nothing in the ROM to spell them by. `battle_anims` addresses its
unnamed rows the same way. `n` is the attribute row's index, which is
`wMenuSelection`.
- `phone_contacts` does not register the four `PHONE_UNUSED` `const_skip` holes
(contact bytes 8, 9, 10 and 25). The manifest gives all four the same id, and
one id cannot key four rows. They stay copies of the wrong-number filler,
which is what the cart does with them.
- `radio_channels` and `phone_contacts` register *content*, not new UI: a
registered station gets a dial position and a name, and a registered contact
gets a row the Pokegear indexes, but neither invents a screen.
**Record shapes.** A registry whose Gen 2 records genuinely differ carries a
Gen 2 schema beside its Gen 1 one (`gen2Fields` / `gen2Keys` / `gen2Write` in
`src/mods/Schemas.lua`, resolved by `Schemas.shapeFor`). The registry name, the
verbs and wherever possible the ids stay shared; only the record changes. The
differences an author meets:
- **`pokemon`.** Gen 2 splits `special` into `specialAttack` /
`specialDefense`, names the level-up table `levelMoves` and the pic size
`picSize`, has no separate `level1Moves`, and points an evolution at `into`
rather than `species`. It also carries the breeding block (`eggGroups`,
`eggMoves`, `eggSteps`, `genderRatio`) and the wild held-item pair.
- **`encounters`.** The id is the encounter *kind*, not the map:
`mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } })`.
A map's row carries a `rates` set per time of day and one slot list.
`fishGroups`, `trees` / `treeSets`, `rocks`, `bugContest` and `roamMaps` are
ids of their own.
- **`trainers`.** The id is the trainer *class*, and the record is
`{ name, index, attributes, baseMoney, encounterMusic, trainers, items }`,
with one entry per named trainer of the class. The registry writes one level
in, into `data.gen2Trainers.classes`, so the call shape is unchanged.
- **`icons`.** Two id forms in one registry, routed by the `ICON_` prefix a
sheet name carries: a species id names an assignment (a string, the sheet's
name), an `ICON_*` id names a sheet.
- **`palettes`, `battle_anims`, `constants`.** The id is a subtable of the
target: `pokemon` / `trainers` / `bg` / `objects` / `roofs` for palettes,
`scripts` / `moves` / `objects` / `framesets` / `oamsets` / `gfx` for
battle_anims, and one of Gold's 42 ordered ROM name lists (plus `mapGroups`,
`trainerClassMembers`, `types`) for constants. Those lists are ordered and
position *is* the id a script byte resolves through, so they replace rather
than append.
Four more id-space notes, because the records at those paths came out of a
Gen 2 ROM:
- Gold's `text` ids are ROM pointer strings such as `"55:4067"`, not the
`TEXT_*` names Red uses. `override` them by pointer; there is no name table.
- A Gen 2 tileset carries its walkability as `collision` where Gen 1 says
`walkable`. Both fields validate; only `collision` is read on Gold.
- A Gen 2 warp row carries `destGroup` / `destMapNum` beside the `destMap` /
`destWarp` pair Gen 1 also has. Both are optional in the shared schema, so a
Gen 1 warp row and a Gen 2 one both validate, and patching one of Gold's own
maps does not mean restating the ROM's map-group numbers.
- Gold writes `"burn"` / `"sleep"` into `mon.status` where Red writes `BRN` /
`SLP`. The `statuses` registry is the same registry; only the ids differ, and
they have to.
**`mod.commands`.** Works on Gold. `src/script/gen2/Vm.lua` runs the cart's own
bytecode, so there is no opcode byte to hand a mod -- the seam is a row the
cart cannot write. `Opcodes.MOD_COMMAND` (`"modcommand"`) is an op *name* with
no byte behind it, and the VM dispatches it through the same merged
`data.commands` table Gen 1's runner resolves by name. Two row shapes reach it:
```lua
{ op = "modcommand", verb = "mymod:shake", args = { 4, 2 } } -- native
{ "mymod:shake", 4, 2 } -- Gen 1 row
```
The second is the Gen 1 row shape verbatim, so one row list can serve both
games as long as every row in it is the mod's own verb. The handler is called
`fn(ctx, unpack(args))` with `ctx.vm` where Gen 1 has `ctx.runner`; it may
block on `ctx.vm:showText` / `:waitFrames`, and its return value speaks Gen 1's
control vocabulary (`"end"`, a row number, or nil). A missing or raising verb
is warned once per name and the rest of the list still runs. The engine's own
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
Generation-agnostic; nothing to adapt.
**`mod.world`.** Same method set, resolved against Gold's world
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
documented on the module: Gold's world is not a stack state, and Gen 2 event
flags are numeric ids into `wEventFlags` rather than string keys.
`mapOverview` returns the same read-only terrain, tile-shading, and marker
shape, using Gold's live object masks and event flags to omit collected items.
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
an extracted one and survives a map reload; it is not serialized, so a mod
respawns on `map.entered`. `queueScript` takes a small allowlist of verbs Gold
has its own entry points for (`start_battle "wild" species level`, `warp`,
`text`, `setflag`, `clearflag`) and refuses a list containing anything else
**by name, before the first row runs**, so a mod never gets a half-run queue.
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
byte for it) and returns `nil, reason` rather than approximating one.
`availableFieldActions` and `useFieldAction` expose the same contextual field
item and move records in both games. Gold extends the shared ids with its own
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
actions. Each engine keeps ownership of its inventory, badges, terrain,
surfing, bike, fishing, and field-move rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
than defining a parallel vocabulary; where Gen 2 carries more, the payload
gains a field instead of the name gaining a prefix.
- *Engine-wide, from the shared modules:* `game.ready`, `screen.pushed`,
`screen.popped`, `screen.render_visible`, `music.started`, `music.stopped`,
`music.select`, `music.volume`, `sound.played`, `zoom.range`,
`assets.transformed`, `mods.loaded`, `mod.options_changed`.
- *Overworld (`src/world/gen2/`):* `map.entered`, `map.exited`,
`map.reloaded`, `player.warped`, `world.stepped`, `world.interacted`,
`world.npc_spawned`, `world.trainer_engaged`, `world.blacked_out`,
`world.block_replaced`, `world.boulder_moved`, `world.tod_changed`,
`world.object_toggled`, `flag.changed`; hooks `warp.destination`,
`movement.collision`, `movement.speed`, `encounter.roll`,
`encounter.species`, `encounter.fishing`, `world.tod`, `map.palette`,
`fieldmove.eligibility`. `flag.changed` carries the numeric `wEventFlags`
id under Gen 1's `name` key, which is the one payload difference the
numeric flag space forces.
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
menus draw with does not raise it yet, so those two are composed through
their own hooks only.
- *The Oak speech (`src/ui/gen2/OakSpeech.lua`):* `intro.oak_speech.started`,
`intro.oak_speech.step`, `intro.oak_speech.answered`,
`intro.oak_speech.finished`, and the `intro.oak_speech.build` hook. Gold has
a real Oak speech, so it is the same extension point rather than a second
one: same names, same payload keys, same moments in the sequence. The beats
are a data table with the same step vocabulary (`say` / `pic` / `name` /
`choice` / `yesno` / `shrink` / `fn`, plus Gold's own `initclock` and
`demo`), and the step *ids* match Gen 1's wherever the moment is the same --
`oak_welcome`, `demo_mon`, `world_spiel`, `ask_player_name`, `name_player`,
`legend`, `shrink` -- so `ModUI.insertStepBefore(steps, "name_player", ...)`
lands in the right place in both games. The two ids with no Gen 1
counterpart are Gold's own beats, `init_clock` (the `farcall InitClock` the
speech opens with) and `oak_study` (the return to Oak for `_OakText5`). Gold
has no rival-naming or name-confirmation beats, so it raises no anchors for
them: the rival is named by `CopScript` in `maps/ElmsLab.asm`, hours later.
- *Battle (`src/battle/gen2/`):* `battle.started`, `battle.ended`,
`battle.turn_started`, `battle.turn_ended`, `battle.move_used`,
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible` and
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
`ctx.battle.data` instead gets nil on Gold.
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
`pokemon.caught` once the mon is in the party or the box, and
`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
composed frame before GBCFX, the letterbox, and the finished playfield rect
-- and carries the same payload.
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
computes no zone of its own there) and the engine's own one-rect list in
CLASSIC mode; a rect that clamps to nothing is skipped rather than throwing,
which is what `src/render/Renderer.lua:scissorClamped` does on the Gen 1 side.
- *Sprites (`src/pokemon/Sprites.lua`, shared):* `pokemon.sprite`,
`pokemon.icon` and `player.sprite`. `pokemon.icon` is reached from
`src/ui/gen2/PartyMenu.lua` through the shared module, so it is one call site
serving both games. `player.sprite` is raised by `Sprites.playerPic`, which
Gold's battle back pic (`src/ui/gen2/BattleState.lua`), Hall of Fame and
intro call with an already-resolved path: Gold's trainer art is not in
`field.playerPics`, so the path is found first and the hook raised over it,
with the Gen 1 `ctx` keys (`side`, `kind`, `demo`, `battle`, `data`)
unchanged. The Gen 2 trainer card is the one player-art read still outside
it: its portrait is a tile sheet that also carries the frame tiles, not a
swappable pic.
`pokemon.sprite` has a second site of its own in
`src/ui/gen2/BattleState.lua`, which adds `letter` (Unown) and `shiny` to the
Gen 1 ctx keys -- both concepts Red does not have.
- *Save and the script VM:* `save.created`, `save.loaded`, `save.loading`,
`save.writing`; hooks `save.write`, `save.new_game`, `script.command`, and
the `script.started` / `script.ended` pair off `src/script/gen2/Vm.lua`.
`script.command` reports a mod's own row under the name `"modcommand"` with
the row's real operands, and may rewrite them, on the same path it wraps a
cart row.
## New in Gen 2
These have no Gen 1 analogue -- Red has no friendship byte, no day care egg,
no Pokegear, no radio, no held items -- so they are the only places a new name
is justified. They are **live**, guarded by `Runtime.wants` /
`Runtime.wantsHook`, and each is driven through a real bus by
`tests/engine/gen2_new_seams.lua`.
### Events
| event | raised from | payload |
| --- | --- | --- |
| `happiness.changed` | `Happiness` (`ChangeHappiness`, `StepHappiness`) | `mon`, `event`, `reason` (`"event"` / `"step"`), `delta`, `from`, `to` |
| `breeding.egg_created` | `Breeding` (`DayCare_InitBreeding`) | `egg`, `mother`, `father`, `compatibility`, `stepsToEgg` |
| `egg.hatched` | `Breeding` | `mon`, `egg`, `slot`, `species`, `nickname` |
| `phone.call_received` | `PhoneRing.script` | `call`, `contact`, `name`, `className`, `special`, `scriptKey` |
| `clock.day_changed` | `Clock` | `day`, `previous`, `reason` |
| `pokerus.infected` | `Pokerus` | `party`, `slot`, `mon`, `strain`, `days`, `source` |
| `roamer.moved` | `Roamers` | `index`, `slot`, `species`, `from`, `to`, `reason` |
| `roamer.encountered` | `Roamers` | `index`, `slot`, `species`, `level`, `mapId` |
| `apricorn.converted` | `Apricorns` (Kurt) | `apricorn`, `ball`, `event` |
| `bug_contest.scored` | `BugContest` | `mon`, `score`, `place`, `results` |
| `unown.unlocked` | `Unown` (`UpdateUnownDex`) | `letter`, `name`, `word`, `count` |
| `radio.channel` | `MapRadio` | `station`, `channel`, `name`, `source` |
| `mail.written` | `Mail` | `entry`, `slot`, `mon`, `message`, `author`, `source` |
| `mail.read` | `Mail` | `entry`, `message`, `author`, `top`, `bottom` |
| `intro.boot.copyright` | `CopyrightSplash:enter` | `screen`, `game` |
| `intro.boot.gamefreak` | `GameFreakPresents:enter` | `screen`, `game` |
| `intro.boot.movie` | `GoldSilverIntro:enter` | `screen`, `game` |
| `intro.boot.movie_ended` | `GoldSilverIntro:finish` | `screen`, `game`, `skipped`, `frames` |
| `intro.boot.title` | `TitleState:enter` | `screen`, `game` |
The four `intro.boot.*` cards are the GS boot cinema, and they are the one part
of Gold's intro with no Gen 1 moment to share a name with: Red boots into
`IntroMovie` with no copyright card, no GAME FREAK splash and no attract movie.
The Oak speech immediately after them is the opposite case and reuses
`intro.oak_speech.*` verbatim (see the shared table above).
Each card raises its name the frame it comes up, because that is the moment a
mod can act on. Only the movie has an `_ended` name, and only because it
carries a fact nothing downstream does -- `skipped` is the difference between a
player who watched all 2335 frames and one who pressed START. The other three
cards chain straight into the next card, whose own event is their end.
`delta` on `happiness.changed` is `to - from`, not the table's column, because
the 0 and $ff carry clamps are part of what the cart applied: a mon at 254
gaining "5" gained 1.
`clock.day_changed` compares against a process-local latch, so the first read
after a boot has nothing to compare against and raises nothing. That is by
design; it is a day *change*, not a day report.
`unown.unlocked` is raised from `UpdateUnownDex` -- a form first entering the
`#DEX` list -- not from the four `ENGINE_UNLOCKED_UNOWNS_*` puzzle flags. Those
flags are written by the cart's own `setflag`, so there is no Lua transition at
the puzzle solve to hang a second event on yet.
`mail.read` rides `Mail.lines` with a per-struct latch, because the read page
redraws every frame. The latch is re-armed by `Mail.get` / `Mail.mailbox`,
which is how both readers pick the letter they are about to open, so reopening
the same letter raises a second event.
### Hooks
| hook | wraps | ctx | vanilla answer |
| --- | --- | --- | --- |
| `held_item.trigger` | `Battle:heldEffect` | `battle`, `mon`, `item`, `def`, `effect`, `parameter`, `trigger` | `ctx.effect, ctx.parameter` |
| `breeding.compatibility` | `Breeding.compatibility` | `data`, `mon1`, `mon2`, `dayCare` | the vanilla byte |
| `phone.contact_list` | `Phone`'s `wPhoneList` read | called `(save, list)`, the shape the other list hooks use | the same list |
| `shiny.roll` | `Mon` | `dvs`, `species`, `def`, `level` | the DV-derived boolean |
| `gender.roll` | `Mon` | `def`, `dvs`, `ratio`, `species`, `level` | the DV-derived gender |
`held_item.trigger` is one hook over eight call sites, because on the cart
those eight *are* one routine (`GetUserItem` / `GetOpponentItem` loading b and
c, and the caller comparing b against the `HELD_*` it cares about). `trigger`
says which comparison is about to happen: `"priority"` (Quick Claw),
`"damage"` (Scope Lens and the type-boost family), `"endure"` (Focus Band),
`"flinch"` (King's Rock), `"accuracy"` (BrightPowder), `"confuse"`,
`"residual"` (the end-of-turn Leftovers / Berry / cure arm), and `"check"` for
any other read. Return nil to make the item do nothing at that trigger, or
another `HELD_*` name to substitute one -- every call site compares against a
name, so substitution is the whole mechanism.
`held_item.trigger` wraps the *read*, so a mod can suppress or substitute an
effect from any item. Defining a **new** held item is the `held_items`
registry's job, and the two compose: register the row, then steer it from the
hook.
`phone.contact_list` refuses an answer of the wrong length or with an unknown
contact id (unknown ids blank to 0 on purpose, so the Pokegear never indexes a
nil). It reorders and blanks the ten save slots; registering a contact id the
game does not know is `phone_contacts`' job.
`shiny.roll` does not override a forced-shiny battle (`opts.shiny`), which is
how the cart's own scripted shiny Gyarados stays shiny.
## Registries with no Gen 2 home
Writing to one of these while Gold is running takes the write, drops it, and
reports it once per mod into the same error feed the manager shows. It is not
fatal: a mod that supports both generations registers its Gen 1 content
unconditionally and still loads the half that applies. The report is worded
from the boot's own generation, because the gating runs both ways.
`rulesets`, `transitions`, `field`, `text_pointers`, `link_fields`,
`map_scripts`.
`Schemas.GEN2` in `src/mods/Schemas.lua` is the authoritative table, and
`tests/engine/gate_gen2_mod_api.lua` holds it to the catalog.
The list used to have three causes behind it and now has one. "No Data path
exists" closed when the overworld tables stopped loading off disk into World
fields. "The shape differs" closed when a registry gained the option of
carrying a Gen 2 record schema beside its Gen 1 one. What is left is one cause:
**Gold reimplements the system without reading a registry.** The Gen 1 target
is still built and merged into, but nothing in a Gold boot ever looks at it, so
routing the registry would be a merge into a table with no reader -- exactly
the silent no-op the gate exists to prevent. Closing one of these is a consumer
change in the Gen 2 module first and a routing row second:
- `rulesets`: no Gen 2 ruleset dispatch exists.
- `transitions`: Gold draws its own battle intro
(`src/ui/gen2/BattleTransition.lua`), and its `STYLES` is a boolean *set* of
the four cart wipes (`spin`, `speckle`, `zoom`, `sine`) rather than the
`{ frames, draw, sound, flash }` record this registry carries. There is no
styleDef lookup for a registered id to reach, so a mod style would fail the
`STYLES` membership test and fall back to vanilla -- routing it would be the
silent no-op, not the fix.
- `field`: the Gen 1 overworld's data grab bag. Gold's equivalents live in
`data.gen2Maps` and the VM's own tables.
- `text_pointers`: Gen 1's `TEXT_*` indirection. Gold's text *is* pointers.
- `link_fields`: link play is Gen 1 only.
- `map_scripts`: `data.gen2Scripts` is the cart's bytecode pool keyed by ROM
pointer, and a Lua row list merged into it is not something
`src/script/gen2/Vm.lua` can run. Routing it needs a Gen 2 side dispatcher in
`World`, not just the verb table `mod.commands` already has. The
`script.started` / `script.ended` / `script.command` seams do fire, so a mod
observes and can veto a script it cannot yet author whole.
Four of this list closed after it was written, and how they closed is the
pattern for the rest:
- **`growth_rates`** now routes to the SHARED Gen 1 target. Gold's curves are
coefficient rows in the extracted `pokemon.lua`, so `src/mods/Builtins.lua`'s
Gen 2 registrant wraps each as the `{ expForLevel }` record Gen 1's registry
uses, and `src/battle/gen2/Mon.lua:growthFor` is the one accessor all six
readers go through (`Mon` twice, `BattleState`, `SummaryMenu`, `Breeding`,
`ItemEffects`). One record shape, one id space, one mod source for both
games. Because it is routed, the `pokemon` schema's `growthRate` reference is
now checked rather than skipped, and it resolves: both sides say
`GROWTH_MEDIUM_SLOW`.
- **`tokens`** was on the list by mistake rather than by cause. `TextBox.new`
runs `TextBox.substitute` on every box in both generations and `substitute`
reads `game.data.tokens`, so the shared target was live on Gold the whole
time. A `{NAME}` a mod registers expands in the world, the menus and the VM's
pages alike.
- **`battle_sprite_scales`** closed consumer-first, the `growth_rates` way:
`src/ui/gen2/BattleState.lua` grew `imageScale` / `picScale`, a faithful
mirror of Gen 1's `BattleState.imageBattleScale` / `resolveBattleScale` down
to skipping `_owners` and the image-then-species-then-default order, so the
registry now routes to the SHARED Gen 1 path and one record serves both
games. Only the default is generation-specific, and neither side reads that
from the registry.
- **`render_pipelines`** closed because the reader moved, not the registry:
`src/core/Game2.lua:load` installs `src/render/Pipelines.lua` on Gold's
merged dataset after `mods:load` and `Game2:draw` composites `present`. The
`drawWorld` half is still inert, which is why this one is worth reading the
caveat above for -- it is routed on the strength of the half that works, and
Gold retires a drawWorld-only level rather than pretending.
## Hooks and events Gold does not raise yet
Gold has its own draw path, intro, evolution and sprite lookups, so the call
sites in those Gen 1 modules are not on Gold's path. The names are not taken
and not reserved for Gen 1: when a Gen 2 call site lands it uses the existing
name and the existing payload, plus fields where Gen 2 genuinely carries more
(the split special stats, held items on a trainer roster).
The list is much shorter than it was. What is outstanding, in descending value:
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
preparation boundary or a battle-local player-party view. Gen 1 mods can use
the hook documented in `docs/modding.md`; do not claim Gold compatibility
when that selection is required.
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
own yet.
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
Gen 2 fingerprint and handshake exist (`src/link/Fingerprint.lua` hashes a
Gen 2 surface and a cross-generation pairing is refused by name), but nothing
in `src/ui/gen2/` opens onto the protocol, so these raise nowhere.
Four groups that used to sit here have since landed and moved to the shared
table above: the frame seams (`render.compose` / `render.hud` /
`render.letterbox` / `render.zones`, `input.step` / `input.pointer`), the three
battle seams (`battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`), the two sprite lookups (`pokemon.sprite`,
`pokemon.icon`), and the catch/evolution trio (`pokemon.caught`,
`pokemon.evolved`, `evolution.check` -- `src/ui/gen2/BattleState.lua` emits
`pokemon.caught` from `pushCaught` once the mon is in the party or the box, and
`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps
each row's decision in `evolution.check`).
Three partial coverages worth knowing about, because "the hook exists" is not
the same as "the hook sees everything":
- `encounter.roll` / `encounter.species` are wired into the grass/water step,
`randomwildmon`, the Bug Contest and SWEET SCENT, but **not** into
`World:tryHeadbutt`, `World:rockMonEncounter` or `Roamers.checkEncounter`.
Those three read row shapes that are not `{ species, level }` slot lists, so
a mod that reskins encounters misses headbutt trees, rock smash and the
roamers.
- `src/ui/gen2/BattleState.lua` builds a flat `opts` for `Catching.attempt`
with no `data` in it, so a mod-registered ball is readable through
`Catching.recordFor` but is not yet resolved at the real throw site.
- Three Gold UI files carry their own copy of the status HUD labels the merged
`statuses` records now hold as `hudLabel`, so a mod status shows no label in
the battle HUD, the party menu or the summary page until they read
`Battle.statusRecordFor(data, status).hudLabel`. The values are identical
today, so nothing vanilla is affected.
## Gen 2 tables with no registry
`Game2:load` assigns 24 `data.gen2*` tables and 12 of them are registry-backed,
so twelve sit in `game.data` on a Gold boot with no registry pointing at them:
`gen2Marts`, `gen2Roofs`, `gen2StdScripts`, `gen2EventTables` (the phone book,
in-game trades, elevator labels, decoration descriptions), `gen2InitialEvents`,
`gen2Pokedex`, `gen2MenuGfx`, `gen2Intro`, `gen2Credits`, `gen2Diploma`,
`gen2Trade`, and `gen2Scripts` (which the `map_scripts` registry does reach, so
it is the one of the twelve that is not out of reach). Naming registries for the
rest is new API surface rather than a routing change, so it is deliberately not
done yet.
## Testing a Gen 2 mod
Static first. `gen2check` reads the manifest, scans every `.lua` the package
carries and cross-references what it finds against the coverage table above:
```sh
python3 tools/modkit.py gen2check my_mod # or a path
python3 tools/modkit.py gen2check my_mod --notes # + the caveat on each backed member
```
It reports one of `will load`, `will load but degrade` or `will not work`, with
a `MK4xx` finding per site and an `unresolved:` note, carrying a file and a
line, for every reach a static scan could not follow. Exit 0 clean, 1 on a
fatal finding (or any finding under `--strict`), 2 on usage; `--json` emits the
whole batch as one document, and `--quiet` prints the findings alone, so a
clean mod prints nothing and the exit code is the answer. The rule ladder is
`MK400`-`MK410` and is listed in `tools/modkit.py`'s section header.
Then the headless harness, which takes the generation without booting Gold:
```lua
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
T.eq(run.mod and run.mod.state, "loaded",
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
T.eq(#run.errors, 0, "and loads with no boot errors")
```
Everything else is the production path: same loader, same validate, same
topological sort, same merge. Assert the state as well as the error count: a
gate skip is deliberately not an error, so `#run.errors == 0` passes for a mod
that never ran a line.
Neither substitutes for a real Gold boot, and the two output channels there are
not the same. The adapter's own warnings (`Gen2Compat.warnOnce`) go to the log
only, each attributed to the mod holding the facade. The boot error feed the
manager shows is `loader.errors`: a failed mod, a duplicate id, a registry with
no Gen 2 target, a cross-validation problem, and a require for a Gen 1 module
the adapter does not serve. A skipped mod and a degraded member are on neither
list, by design.
+96
View File
@@ -0,0 +1,96 @@
# RFC 0008 — Runtime mod option schema export
## Status
Proposed. Engine: `src/mods/Loader.lua`. Tests:
`tests/mod_loader_tests.lua`. This RFC defines an optional filesystem
contract; it does not require a native launcher or any other consumer.
## Motivation
A native launcher may want to present settings for installed mods before it
starts the game. Running every mod's entry chunk in that launcher just to
discover its settings would duplicate engine behavior and give the launcher
an unnecessary code-execution surface. The engine already has the authoritative
runtime schemas after mod loading, so it can publish a data-only snapshot for
platform shells that want one.
## The exact contract
After the mod loader has finished running entry chunks, it may write
`mod_option_schemas.json` beside `options.lua` in the same filesystem. The
document is a snapshot of the current boot; it is not a second settings store
and does not change how option values are read or written.
Version 1 has this shape:
```json
{
"schema_version": 1,
"mods": {
"example": [
{"key":"enabled","type":"toggle","label":"Enabled","default":true},
{"key":"mode","type":"choice","label":"Mode","default":"safe",
"choices":[["Safe","safe"],["Fast","fast"]]},
{"key":"rate","type":"number","label":"Rate","default":5,
"min":0,"max":10,"step":1},
{"key":"name","type":"text","label":"Name","default":"","maxLen":12}
]
}
}
```
`mods` is keyed by mod id. Its rows come from the runtime
`mod.options:define` schema, or from the legacy manifest `options_schema` file
when the runtime schema is absent. The supported row types are `toggle`,
`choice`, `number`, and `text`. Their optional fields retain the meanings
established by the existing in-game option UI: choices are `[label, value]`
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
provide `maxLen`. A row may also use
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
`not_equals`. This only hides the in-game menu row; the schema and stored value
remain available, and consumers that do not implement conditions may ignore
the field.
Only mods that are enabled and successfully loaded in the current boot are
included. A disabled or failed mod must not contribute rows. If an older
snapshot exists and the current boot has no schema-bearing mods, the producer
overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale
settings rows from surviving a disable or load failure. A fresh mod-free boot
does not create the file, and a filesystem without write support is tolerated.
The producer writes the snapshot after entry chunks and the final load set
have been established. Consumers must treat the file as untrusted input and
must not execute anything from it.
## Compatibility and versioning
The contract is optional on both sides. A native consumer may be absent, and
the engine continues normally if the file cannot be written. A native
consumer is not required to render, validate, or persist every supported row;
it may ignore an unknown row type or optional field.
For compatibility with files produced by the original unversioned prototype,
a missing `schema_version` means version 1. Consumers must ignore documents
with a newer version rather than guessing at their shape. Producers must bump
the version whenever they change the document envelope or the meaning of an
existing field. New optional row fields that older consumers can safely ignore
do not require a bump. Version 1 is therefore the legacy unversioned format as
well as the explicitly versioned format shown above.
## Migration note
Nothing. Existing mods, option values, and the in-game options UI are
unchanged. Platforms that do not consume `mod_option_schemas.json` have no
new integration requirement.
## Parity tests
`tests/mod_loader_tests.lua` verifies the explicit version, runtime and legacy
row round-tripping, enabled/disabled filtering, failed-mod filtering,
stale-snapshot clearing, and tolerance of a read-only filesystem.
## Deprecation etiquette
Nothing is deprecated. The unversioned file form remains readable as legacy
version 1; new producers write the explicit `schema_version` field.
+900 -5
View File
@@ -12,12 +12,175 @@ The modding book lives on the
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries) - [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
— every registry, generated from `src/mods/Schemas.lua`. — every registry, generated from `src/mods/Schemas.lua`.
Regenerate the reference straight into a wiki checkout: Regenerate the reference. With no argument it writes in-repo, to
`docs/modding/reference/registries.md`; name a wiki checkout to write the
wiki's own page name into it instead:
```sh ```sh
luajit tools/gen_registry_docs.lua
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
``` ```
## Manifest specification (`manifest.json`)
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
```json
{
"id": "my_mod",
"name": "My Cool Mod",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"games": ["gen1", "gen2"],
"game_version": ">=0.0.0-dev <2.0.0",
"priority": 100,
"dependencies": [
"helper_lib@^1.0.0",
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
],
"optional_dependencies": [
"gen1_modern_ui"
],
"required_imports": [
{
"id": "stadium2",
"name": "Pokemon Stadium 2 ROM",
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
"file": "stadium2.z64",
"format": "n64",
"size": 67108864,
"md5": ["00000000000000000000000000000000"]
}
],
"optional_imports": [
{
"id": "bonus_source",
"name": "Optional bonus source",
"file": "bonus.bin",
"md5": "00000000000000000000000000000000"
}
],
"conflicts": [],
"permissions": ["engine_internals"],
"description": "A brief description of the mod.",
"github": "author/my_mod"
}
```
### Manifest Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
| `name` | `string` | Human-readable title shown in launcher and manager. |
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
1. **Simple string**: `"mod_id"`
2. **Version-pinned string**: `"mod_id@^1.2.0"`
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
4. **Structured object**:
```json
{
"id": "mod_id",
"range": "^1.2.0",
"games": ["gen2"],
"github": "owner/repo"
}
```
#### Version-Scoped Dependencies
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
### Required user-supplied files
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
out of mod archives while giving every platform the same installation flow.
Each object requires a stable `id`, a display `name`, a destination `file`
(a filename, never a path), and one MD5 digest or an array of accepted MD5
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller
per-import ceiling when an exact size is not appropriate. Every import also
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
filesystem reports an invalid size.
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading.
MD5 here identifies a known dump because ROM databases commonly publish it;
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
must not include anything beneath `baseroms/`. The engine records a validation
receipt keyed by file size and modification time so launcher refreshes and
later boots do not repeatedly hash an unchanged imported ROM.
New mobile code should call `love.system.pickFile("required_import")`. The
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
Android now returns `false` for unknown picker kinds instead of treating them
as game-ROM picks.
### Platform import flow
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
applies on every supported platform. Windows, macOS, and Linux use the
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
the Files document picker; both stage the choice as `picked_required_import.bin`
before validation. Xbox/UWP uses its native picker and hands the launcher a
temporary path. Switch/NX has no host picker, so the player copies a file to
`imports/baseroms/` over MTP and chooses the import again. No platform grants
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
checks.
## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle
engine, overworld, script VM and save format, so a mod says which games it is
for and Gold serves a declared subset of the surface.
- [`docs/preparing-your-mod-for-gen2.md`](preparing-your-mod-for-gen2.md)
the migration guide: what breaks, the `games` manifest key, the module
adapter, the patterns no adapter can fix, and a worked before/after.
- [`docs/mod-api-gen2-compat.md`](mod-api-gen2-compat.md)
the reference: every registry, hook and event, whether Gold serves it, and
the record-shape differences where it does.
Start with the checker, which reads your manifest and scans your Lua against
the adapter's own coverage table:
```sh
python3 tools/modkit.py gen2check mods/my_mod
```
## Editing maps in Tiled ## Editing maps in Tiled
Maps are data, not assets, so they can be authored in a real map editor and Maps are data, not assets, so they can be authored in a real map editor and
@@ -35,6 +198,106 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
fields that moved; a new map becomes a `:register`. See fields that moved; a new map becomes a `:register`. See
`docs/new-features.md` and the extension's own README. `docs/new-features.md` and the extension's own README.
## Read-only map overviews
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
`"3"` (darkest); their matching width and height fields describe the grid.
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
`warp`, visible `item`, and untaken `hidden` locations. All fields are
read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules.
## Party ordering
Companion UIs and alternate party screens can call
`mod.world:canReorderParty()` before offering a reorder action, then
`mod.world:reorderParty(fromSlot, toSlot)` with one-based party slots. The
operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched.
## Contextual field actions
`mod.world:availableFieldActions()` returns the field items and moves that can
start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
eligible `sources`; each source contains its eligible `targets`. Fishing rows
include the owned rods that are valid choices. The list is empty while the
world is busy, and omits an action whenever its item, move, badge, terrain, or
engine state forbids it.
The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Red's `softboiled`
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
record. Invalid, stale, and busy requests return `nil` plus a reason without
changing game state. Mods do not need generation-specific badge, terrain,
bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length.
Red exposes FLY separately because it requires a destination picker:
`mod.world:canFly()` reports whether FLY is eligible at the current location,
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
same core fields:
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
the result are detached records; changing them cannot change the battle.
`revision` stays stable while the visible battle context is unchanged and
advances when it changes, so a UI can skip rebuilding an identical view.
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
an optional stock `catchChance` percentage.
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
phase owns input. Generation-specific features remain optional: Gen 1 includes
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
Gold currently returns an empty `items` list rather than guessing at its
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
optional ones.
## Battle menu intents
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
just read. Every intent needs a mod-owned, strictly increasing positive
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
invalid choices return `nil` plus a reason without changing the battle.
The shared Red, Blue, Yellow, and Gold intents are:
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
other accepted choices)
- `{ kind = "move", slot = 1..4 }`
- `{ kind = "back" }` while the move menu is active
Red, Blue, and Yellow also expose their generation-specific choices:
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
other accepted actions)
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
Menu choices and moves use the same engine methods as the native controls;
`party` and `item` open the native screens rather than exposing or duplicating
their mutable logic. Tutorial, link, forced, stale, and covered battle states
refuse core intents. Use `mod.input` for ordinary text advance.
## Rendering pipelines ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
@@ -100,6 +363,44 @@ Three rules worth knowing:
Returning `nil` from `drawWorld` is a normal answer meaning "not this Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead. frame"; the engine draws the vanilla world instead.
## Variable-size overworld sprites
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
but a mod can describe any frame rectangle and anchor for player characters,
NPCs, followers, mounts, vehicles, bosses, or other field actors:
```lua
mod.content.sprites:register("SPRITE_COMPANION", {
image = "mods/example/companion.png", -- one frame per row
frames = 6,
walker = true,
frameWidth = 32,
frameHeight = 32,
anchorX = 16, -- frame-relative bottom-center anchor
anchorY = 32,
})
```
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
measured from each frame's top-left; when omitted they default to the frame's
horizontal center and bottom edge, so a larger sprite grows upward while its
feet stay on the same world cell. Omitting all four fields is exactly the
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
these values automatically, including horizontal flips and the fishing pose.
Custom render pipelines can use the same geometry without reproducing the
pose rules:
```lua
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
```
`getFrameGeometry(frame)` is the corresponding accessor for a specific
zero-based sheet frame. Both accessors return fresh tables and share the
renderers frame selection and mirror conventions.
## Battle sprite scaling ## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way The enemy's front pic draws at 1x and the player's back pic at 2x, the way
@@ -140,6 +441,198 @@ default** (1x front, 2x back).
ball-to-pic grow multiplies your scale through each stage, so a rescaled 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. mon still grows into place from the ball, grounded the whole way.
## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next
normal Pokémon SAVE. Tools that need independently written, larger data-only
records can use `mod.storage`; the engine scopes every logical key by game
version, opaque playthrough identity, and mod id, and routes it through the same
standard or portable persistence backend as saves:
```lua
local context, code, message = mod.storage:context(game)
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
format = 1, createdAt = os.time(), payload = { money = 3000 },
})
local value, code, message = mod.storage:read(game, "history/quick/q0001")
local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
```
For independently generated binary data, use the opaque byte methods. They
accept and return the exact Lua string of bytes, including NUL bytes and bytes
that are not valid text:
```lua
local ok, code, message = mod.storage:writeBytes(
game, "cache/maps/pallet/terrain", encodedMesh)
local encodedMesh, code, message = mod.storage:readBytes(
game, "cache/maps/pallet/terrain")
```
Opaque values are limited to 512 MiB per key. The engine stores them without
decoding, compression, or an engine-defined file format, and never executes
them. A consuming mod owns validation of its format, fingerprint, checksum,
and compression metadata. Byte writes are staged and compared byte-for-byte
before replacement, and reads can recover a valid backup after an interrupted
write. Existing table values and opaque byte values use one shared logical key
space; delete a key before changing its value from one type to the other.
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the
validated matching ordinary-save chronology only; it never exposes normal-save
progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`.
Resolving this facade is non-allocating: it never allocates an identity, adopts a
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
`list(prefix)`, and `delete(key)` methods have the same scoped and
transactional contract as `mod.storage`, but remain restricted to the calling
mod's selected existing namespace. It is intended for title tools that need to
browse or manage durable history before the first normal SAVE.
Table values must contain serializable data only. Opaque values must be Lua
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
`-`); paths and filesystem handles are never exposed. Table writes are staged
and decode-verified; opaque writes are staged and byte-verified; reads recover
from a valid staged/backup generation. Methods return structured errors for
normal data, byte validation, and I/O failures. The playthrough identity is
allocated lazily on the first storage/checkpoint call, so an unused API changes
no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
```lua
local capability = mod.checkpoints:inspect(game)
if capability.canCapture then
local checkpoint, code, message = mod.checkpoints:capture(game)
-- Store the detached data-only checkpoint through mod.storage.
end
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
-- After the tool has durably committed its first checkpoint, make a
-- never-saved playthrough reachable through ordinary title boot exactly once.
local anchored, anchorCode, anchorMessage =
mod.checkpoints:ensureNormalSave(game, checkpoint)
```
Checkpoint format 1 supports settled overworld control and proven battle
player-decision safe points. Ordinary single-player wild/trainer encounters are
supported. Scripted story battles are also supported when the engine can detach
their current built-in battle command and data-only row continuation, rebind any
NPC by stable id, and resume the story through a fresh runner. The suspended Lua
coroutine is never serialized. Link, Safari, ghost, demo, opaque callback,
non-data-only script, animation, message, queue, concurrent-script, and
forced-action phases fail closed. New checkpoints preserve gameplay RNG, while legacy overworld records
without RNG remain loadable. Capture excludes global options and runtime
objects. Restore validates format, game/playthrough identity, content,
coordinates, battle relationships, continuation, and RNG before mutation;
preserves current options; suppresses normal map-entry/save-load/intro side
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
reconstruction fails. Callers that need crash recovery should durably capture
their own recovery checkpoint before restore.
Checkpoint ownership follows the persistence model rather than mod identity:
- canonical `game.save` progress, including every mod's `save.modData` /
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
- global and per-mod options remain at their current values;
- independently written `mod.storage` records do not rewind; and
- mod-owned runtime objects, references, and caches are never serialized.
Successful restore emits `checkpoint.restored` only after reconstruction and
differential recapture have committed. Mods that cache rewound progress or hold
references to reconstructed runtime objects can re-read their own public state
and rebuild at that point:
```lua
mod.events:on("checkpoint.restored", function(ev)
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
cachedQuestStage = mod.save:get("quest_stage", 0)
rebuildRuntimeFor(ev.game, ev.kind)
end)
```
The event is not emitted for validation failure, failed reconstruction, or a
successful rollback. Its payload contains no checkpoint data or other mod's
private state. A mod that deliberately stores progress-coupled truth in
`mod.storage` must version and reconcile that relationship itself; the engine
cannot distinguish it safely from independent history, configuration, or cache
data.
`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to
live `restore`. It validates the same data-only checkpoint against the
engine-selected existing playthrough, reconstructs only after all validation
passes, preserves current options, and verifies by recapture. A title session
has no live gameplay rollback state: if reconstruction or verification fails,
the engine rebuilds a usable title session and returns `false, code, message`.
It never rewrites a normal Pokémon save. It is unavailable outside title and does
not broaden capture or arbitrary-frame support.
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
operation for durable checkpoint tools. It creates ordinary progress only when
none exists, only after validating that the supplied checkpoint is the exact
current safe runtime, and through the normal atomic save lifecycle. Once an
ordinary save exists it returns `true, "already_exists"` without writing, so
subsequent checkpoints and the player's later SAVE commands remain independent.
Call it only after the tool's own checkpoint/index commit; treat an anchoring
failure as a failed first checkpoint rather than claiming restart safety.
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
codes.
At that same settled supported wild/trainer decision boundary, a tool may claim
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
never receives the live battle controller. Return `true` to consume START after
opening source-owned UI, or call `next(game, context)` to allow lower-priority
handlers. With no handler, START remains inert. Ordinary encounters and the
validated built-in scripted battle origins described by RFC 0005 are eligible;
opaque scripts, link/Safari/ghost/demo battles, action queues,
animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn.
Gen 1 trainer encounters also expose `trainer.before_battle` after the
challenge text and immediately before battle construction. This lets a mod
defer the encounter while it collects a player choice through a registered
screen, then resume with a battle-local view of the save party:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
mod.ui.push(game, "party_registration", {
onConfirm = function(indices)
continue({ playerPartyIndices = indices })
end,
onCancel = function()
continue({ cancel = true })
end,
})
return true
end)
```
Return `true` only when retaining `continue` for a later callback. Calling
`continue({ cancel = true })` ends the encounter without constructing a battle;
the normal encounter completion callback returns control to the overworld and
no trainer-defeated state is written. A cancelled sight encounter is suppressed
at the current player cell so it cannot immediately reopen; moving one cell or
talking to the trainer permits a new challenge. Calling `continue()` uses the
full save party; passing
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
members for initial send, switching and forced replacement, exhaustion,
experience traversal, and battle party displays. The continuation is one-shot.
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
back to the full party. The view references the original Pokemon records and
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees.
## Developer console ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
@@ -174,6 +667,35 @@ It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`. `(next, game, dt)` and must call `next(game, dt)`.
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
button }`: `phase` is
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
active game viewport and `insideGame` says whether the pointer is inside it.
Without a custom viewport both coordinate pairs are identical. The on-screen
touch controls keep first refusal: a
pointer that begins on a virtual control belongs to the pad for its whole
lifecycle and never reaches the hook, while one that begins outside stays
visible even if it later crosses a control. A real mouse reaches the hook
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
a mobile touch fires once), and focus or visibility loss and input recovery
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
released. Return `true` without calling `next` to consume the event.
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
queues exactly one `wasPressed` edge for the next fixed step and holds
nothing; `local token = mod.input:press(game, btn)` holds the button until
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
`b`, `start` and `select`. Every press is its own input source inside the
engine's multi-source bookkeeping, so releasing a token never clears a hold
the keyboard, a controller, the touch overlay or another mod still owns;
`release` is idempotent and refuses tokens taken by another mod.
Outstanding tokens are released automatically on entry-chunk rollback, hot
reload and input recovery.
`ui.title_menu.items` receives `(next, game, items)` and follows the same `ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins. for a tool to offer a fresh-session action before gameplay begins.
@@ -187,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state. playfield or pushing an updating game state.
`render.viewport` lets a layout mod reserve the window-space rectangle in which
the game renders. It receives `(next, ctx)` with the full window's `width`,
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
window and makes game layout, safe-area calculations, and rendering use it as
their display. Set `capture = true` to request a composition canvas even when
the rectangle fills the window. With no subscriber, no canvas is allocated and
the normal presentation path is unchanged.
When a viewport is active, `render.window` receives `(next, game, ctx)` after
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
a wrapper may instead compose that canvas with its own UI. Touch controls remain
full-size OS-window chrome and draw after this hook.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It `render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's the mod full control of the window, while calling `next` runs the engine's
@@ -195,10 +733,367 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`) `secondScreen` bridge (`available()` / `detected()` / `push(...)` /
for driving a second physical display. This is what lets a mod lay the two `pollTouch()` / `setEnabled`) for driving a second physical display.
passes out as two stacked Game Boy screens, or push one onto a second screen, `detected()` reports a connected target even while its output is being created;
without the engine knowing the layout. `available()` means it can accept a frame now. `push(imageData, w, h)` retains
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
arguments request an extended presentation; a preference ending in `:cover`
fills and crops the target, while other values preserve the whole frame.
Android also accepts `handheld` or `secondary` (with an optional `:cover`
suffix) as routing hints; unsupported or unavailable targets fall back to the
other connected display.
`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
On process-capable Windows, Linux and macOS hosts without a native display
bridge, enabling this facade opens a second resizable app window instead. It
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
contract, so a mod does not need a desktop-specific rendering path.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
layers. It runs after registered present pipelines and before GBCFX,
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
`true` only while output ownership is needed, and the second receives
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
`gameHeight`, `scale`, `dpiX`,
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
to `false`. Enabling the seam requires a full-window canvas for that frame.
With no `render.output` subscriber, or while `render.output_enabled` is false,
the existing presentation path is unchanged. `render.compose` takes precedence
when it owns the frame.
`screen.render_visible` receives `(next, state)` while the main screen is being
composed. Return `false` to omit that state from drawing, opacity selection and
palette-zone ownership. The state remains on the stack and keeps its normal
update and input ownership, so a mod can mirror a native menu on another
display without reimplementing it. The default is `true`. Treat the wrapper as
a pure predicate: the renderer may ask it more than once per frame.
Scrollable list states expose `state.kind` for use with this hook. Generic
lists fall back to their title; PC lists use stable, localization-independent
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
control the battle text/menu layer and the HP/status panels. Both receive
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
Both hooks apply to Gen 1 and Gen 2 battles.
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
for that battle, so hiding the bottom layer cannot leave their white backing
behind under another overlay. Text boxes also pass through the hook as their
own state, preserving selective control outside a battle; a wrapper that only
owns battle presentation should return `false` only for its active battle or
text-box state.
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
category `Game.speedCategoryInStack` says is active right now. A mod may
call `next(game)` and return its result to pass that resolution through, or
return a different number outright to override it for that frame (a bot mod
forcing 1X for one route segment, say, regardless of the category or saved
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
cannot destabilize the fixed-step accumulator. This hook runs *after* link
play's 1X lock and the `--speed`/equivalent run-argument override, both of
which stay unconditional and are never visible to a subscriber.
Developer mode also arms the mod loader's dev tripwire, which flags mods Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set. that reach outside their permission set.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
that embeds this engine and wraps its window in platform UI) can live
entirely in a mod instead of hand-patching `main.lua`, which every other
engine change also touches.
`core.update` receives `(next, game, dt)` once per frame from
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
mod may skip calling `next(game, dt)` to pause the simulation for that
frame (e.g. while a native settings sheet is on top), and may run
additional per-frame polling before or after that call regardless of
whether it calls `next` -- useful for one-shot flags that must be observed
every frame even while paused.
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
returns the engine's own decision for whether closing the window should
return to the Lua launcher instead of exiting; a mod may return `false`
outright, without ever calling `next`, to veto that and let the process
really quit -- for a platform host that owns its own "return to launcher"
UI and would otherwise get looped straight back into the game it just
quit.
A manifest may also declare `force_enable_env`, an environment variable
name that re-enables the mod regardless of a saved disable in
`options.mods` when that variable is set to `"1"`. This is for a mod that
cannot function disabled on the one build where its env var is set (a
platform-bridge mod bundled only with that build's launcher, for example).
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
already falls straight through to the vanilla function when no mod has
wrapped the name, at negligible cost.
## Detached Pokémon icon presentation
`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon
the native Party menu would resolve without exposing a live Pokémon record or
the private Party menu. `summary` is the detached data-only shape
`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and
`opts.counter` optionally request the native selected-icon animation phase.
The engine retains icon ownership. Content registered through
`mod.content.icons`, species `icon` definitions, asset overrides, and the
public `pokemon.icon` hook therefore continue to compose. Invalid summaries
return `false, code, message` and draw nothing. The helper is presentation
only: it does not expose moves, status, checkpoint payloads, or mutable party
state.
## Shared date and time presentation
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`).
These preferences live in `options.lua`, so checkpoint restore never rewinds
them. `DEVICE` uses the process time locale when the platform provides one;
the portable fallback is `DD-MM-YYYY` plus 24-hour time.
Mods format captured timestamps through the read-only public facade:
```lua
local date = mod.datetime:date(game, createdAt)
local time = mod.datetime:time(game, createdAt)
local both = mod.datetime:dateTime(game, createdAt)
```
The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return
`"----"`.
## Device power information
Sandboxed mods can read the host's battery state without receiving the rest
of `love.system`:
```lua
local state, percent = mod.device:powerInfo()
```
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
the platform cannot report it. The facade is read-only and does not expose
URL launching, clipboard access, or other system operations.
## Real-world steps
On iOS and Android the game counts the player's real-world steps natively
(HealthKit / the hardware step counter). A mod reaches that bridge through
the `steps` permission in `manifest.json`, which the player sees in the
mod manager like every other permission:
```lua
if mod.steps:available() then
mod.steps:sync() -- async; OS consent sheet on first use
end
-- later, at a quiet moment:
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
```
`available()` is `false` on builds without the bridge (desktop) and for
mods without the permission, so a probe is always safe. `sync()` asks the
platform to refresh its count and returns whether there was a bridge to
ask. `poll()` returns the next delivery for this mod — the engine consumes
the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
## Background HTTP
`mod.fetch` is how a mod does work off the main thread. It is behind the
`network` permission in `manifest.json`, the same one that gates
`require("socket")`, and the player sees it in the mod manager.
```lua
-- somewhere once
local job = mod.fetch:get("https://example.com/data.json")
-- in a hook or update, every frame -- poll never blocks
if job then
local r = mod.fetch:poll(job)
if r.status ~= "pending" then
if r.status == "ok" then use(r.body) else warn(r.err) end
mod.fetch:release(job)
job = nil
end
end
```
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
blocks, so calling it every frame is the intended use. `release(handle)`
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
drops a result you no longer want. `available()` is `false` when the build
has no transport and for mods without the permission, so a probe is safe.
The rules worth knowing before you design around it:
- **http and https only.** The underlying transport also speaks `file://`,
`ftp://` and `scp://`; those are refused, on the initial URL and on any
redirect. `mod.fetch` is not a way to read a local file.
- **Four requests in flight per mod.** The worker pool is shared with the
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
`get` returns `nil` and a reason until you release something.
- **Handles are yours alone.** A handle from another mod, a fabricated
table, or a guessed number all poll as `"error"`.
- **Your mod id is in the User-Agent**, so a server operator can see who is
calling and a mod cannot pose as the launcher.
- Jobs are released when your mod unloads.
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
with a full standard library that the sandbox cannot reach, so handing one
to a mod would undo every other rule; `mod.fetch`'s workers run engine
code, so a mod gets asynchrony without gaining any new reach.
## Log reporting
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
decides where it talks. It reports a debug/crash log to the https URL the
manifest declares in `log_url`, and it is the only API that may not be
pointed at a caller-chosen address:
```json
{
"permissions": ["network"],
"log_url": "https://logs.example.com/receive"
}
```
The URL is validated at load: it must be `https://`, and declaring it
without the `network` permission is a load violation for api 2 mods. The
destination is reviewed when the mod ships, not chosen per call, so a mod
cannot aim this at arbitrary hosts or read back anything a server replies.
```lua
-- fire and forget; poll() never blocks, same shape as mod.fetch
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
```
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
is a closed list with one switch: `format`, either `"text"` (the default)
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
body }` so a server can attribute and sort reports; any other key or value
is refused before a job is submitted. The body is capped at 64 KB, the
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
response body is never returned to the mod.
## Background jobs
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
the CPU — generating a map, crunching a table, anything that would otherwise
stall a frame. It is behind the `background` permission in `manifest.json`.
Ship the job as its own file inside your mod:
```lua
-- mods/your_mod/jobs/crunch.lua
local arg = ...
local total = 0
for i = 1, arg.n do total = total + i end
return { total = total }
```
```lua
-- in your entry file
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
-- later, in a hook -- poll never blocks
local r = mod.job:poll(job)
if r.status == "ok" then
use(r.result.total)
mod.job:release(job)
end
```
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
`poll(handle)` returns `{ status, result, err }` with `status` one of
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
`available()` is `false` on a host without threads and for mods without the
permission, so a probe is always safe.
**A job is pure compute.** This is the part to design around, not a detail:
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
them. A function, userdata, a cycle or a table key that is not a string or
number is refused at your `run` call with a reason. Nothing is shared —
your argument is snapshotted, and mutating the original afterwards does not
reach the job.
- **No engine API, no game state, no storage.** `require` is refused inside a
job, and there is no `mod` object. A job cannot read the party, write
`mod.storage`, or touch a registry. Get what it needs into the argument and
act on the result back on the main thread.
- **Your script is a file in your mod folder.** The path goes through the same
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
`nil` and a reason until you release one.
- **The budget bounds how long YOU wait, not how long the work runs.** Past
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
arrives — but the thread runs to its own end. There is no way to stop a
LÖVE thread from outside, and every attempt to stop one from inside was
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
and raising from one wedged the whole process). `cancel(handle)` is the
same deal: it drops the result, it does not stop the work.
So **write jobs that terminate.** A job with an infinite loop will keep one
core busy until the game closes. It will not freeze the game — the main
thread stays responsive and quitting still works — but nothing will reclaim
that core in the meantime.
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
fresh Lua state with a full standard library that the sandbox cannot reach, so
handing one to a mod would undo every other rule. Here the worker builds your
sandbox first and loads your chunk into it.
## Pre-sandbox globals (compat)
A mod written before the sandbox landed does not have to be updated to
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
`love.system` and `love.event` are all present again as compat stand-ins
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
(`love.mousemoved = fn`) installs on the real table the way it always did.
Every stand-in call logs one warning naming its replacement, and
`loader:legacyReport(modId)` returns the same list with call counts, which
is what a "needs updating" badge should read.
The stand-ins are not the old globals. Paths are classified rather than
passed through:
- A path inside your own mod directory reads the file you shipped.
- Anything else, including an absolute path, resolves into a private
per-mod overlay at `mod_compat/<your id>/` under the save directory.
Two mods naming the same path never see each other's bytes, and nothing
is written outside the game tree.
- A read misses through the overlay to your shipped file, then to
`mod.storage`, so a half-migrated mod sees both.
- A write over a path you shipped shadows it; the packaged file is never
modified, and `mod:read` still returns the packaged bytes.
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
a virtual root, so a legacy mod that joins its own paths lands back in
the same overlay.
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
with the full standard library, which the sandbox in this state cannot
reach, so a stand-in would be a hole rather than a reroute. The same goes
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
`love.errorhandler`. A mod that needs real background work needs an
engine-owned facility, not a compat shim -- for HTTP that facility is
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
File diff suppressed because it is too large Load Diff
+20 -553
View File
@@ -1,553 +1,20 @@
# New features (deliberate additions beyond the original) # New Features
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue, Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
Genuine divergences from the original (things still missing, wrong, or * **Survey zoom** with connected-map rendering and configurable void fill
approximated) live in docs/known-differences.md; faithfully-ported behavior is * **Perspective tilt mode** for an HD-2D-style overworld
in docs/behavior-porting-notes.md. * **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
## Survey zoom * **Performance presets** and configurable FPS limits
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4` * **Persistent custom options** stored separately from game saves
zooms the overworld between 1 pixel per world pixel (full survey) and 2× * **Optional widescreen battle layout**
the window fit scale (close-up), in crisp integer steps. This has no Game * **Mobile touch controls** with editable layouts, vibration, and orientation settings
Boy equivalent: * **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports**
- Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers, ## Gen 2 Specifics
dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
zoomed world. Zoom input is ignored while a script, menu, or battle is * **Followers** for mods, plus Gen 2-only registries and hooks
active; the zoom offset is persisted as `save.options.zoom` (default
`0` = FIT) and survives New Game via `options.lua`.
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
- Beyond the border ring the void fill repeats indefinitely (see VOID
FILL below); interiors keep their own border block. Each visible map
area is colorized with its own SGB palette (the original recolored the
whole screen per map).
- Neighbor maps load two connection hops out so corner-adjacent maps
don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init).
## VOID FILL
The Options **VOID FILL** row picks what paints the infinite beyond-edge
space on OVERWORLD-tileset maps during survey zoom:
- **TREES** (default): solid tree wall block `$0F`.
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
- **BLACK**: solid black.
Other tilesets are unchanged (house/cave borders stay as authored).
Persisted as `save.options.voidFill`.
## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
diorama look. Like survey zoom this is purely presentational and has no
Game Boy equivalent:
- The entire map tilts as one rigid ground plane, paths, grass, water,
floors, and every background-tile structure (buildings, trees, fences,
signs; in Gen 1 these are baked into the tile layer, not sprites), so
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
facades with their own baked-in fake perspective, because Gen 1's art
was never drawn with a clean seam between ground and standing scenery. It
wasn't merged; tilting everything but the characters as one plane is the
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
like a photo of a diorama, rather than standing fully upright next to
a full-height character).
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
with tilt fully off the world pass drops back onto the flat blit path, so
flat rendering stays pixel-identical to tilt-off and off costs nothing.
- Tilt input is gated exactly like survey zoom, honored only while
free-roaming, ignored while a script, menu, or battle is active, and it
composes with survey zoom (the zoom scale feeds the projection). The tilt
level is persisted in `save.options.tilt` (default OFF).
- It applies everywhere the overworld draws, interiors and caves included.
Menus, text boxes, and battles render flat on top, unaffected, and the
infinite beyond-the-border-ring fill stays flat by design.
- Collision, movement, sight lines, triggers, encounters, and scripts are
untouched; nothing about the tilt reaches gameplay.
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
one global BG palette + one OBJ palette, every map, no per-map variation
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
pair).
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
boot-ROM ramp -- Yellow was CGB-enhanced.
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
"RED++"; it is the richest colorization rather than anything Red-specific.)
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **SGB INV**: each SGB zone palette with shade order reversed.
- **CLASSIC**: original Game Boy pea-soup greens
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
labels.
## GBC FX
The `5` key (and the Options menu GBC FX row) cycles a "played on real
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
levels are a cumulative ladder:
- **1**: reflective-screen backing transparency.
- **2**: + LCD pixel grid.
- **3**: + pixel drop shadows.
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
It runs as a final present pass after world + UI composite in
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
Default OFF; persisted as `save.options.gbcfx`.
Mobile GPUs often compile the pass but present a black frame, so Android and
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
on a reliable-ordered channel, replacing the original standalone Python
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
GAME enters it. Closing performs a graceful ENet disconnect so the final
confirm/bye always lands; a vanished peer exits with "The link was
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
duration:
- **Game speed is pinned to normal.** The GAME SPEED option and
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla, except for your language.** Picking ONLINE
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
off and relaunch (mods merge at boot, so a restart is the only way). The
restart is confirmed, not silent. They stay listed as disabled, ready to
switch back on. A mod that declares itself a translation and provably
writes nothing but text stays on: the two games hash the same link
surface, so a Spanish install and an English one can battle and trade,
each reading the game in its own language and naming the other player's
party out of its own text.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
divergence there ends the match as a draw. `volatile` carries per-turn
flags both sides recompute anyway - a divergence there is logged and
reported to mods, and play continues.
The relay logs which component diverged on which turn, so a desync report
names something specific.
## Custom boot text
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
"bois club" / "bryanthaboi", a deliberate branding customization. The
rest of the boot beats (copyright splash, "presents" shooting-star, the
Nidorino-vs-Gengar attract scene) mirror the original.
## Custom Options
Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New Game
and aren't wiped when a save slot is cleared. Changing a row in the Options
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load.
- Music / SFX volume
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
SFX level, so the follower's constant chatter, the title-screen cry and
every in-battle "Pika!" can be pulled down (or muted at 0) without
quieting the rest of the sound effects. The row is hidden on Red/Blue,
which have no voice clips.
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
`-`/`=` step one level and save
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
## Battle transition cascade + white battle letterbox
Into-battle wipes still run the original eight styles inside the classic
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
## Widescreen battle layout
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
which gives battles a 304×144 native-pixel surface and a Gen 3-style
arrangement on it:
- the foe's status box upper left, the foe's picture upper right;
- the player's picture lower left, the player's status box lower right,
with a longer HP bar and the numeric HP under it;
- a full-width message window;
- a split "What will X do?" prompt / 2×2 command window;
- a 2×2 move menu, navigated with all four directions, with a PP and type
panel attached to its right.
Only the composition changes. Pictures, palettes, HP-bar colors, font
pages, window borders, sounds, animations, timing and every battle rule
stay the engine's, so a COLORS mode or an asset mod still owns the look.
Each side's picture keeps its original pixels and placement math and is
composited into its own region of the wider battlefield -- nothing is
scaled or squeezed -- and animations, which are authored in the original
160-pixel space, shift as one rigid group onto whichever side they play
on. The whole screen is drawn at the window's integer fit scale for the
wider surface, so a 304-pixel screen is drawn a step smaller than a
160-pixel one in the same window.
The wide surface is live only while the battle itself is the screen on
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
brings the classic surface back with it.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
center) over the frame, using Xelu's CC0 controller prompts
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
finger does, sliding on the d-pad changes direction without lifting, and
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
The launcher's **Touch Controls** button opens a drag editor: move each
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
to hide the overlay permanently (for controllers / emulation handhelds --
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
**Done** to save into `options.lua` as normalized window fractions so a
different screen keeps the relative placement.
Portrait and landscape are edited and saved separately (#633): the editor
follows whichever orientation is on screen, and **Reset** only clears that
one, so a layout that works held upright does not have to double as the
one used sideways. An `options.lua` from before this split keeps its single
layout in both orientations until one of them is edited. In-game, Options →
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Screen orientation lock (Android)
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
allows every orientation but defers to the system: with auto-rotate turned
off in Android's quick settings, the game stays put instead of following
the sensor (#716). Changes apply immediately -- the screen rotates as the
row is stepped -- and persist in `options.lua`. Android only: iOS follows
the app's fixed orientation list, and desktop windows rotate nothing.
## Translation support
Every string the player can read is now reachable from a mod, so a
translation is an ordinary content mod rather than a fork.
Two things had to change. Text layout stopped counting bytes: the dialogue
box measures a line in glyphs (charmap sequences), so a 3-byte character
costs one column, a cut never lands inside a character, and a page with a
non-default `advance` re-measures instead of overflowing. That also fixed
25 vanilla English lines that were wrapping early because `é` in POKéMON
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
glyphs, and the box was breaking it).
Second, the text the engine writes itself - battle messages, item results,
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
and the new `strings` registry. Extracted script text was already
overridable through `text`; this covers the other half. Entries are keyed
by the English source, so a translation that has not reached a string yet
keeps rendering in English and a half-finished translation stays playable.
Authors generate the whole thing:
```sh
python3 tools/modkit.py translation francais --language "Francais"
```
That scaffolds a mod with every translatable string as an empty catalog,
plus a glyph-page and charmap stub, a naming-grid stub, and a
`francais-worksheet/` directory holding the English to translate from
(deliberately outside the mod: extracted text is ROM content and must not
be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
A translation can also skip glyph pages entirely: scaffolding with
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
an existing mod) renders text through a bundled TTF covering Latin with
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
`spacing` added to every advance, `yOffset` for vertical alignment
against the 8px cell grid, `bold`, which double-prints at a 1px
offset for fonts whose strokes read too light, and `tiles`, the
characters that keep their ROM tile instead of coming from the TTF.
`tiles` matters for a CJK translation. Sizing the font so a kana fills
the 8px cell leaves Latin narrower than the tile font it replaces, which
pulls the numeric columns out of line: the party menu's `:L12` stops
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
vanilla tiles, so numbers render exactly as they do in English while
kana still come from the font. It takes a string of characters, or a
list when a multi-character charmap sequence is meant.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
The save editor ships inside every build instead of being a developer-only
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
Delete on every slot that actually holds a save. Edit suspends the
launcher, opens that slot's file in the editor, and **Close** hands the
process back to the launcher with the slot list re-read (a rename, a badge
or a dex change shows up on the row immediately). Unsaved edits arm a
confirm first, so leaving cannot lose work. `love . --editor` still opens
it standalone, where Close quits instead; `--save <path>` points it at any
file, and a save can be dragged onto the window.
The editor now wears the launcher's visual language - the same navy radial
field, 16px translucent cards, tri-colour version rail and green/yellow/red
semantics - so the two windows read as one app. Six tabs:
- **Party**: the roster with sprites, HP bars and level chips on the left,
and the mon inspector permanently docked on the right instead of floating
over the list. Species, level, DVs and moves all round-trip through the
Gen 1 formulas, so the inspector can never show illegal stats.
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips. The picker, the bag
and PC storage all scroll under the mouse wheel, so the whole catalog is
reachable one-handed without typing a query.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
and the player / lastHeal / lastOutdoor spawn points settable by clicking
a cell. Setting lastOutdoor on a map the game would not accept as an
outdoor source is refused with the reason.
- **Dex**: seen / owned completion meters and a four-column grid; owning
implies seen and un-seeing clears owned, exactly as the game requires.
Two rules run through all of it. Every mutation goes through one funnel
that sets the dirty flag and writes the status line together, so nothing
changes silently and no branch can quietly no-op - "Party is full", "Bag is
full", "click a cell first" all say so. And every destructive verb (Remove,
Release, Clear all, Wipe dex) arms on the first click and commits on the
second, relabelling itself to `Confirm?` in between.
A validation pill in the tab rail mirrors what the running game would
quarantine on load; clicking it jumps to the tab holding the first problem.
## Tiled map editing (mod authoring)
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
so maps can be edited in a real map editor and exported back out as a mod.
It has its own document: docs/tiled-map-editing.md.
## Pokédex diploma (both versions)
The Celadon Mansion 3F game designer shows the dex-completion diploma
once 150 species are owned. On Yellow, the graphic artist next to him
then offers to print it, saving the certificate as a PNG under `prints/`
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
exports the current box list the same way.
## Pokédex printing (Yellow)
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
for by an image export: choosing PRNT renders the mon's entry page (sprite,
kind, number, height/weight, dex text) to a PNG at 4x scale under
`prints/` in the save directory, then reports the filename in a dialog.
No printer hardware or link cable emulation involved; the file is the
printout.
## Find Mods (community mod indexes)
A FIND MODS tab sits beside MODS in the launcher and browses a published
mod index: a metadata-only feed listing mods that live in their authors'
own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
A feed author can publish per-mod release stats by adding three optional
fields to an entry -- `downloads` (total across every release), and
`first_release` / `last_release` (ISO days) -- which the listing shows in
the same gold line the MODS tab uses. When a feed does not carry them,
the row fetches the mod's own GitHub releases instead -- the same cached
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
appear for any mod with a `github` field regardless of feed maintenance.
The fields are additive: feeds that carry them stay readable by every
build that predates them, and feeds that do not render exactly as before.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
## Controls rebinding (CONTROLS screen)
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
key and controller button side by side (Z/A). Press A on a row, then press
and release the key or pad button you want; the rebind commits on the
release. If that input already belongs to another row, the two rows swap,
so no button is ever stranded without an input and no input ever serves
two buttons. Holding a second key or pad button while the first is still
down backs out of the capture without touching a keyboard; Escape still
cancels too. SELECT clears one row back to its default, and START resets
every binding after a confirmation.
Controllers a system has no mapping for (common on Linux handhelds and
off-brand pads) report bare button numbers rather than names. Those are
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
controller column. Recognized controllers are read only through their
named buttons, so a rebind on those is never shadowed by the factory
layout underneath it.
## Mod profiles (#593)
The mod manager's PROFILES tab holds named setups. A profile remembers which
mods are on, every mod's own options, and which save slot each game version
plays, so swapping profiles swaps the whole playthrough and not just the mod
list. The setup that existed before profiles shipped becomes PROFILE 1 the
first time the manager opens.
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
save directory; drop a `.g1rmodlist` someone shared into that folder and
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
clash gets a number). Mods the shared profile names but that are not installed
are reported when the profile is applied; installing them is still a manual
trip through the mods list or Find Mods.
## Windows: no console windows on launcher actions
Checking for updates, browsing a mod index, adding a mod repo, installing a
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
process. On Windows those children used to each open their own console
window, so a session could end up buried under half a dozen of them. The
game now claims one console for itself at boot and hides it; the children
inherit that invisible console and nothing pops up. Nothing else changes:
file pickers are ordinary desktop dialogs and still appear normally, and a
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
out.
+798
View File
@@ -0,0 +1,798 @@
# Preparing your mod for Gen 2 (Gold)
You have a mod that works on Red, Blue or Yellow, and you want it to work on
Gold. This is the migration guide: what breaks, what the engine papers over
for you, what it refuses to paper over, and the order to do the work in.
`docs/mod-api-gen2-compat.md` is the reference for *what Gold serves*. This
document is the procedure for *getting your mod there*. Read that one when you
need to know whether a registry or a hook exists; read this one first.
## What actually breaks, and why
Gold is not a skin over the Gen 1 engine. It is a second engine living beside
the first one: `src/core/Game2.lua` owns the boot, `src/world/gen2/World.lua`
is the overworld, `src/battle/gen2/Battle.lua` is the battle, and
`src/script/gen2/Vm.lua` runs the cart's own bytecode instead of a Lua row
list. A Gold boot never loads `src/core/Game.lua`,
`src/world/OverworldController.lua` or `src/battle/BattleState.lua` at all.
The mod API on top is deliberately one API -- the same registry names, the
same hook names, the same event names, the same `mod.*` facade -- so a mod
that stays on that surface mostly moves across unchanged. What does not move
is everything underneath it.
The failure that motivated all of this is quiet, which is what makes it worth
a whole document. A mod with `engine_internals` writes
`local Game = require("src.core.Game")` and patches a method on it. Under Gold
that require used to succeed: the file is on disk, `require` finds it, hands
back a perfectly good module table, and your patch lands on it. Nothing ever
instantiates that table, so the patch runs zero times and the only symptom is
that your mod does nothing. No error, no warning, no crash to bisect. Two
things fixed that. First, a mod is not loaded on a Gold boot unless it says it
is for Gold, so the default outcome is "not running" rather than "running
wrong". Second, when it does say so, a require made from your own file is
answered by an adapter (`src/mods/Gen2Compat.lua`) that presents the Gen 1 API
over Gold's internals, and a member the adapter cannot honestly back reads nil
instead of reading plausibly-wrong.
## Step 1: run the checker before you change anything
`modkit gen2check` reads your manifest, statically scans every `.lua` the
package carries, and cross-references what it finds against the adapter's own
coverage table. Run it first, because it tells you the size of the job in a
few seconds.
```sh
python3 tools/modkit.py gen2check <id-or-path> [<id-or-path>...]
```
Real output, against a follower mod written for Yellow:
```
-- PokePCFollowers_VoxelMerge: api 1, profile content, no games declared, permissions engine_internals, 0 dependencies, game_version unset
MK400 ERROR manifest.json: no Gen 2 game in "games" (and no gen2compat), so a Gen 2 boot skips this mod; the rest of this report is what it would hit once it claims one
MK404 ERROR main.lua:575: BattleState.newWild has no Gen 2 backing: Gold has no factory that returns an unpushed battle, and World:startBattle constructs and pushes in one call. A mod that wraps newWild to rewrite the species must be pointed at the encounter.species hook, which Gold raises with the same name and shape (World:rollEncounter); this reads nil
MK404 ERROR main.lua:576: BattleState.newWild has no Gen 2 backing: ... ; nothing on a Gen 2 boot reads this write
MK409 WARN main.lua:13: allow-lists a Gen 1 version string, which excludes this mod from a Gen 2 game by construction; test for the capability the code needs instead of the version
MK409 WARN main.lua:424: ... (same, a second allow-list)
MK409 WARN main.lua:565: ... (and a third)
modkit: unresolved: 1 site: requires whose result is neither bound to a name nor indexed here, so where the module goes is not followed (main.lua:221)
modkit: unresolved: 5 debug upvalue calls whose target function this scan could not tie to an engine module, so the local they reach could not be resolved (main.lua:279, main.lua:285, main.lua:288, main.lua:321 and 1 more)
modkit: src.world.PikachuFollower.onMapEntered closes over 'shouldSpawn' on a Gen 2 boot, so the upvalue surgery at main.lua:325 lands as it does on Gen 1
FAIL PokePCFollowers_VoxelMerge on gen 2: will not work (3 errors, 3 warnings)
```
Three kinds of line, and the difference matters:
- **`MK4xx ERROR` / `MK4xx WARN`** are findings with a file and a line. Errors
set the exit code; warnings do not unless you pass `--strict`.
- **`modkit:` notes** are things the tool derived rather than found, or could
not decide at all. They never change the exit code. The `shouldSpawn` note
above is the tool resolving that member through the adapter on a Gen 2 boot,
enumerating the function's real upvalues, and confirming the surgery lands;
the `unresolved:` notes are the tool naming, with file and line, every reach
it saw and could not follow.
- **The verdict**: `will load`, `will load but degrade`, or `will not work`.
The rule ladder:
| rule | what it means |
| --- | --- |
| `MK400` | the manifest claims no Gen 2 game, so a Gen 2 boot skips the mod |
| `MK401` | a dependency claims no Gen 2 game, which takes you down with it |
| `MK402` | you require a Gen 1-only module the adapter does not serve |
| `MK403` | a Gen 2 boot runs a `gen2/` sibling of the module instead |
| `MK404` | a member you touch has no Gen 2 backing (the adapter's own reason is quoted) |
| `MK405` | a member you touch degrades and says so once |
| `MK406` | the signature moved under an alias |
| `MK407` | `debug` upvalue surgery the Gen 2 arm cannot take: the member is not a function there, or the function does not close over that local |
| `MK408` | upvalue surgery the scan could not resolve either way |
| `MK409` | a version allow-list, or a Gen 1 screen id |
| `MK410` | the entry chunk reads a member of a game that is not up yet |
Flags: `--strict` promotes warnings to failures, `--notes` prints the adapter's
note for every *backed* member you touch (worth reading once per mod, because
several backed members are backed with a caveat), `--json` emits one document
for the whole batch, `--quiet` drops everything except the findings -- no
header, no notes, no verdict line, so a clean mod prints nothing at all and the
exit code is the whole answer. Exit code is 0 clean, 1 on a fatal finding, 2 on
usage.
Name several mods in one invocation and they are read as one install set, so a
mod and its dependencies can answer each other's `MK401`.
**What the checker cannot see, and now says so.** It is a static scan, not a
run. It follows more than it used to -- a require made through your own
`tryRequire`-style wrapper, `local ok, M = pcall(require, "...")`, an inline
`require("src.world.Map").waterTiles(...)`, a bracket index `M["member"]`, a
local hop `local F = M` -- so reaches that used to be invisible now produce
real findings, and a mod that passed before can fail now.
Two places where it used to answer confidently and wrongly now do not.
`local A, B = require("src.world.Map")` is read as binding `A`, which is what
Lua does; it used to take the name nearest the `=` and pin the module on `B`,
so every reach off `A` went unchecked and every reach off `B` was checked
against a module that was never there. And a helper of your own is only read as
upvalue surgery when the scan can see it forward its own `(function, name)`
pair into the `debug` call; a helper that merely mentions `upvalue`, or that
finds the slot by walking `debug.getupvalue`, no longer has its call sites
read as naming an engine local, because they do not.
What it still cannot follow it names instead of ignoring. Every unfollowed
reach comes back as an `unresolved:` note carrying a file and a line. The scan
side raises one for:
- a require name built at runtime, whether handed in whole or concatenated
(`require("src.world." .. name)` is as unfollowable as `require(name)`);
- an engine module name handed to a call the scan does not follow;
- an engine module name spelled in a literal with no require attached;
- a require whose result is neither bound to a name nor indexed on the spot;
- a require in a multiple assignment whose value it cannot pair to a name;
- a name bound to a *member* of a module rather than the module;
- an engine module indexed with a computed key;
- `rawget` or `rawset` on a bound module: that goes straight to the table the
require shim hands back, so on a Gen 2 boot it reads or writes the
Gen2Compat facade and not the module behind it;
- an engine module read as a value rather than indexed, so where it goes from
there (a table field, a call argument, a metatable's `__index`) is not
followed;
- a `debug` upvalue call whose target function could not be tied to a module;
- a call through one of your own upvalue helpers that the scan could not
confirm carries an upvalue name through to the `debug` call.
Four more come from the coverage side rather than the scan: a dependency that
is not installed beside your mod, a required name that is neither an adapter
nor a module in this checkout, a module with no coverage row at all, and a Gen
1 member the coverage table does not classify.
The practical consequence is worth stating plainly: an empty finding list
*plus* no `unresolved:` notes now means the scan followed everything it saw,
and an empty finding list on its own does not.
It is still silent on any member the adapter's coverage table does not record:
the table lists 481 members across the 15 served modules, which is a large
majority of what real mods touch and is not the whole Gen 1 API. A clean
`gen2check` means "nothing known-broken was found", not "this works". Boot it.
## Step 2: declare which games the mod is for
Nothing moves on disk. A mod is installed once, into `mods/<id>/`, and that one
directory serves every game. There is no `mods/gen1/`, no `mods/gen2/`, and no
per-generation copy: targeting is something the manifest *declares*, not
something the filesystem encodes.
```json
{
"id": "my_mod",
"name": "My Mod",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"games": ["gen1", "gen2"]
}
```
`games` is an optional array. Each entry is one of:
| token | means |
| --- | --- |
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has |
`src/mods/ModTargets.lua` is the one place those tokens are resolved, and it
derives the list from `GameVersion.ORDER` rather than restating it, so a game
added later needs no edit there. The scaffold writes the key for you:
```sh
python3 tools/modkit.py scaffold my_mod --games gen1,gen2
```
**Omitting `games` keeps the old meaning exactly.** No `games` key means Gen 1
only, plus Gen 2 if the legacy `"gen2compat": true` flag is set. Every manifest
written before the key existed means precisely what it always meant.
`gen2compat` is still accepted and is purely additive: it *adds* the Gen 2
games to whatever `games` says, so no manifest can lose a game it already ran
on. `Manifest.validate` (`src/mods/Manifest.lua:210-224`) resolves the two
into one ORDER-sorted `manifest.games` array and derives `manifest.gen2compat`
from it, which is why `"games": ["gen2"]` is honoured by the loader's gate
today with no other change.
An unknown token warns and is dropped under `api` 1 and refuses the manifest
under `api` 2 (the normal `violation()` rule). A `games` array that names no
game this engine knows falls back to the default rather than orphaning the mod.
A non-array `games` is a hard error.
### What you are claiming
Adding a game to `games` is you saying *I have run this there*. It is not a
request for best-effort support and the loader does not treat it as one: a mod
that claims a game is loaded on that boot in full, with its registrations, its
subscriptions and its entry chunk, exactly like a mod written for it. If it is
half-working, the player sees a broken mod, not a partially-supported one. That
is the whole reason the key exists rather than being inferred.
**Every token is enforced, per game.** `Loader:_gateGeneration`
(`src/mods/Loader.lua:447`) gates on `ModTargets.supports(manifest, version,
generation)` -- the same call both mod surfaces make -- so `"games": ["blue"]`
really does not load on Red, and the skip line is the launcher's line, `For
Blue, not Red`. `"games": ["gold"]` alone no longer loads on Red either: it
names one game, and that game is Gold. A manifest with no `games` and no
`gen2compat` still covers every Gen 1 game, so nothing written before the key
existed changes behavior; what changed is that a version-id token is now a
statement the boot keeps rather than a label the UIs draw. If you want a mod
everywhere, say so: `["gen1", "gen2"]` or `["all"]`.
**Dependencies are contagious.** A mod whose hard dependency does not run here
is left out too, carrying the dependency's own wording (`depends on X, which
does not run here (For Blue, not Red)`). It is reported as a skip rather than a
failure and neither mod lands on the boot error list, but the mod does not run.
Every hard dependency in the chain has to cover the same games; `MK401` is the
checker's version of this question for the Gen 2 half of it.
**The player can overrule you, in one direction only.** The in-game mod
manager offers `TRY HERE ANYWAY` on the detail pane for any mod that does not
claim *this* game (`src/mods/ManagerState.lua:386`), which now includes a Gen 1
boot: a Blue-only mod is genuinely skipped on Red, so that row is the only way
to run it there. The choice is **per game**: `options.modsGen2[id]` is a
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
Gold. A stored legacy `options.modsGen2[id] = true` from before the key was
per-game reads as "the Gen 2 games", which is the only set it could ever have
affected, and it is expanded in place the next time the player answers. A
forced mod loads normally and keeps a note saying its author never verified it
here; the launcher shows it as `Forced onto Gold by you (untested)`. If the
override cannot be persisted the manager says `COULD NOT SAVE` rather than
promising a restart that would change nothing.
### What the player sees
All three surfaces read the same derivation -- the two UIs and the loader --
so they cannot disagree about your mod. The launcher's mod panel carries a
`Show for:` chip row (All games / Red / Gold / ...) and a per-mod tag from
`ModTargets.chip` -- `GEN 1`, `GEN 1+2`, `RED/GOLD`, `BLUE` -- greyed out when
the mod does not run on the selected game, with the line `Not for this game`
(`src/import/LauncherView.lua:320`) and the detail from `ModTargets.detail`,
`For Gen 1, not Gold`. The in-game manager shows the same thing as
`ENABLED (NOT THIS GAME)` with the skipped glyph, plus an inert `FOR GEN 1+2`
row on the detail screen. The launcher's dependency verdict asks the same
question of your dependencies: a mod whose hard dependency does not run on the
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### Scoping dependencies per game / generation
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
dependency can be scoped to specific games so that it is only enforced when
booting those games:
```json
"dependencies": [
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
]
```
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
For conditional integrations where the dependency is optional across the board,
`optional_dependencies` remains the standard pattern.
### One limit worth knowing
**Enablement is per game.** The overlay
`options.modsByVersion[version][id]` is read and written through
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
manager, and loader. Existing shared settings are copied to every game the
first time this version sees the installed mods; from then on, each coloured
game checkbox changes only that game's next boot. Nothing about this affects a
mod author; it affects what a player can express.
Targeting is a different question from enablement and *is* enforced per game,
as above. The two do not share a switch.
## Step 3: prefer the API over the modules
Before doing any adapter work, check whether you need the modules at all. In
new code, take the live game from `mod.game` and the world from `mod.world`.
Both resolve per generation inside the loader (`src/mods/Loader.lua:1021`):
`mod.game` is `src/core/Game.lua`'s singleton under Gen 1 and the `Game2`
*instance* Gold injected under Gen 2, read on every touch rather than cached;
`mod.world` is `src/world/WorldAPI.lua` or `src/world/gen2/WorldAPI.lua` behind
one method set. Neither needs `engine_internals`. The `game.ready` payload and
every `ui.*` hook's first argument carry the same live game.
Anything you can express as a registry write, a hook or an event subscription
is generation-agnostic already and needs nothing from this document. The
adapter exists for the code that was written before Gold did, and for the small
number of things the API genuinely does not reach.
## Step 4: the adapter, module by module
On a Gen 2 boot with mods present, `require` is interposed
(`Loader:_installDevShim`, `src/mods/Loader.lua:184`) and a require *made from
a mod's own chunk* for one of fifteen Gen 1 names is answered by
`src/mods/Gen2Compat.lua`. Engine code is unaffected: the shim compares the
caller's chunk name against the engine tree, so `src/render/PaletteFX.lua`
requiring `src.core.Game` still gets the real Gen 1 module on both generations.
This is not a dev-mode feature; it installs on any Gold boot that has mods.
| the name you require | kind | what you get | backed / warned / absent |
| --- | --- | --- | --- |
| `src.core.Game` | facade | a live proxy onto the `Game2` instance | 70 / 9 / 12 |
| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua` | 56 / 5 / 68 |
| `src.world.Map` | alias | `src/world/gen2/Map.lua` | 28 / 2 / 9 |
| `src.world.NPC` | alias | `src/world/gen2/Npc.lua` | 27 / 0 / 1 |
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
| `src.world.FieldDefaults` | facade | the `playerSprites` answer and named refusals | 5 / 2 / 3 |
| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` | 4 / 1 / 0 |
| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua` | 4 / 0 / 0 |
| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` | 1 / 0 / 0 |
**Alias means the adapter *is* the Gen 2 module.** Your monkey-patch, your
`rawset` sentinel and your `==` idempotency check all land on the table Gold
actually runs, and `getmetatable(npc) == NPC` is true. Five names are aliases
because nothing less would work: mods set their own trailer's metatable to
`src.world.NPC`, a mod is handed `world.map` rather than building one, the
loader builds every `mod.world` out of `src.world.WorldAPI` so a copy would
give two, `src.world.PikachuFollower` is reached with `debug.setupvalue` on a
file-local, and `Screens` caches `src.ui.BoxMenu` for `"Gen2PcMenu"` so a
`.new` patch has to land there.
Note that `src.ui.BoxMenu` points at `src/ui/gen2/PcMenu.lua`, not at
`src/ui/gen2/BoxMenu.lua`. Gen 1's `BoxMenu` is Bill's PC *top menu*, whose
Gold counterpart is `PcMenu`; Gold's `BoxMenu` is the withdraw/deposit *list*
that Gen 1 builds inline.
**Facade means a translating wrapper.** `.overworld` resolves `Game2.world`,
`writeOptions` resolves `Game2:persistOptions`, `game.data.sprites` resolves
`data.gen2Sprites`, `NPC.new(data, mapId, objDef)` is sniffed apart from
`NPC.new(mapId, objDef, spriteDef)` and the movement vocabulary is translated
with it. The four UI facades (`PartyMenu`, `StartMenu`, `OptionsMenu`,
`BattleState`) are write-through: reads fall to the Gen 2 class and **writes go
to the Gen 2 class**, so `PartyMenu.update = wrapper` still patches the live
class Gold pushes. Your write also *reads back as your own value* -- after
`PartyMenu.new = wrapper`, `PartyMenu.new` is `wrapper` and nothing else, so
`rawequal` holds and an idempotency check works. That is what makes the ordinary
capture-and-chain idiom safe: a wrapper that calls the value it captured reaches
Gold's real constructor rather than re-entering the facade's own override.
Writing `nil` clears the member instead of re-exposing the override underneath.
The `src.world.OverworldController` facade is a facade over the live `World`,
not over a class, so seven of its fields (`map`, `player`, `npcs`, `entities`,
`ghosts`, `npcPool`, `camera`) read **and write** through to the running world:
Gen 1's module *is* the singleton, so a write has to land somewhere real. A
write made before a world exists is dropped with a warning rather than
shadowing the world it would have applied to.
### backed, warned, absent
The adapter publishes what it covers, and the checker consumes that same table
rather than a copy of it. Exactly three statuses, and a member listed as both
resolves to the weaker one:
- **`backed`** -- present, and it does the Gen 1 job on Gold. Read the note
anyway where there is one: several backed members are backed with a caveat
(`Boxes.COUNT` is 14 on Gold and not 12; `BattleState.say` ignores
`sayAuto`'s delay because Gold's messages always auto-advance;
`Collision.DELTA` is Gold's live table, so adding a key mutates Gold's own
movement).
- **`warned`** -- present, answers nil or degrades, and names itself once in
the log with your mod attributed. `Game.renderer`, `Game.load`,
`Game.step`, `game.data.field`, `game.data.constants`,
`ScriptRunner.resume` / `.update` / `.parallel`, `PartyMenu.tmhm` and
`OverworldController.neighbors` / `.npcByIndex` are here. `neighbors` is the
shape of the whole category: Gold's rows are `{ id, ox, oy, image }` where
Gen 1's are `{ map = mapDef, ox, oy }`, so the field warns and answers nil
rather than handing back a list whose `nb.map` is nil on every row.
- **`absent`** -- deliberately not on the table. It reads nil, which is the
honest failure. `BattleState.newWild`, `OverworldController.rollEncounter`,
`Map.warpPadOrHoleAt`, `PikachuFollower.shouldSpawn` and 157 others are
here. (`shouldSpawn` is absent as a *module member* on both generations: it
is a file-local, reached through `setShouldSpawn` or the upvalue of that
name, and the coverage table says so rather than implying a field exists.)
"Absent" means *not served*, not *wrong*. Every one of them was left off for a
stated reason, and the reason is in the coverage note. `BattleState.newWild` is
the clearest case: Gold has no factory that returns an unpushed battle, because
`World:startBattle` constructs and pushes in one call, so a `newWild` taking a
species and a level would be a lie about what Gold's battle screen is. The
route for the thing you were actually doing (rewriting the species of a wild
encounter) is the `encounter.species` hook, which Gold raises under the same
name with the same shape.
A member the table does not record is not a guarantee of anything. What it does
depends on the adapter: an alias hands you the Gen 2 module's own member,
whatever that is; a write-through facade falls to the Gen 2 class; the
`src.core.Game` facade names it in the log and reads nil; the
`src.world.OverworldController` facade reads nil silently. The checker is
silent about it too.
### Reading the coverage yourself
The table is queryable, and it is the same query the checker makes:
```lua
local Gen2Compat = require("src.mods.Gen2Compat")
Gen2Compat.modules() -- the 15 served names, sorted
Gen2Compat.serves("src.world.Map") -- true
Gen2Compat.memberStatus("src.battle.BattleState", "newWild") -- "absent"
local c = Gen2Compat.coverage("src.world.Map")
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
-- notes = { [name-or-topic] = "one line" } }
```
`Gen2Compat.COVERAGE_VERSION` is 1 and `Gen2Compat.STATUS` carries the three
status strings. `notes` keys are documentation topics, not a member list:
dotted paths (`save.money`), field names (`warpAt`), hook names
(`hook ui.pc.items`) and bare topics (`identity`, `iteration`, `rawset`) all
appear there. `members` is the authoritative set.
To dump the lot for one module:
```sh
luajit -e 'package.path="./?.lua;"..package.path
local G=require("src.mods.Gen2Compat")
local c=G.coverage("src.world.OverworldController")
for m,s in pairs(c.members) do print(s,m) end
for k,v in pairs(c.notes) do print("note",k,v) end'
```
## The patterns no adapter can fix
Five shapes come up in nearly every real Gen 1 mod, and none of them can be
fixed on the engine side without lying to you. Each one has a route that works
on both generations.
### 1. A hardcoded version allow-list
```lua
local v = GameVersion.get()
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
```
This excludes you from Gold by construction, and it does so *after* everything
else in your mod has been made to work, which is why it produces the most
confusing possible outcome: the adapter resolves, your patches land, and the
feature still never appears. `MK409` catches it.
**Instead**, test for the thing the branch actually depends on. If it is there
because a member might be missing, test the member:
```lua
local Follower = require("src.world.PikachuFollower")
if Follower.setShouldSpawn then ... end -- present on Gold, absent on Gen 1
```
If it is there because a piece of per-cart content might be missing, test the
content -- `mod.find` and the merged data tables answer that in both games.
Version tests stay legitimate for genuinely per-cart *content*, which is what
Yellow's starter rename is; they are never right as a gate on a whole feature.
### 2. String-matching a screen id
```lua
if id == "BoxMenu" then ... end
```
Gold's builtin screens are registered under `Gen2`-prefixed ids, so this
matches nothing there. `Screens.GEN2_IDS` in `src/ui/Screens.lua` is the full
list, 51 ids: `Gen2BoxMenu`, `Gen2PartyMenu`, `Gen2NamingScreen`,
`Gen2Credits` and 47 more. `MK409` catches this exact line: it keys off the
string literal itself, not off a screen-shaped word elsewhere on the line, so
`if id == "BoxMenu" then` is flagged where it used to slip through. The price
of that is deliberate breadth -- any literal equal to a Gen 1 screen id with a
`Gen2` twin is warned about, wherever it appears -- so the message states what
is true of the literal rather than guessing what the surrounding code meant.
It is a warn, and reading past a false one costs you nothing.
**Instead**, either match both ids, or stop matching ids and take the seam the
screen offers. Most screens a mod wants to decorate raise a hook whose name is
shared across both generations -- `ui.start_menu.items`, `ui.options.rows`,
`ui.party.submenu`, `ui.pc.items`, `ui.naming.grid`, `ui.list_menu` -- and a
hook subscription needs no id at all. Where you genuinely must key off the id:
```lua
local BOX_IDS = { BoxMenu = true, Gen2PcMenu = true }
if BOX_IDS[id] then ... end
```
Watch the pairing. `ui.pc.items` has the same name on both sides but a
different menu behind it: Gen 1 raises it over the WHICH-PC list, Gold over
Bill's PC's own rows. And Gen 1's `BoxMenu` pairs with `Gen2PcMenu`, not with
`Gen2BoxMenu`.
### 3. `debug.setupvalue` on an engine local
```lua
local idx = findUpvalue(PikachuFollower.update, "shouldSpawn")
debug.setupvalue(PikachuFollower.update, idx, myPredicate)
```
This only ever worked because the Gen 1 file happened to hold that predicate in
a file-local of that name. Nothing about the engine promises it, and on the Gen
2 side the local has to exist under the same name and hold the same thing for
the surgery to land. Today it does: `src/world/gen2/Follower.lua:23` declares
`local shouldSpawn` for exactly this reason, so follower mods reaching for it
work unchanged on Gold. That is a deliberate courtesy, not a contract.
`MK407` fires in the two cases where the surgery cannot land: when a Gen 2 boot
resolves the member to something that is not a function (so `debug.setupvalue`
raises), and when the function it does resolve to does not close over that
name, in which case the message quotes the upvalues it *does* close over. The
check resolves the member through the adapter exactly as the loader does and
enumerates the resolved function's real upvalues, so a local that merely
appears somewhere in the Gen 2 file is never mistaken for one -- that used to
be the check, and it blessed surgery that landed on nothing. `MK408` fires when
the scan could not resolve the member either way, which is what you get when
`luajit` is not on `PATH`: the check degrades to an honest warn, never to a
reassuring note.
**Instead**, use the named seam when there is one, and fall back only when
there is not:
```lua
if Follower.setShouldSpawn then
Follower.setShouldSpawn(myPredicate) -- Gen 2, and any future Gen 1 arm
else
patchUpvalue(Follower.update, "shouldSpawn", myPredicate) -- Gen 1 today
end
```
`Follower.setShouldSpawn` writes the same cell `debug.setupvalue` reaches, so
the two cannot disagree. Note the presence test is doing real work:
`src/world/PikachuFollower.lua` has no `setShouldSpawn`, so this is not a
rename you can apply blindly. Note also that the predicate is called
`(game, world)` on Gold where Gen 1 passes `(game, ow)` -- the same object under
a different name, so a predicate reading `ow.player` or `ow.map` is unchanged.
### 4. Capturing state off `src.core.Game` at file scope
```lua
local Game = require("src.core.Game")
local save = Game.save -- nil forever
local party = Game.save.party -- error at load
```
The module require itself is fine and is meant to be: the Gen 2 `src.core.Game`
is a proxy that reads the live `Game2` instance on *every* touch, precisely so
that a mod capturing it at file scope, before a save or a world exists, keeps
working once they do. What does not survive is capturing a *field* off it at
file scope, which snapshots nil. This is true on Gen 1 as well; Gold just makes
it bite more often because the entry chunk runs earlier relative to the world.
`MK410` catches the file-scope read of a member the Gen 1 module only ever
writes as `self.<name>`.
**Instead**, read through the facade at the moment you need the value, or take
the live game from the `game.ready` payload:
```lua
local Game = require("src.core.Game")
mod.events:on("game.ready", function(ev)
local game = ev.game -- the real Game2 instance
local party = Game.save.party -- read now, not at file scope
end)
```
Three further properties of the proxy that a Gen 1 mod can trip over, all
recorded in the coverage notes:
- **Identity.** The proxy can never compare equal to the `Game2` instance the
`game.ready` payload carries. Lua 5.1 fires `__eq` only when both operands
share a metatable, so `Game == ev.game` is false on Gold. Do not use it as
an idempotency check.
- **Iteration.** `pairs`, `next` and `rawget` see an *empty* table, because the
proxy holds nothing of its own. Enumerate the `game.ready` payload instead.
- **`rawset`.** `rawset(Game, k, v)` lands on the proxy, reads back correctly
through the same facade, and is completely invisible to the engine. That
read-back is what hides it. Use a plain assignment, which writes through to
the live instance.
The save layout moved too, and those fields are absent rather than aliased so
that a wrong read is loud rather than silent: `save.money` is
`save.player.money`, `save.player.map` / `.x` / `.y` / `.facing` are
`save.position.*`, and `save.player.rival` is `save.rival.name`. `save` itself
is a straight pass-through on purpose.
### 5. Monkey-patching a class, and the two ways it goes wrong
Patching a shared class method is *supported*, and this is worth stating
plainly because it is the thing most authors expect to have to rewrite. The
four UI facades write through: `__newindex` forwards to the Gen 2 class, so
```lua
local PartyMenu = require("src.ui.PartyMenu")
local origUpdate = PartyMenu.update
function PartyMenu.update(self, dt) ... return origUpdate(self, dt) end
```
lands on the class Gold actually pushes. Aliases are the class, so the same
holds there.
Two variants do not work, and neither can be made to.
**Patching a member the Gen 2 class does not have.** The write succeeds, reads
back as your own function, and nothing ever calls it. `BattleState.newWild =
wrapper` is the canonical case: the assignment is taken, and no Gold code path
reads that name. This is the one place the read-back works against you, which
is why `MK404` reports the write site separately from the read site.
**Patching a field on a live instance.** `menu.onSwitch = fn` writes a field
Gen 2 never reads -- Gold takes it as `onChoose` at construction. Same for
`menu.swapFrom` (renamed `switchFrom`) and for `StartMenu`'s `tx` / `ty` / `tw`
/ `th` / `anchor` / `maxVisible`, which do not exist on Gold at all because the
box is fixed at `Chrome.box(10, 0, 10, h)`. A write to any of them is inert.
Pass what you need to `.new` instead: `PartyMenu.new(game, { onSwitch = f })`
with no `battle`, `pickOnly` or `forceSwitch` opens the plain list and calls
`onSwitch(mon, menu)` on A, which is the Gen 1 behavior the facade reproduces.
A close relative worth calling out because it errors rather than no-ops:
`map.warpAt` is a name collision, not a rename. Gen 1's is a *table* keyed by
cell; Gold's `Map:warpAt` is a *method* of the same name. `map.warpAt[cell]`
and `pairs(map.warpAt)` both raise, which is loud but points at your mod.
Enumerate `map.warps`, which Gold carries as an ordered array.
## A worked migration
Here is one real one, start to finish. The mod is a follower pack written for
Red/Blue/Yellow. `gen2check` reports `MK400` on the manifest, `MK404` twice on
`BattleState.newWild` and `MK409` on a version allow-list, plus a note
confirming its `shouldSpawn` surgery lands.
**Before.** Three separate problems in about twenty lines.
```lua
local BattleState = require("src.battle.BattleState")
local PikachuFollower = require("src.world.PikachuFollower")
local GameVersion = require("src.core.GameVersion")
return function(mod)
-- (1) rewrite the starter encounter's species
local origNewWild = BattleState.newWild
BattleState.newWild = function(game, species, level, ...)
if species == "PIKACHU" and level == 5 then species = "CHARMANDER" end
return origNewWild(game, species, level, ...)
end
-- (2) decide whether a follower spawns
local newShouldSpawn = function(game, ow)
local v = GameVersion.get()
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
return packSize(game) > 0
end
-- (3) install it
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
end
```
On Gold: (1) assigns onto a name nothing reads, so the species rewrite never
happens. (2) returns false for every Gold boot, so no follower ever spawns.
(3) actually works, and works on a predicate that has already decided to do
nothing. Two silent failures and one correct mechanism pointed at them.
**After.** The manifest gains `"games": ["gen1", "gen2"]`, and:
```lua
local PikachuFollower = require("src.world.PikachuFollower")
return function(mod)
-- (1) the species of a wild encounter is a hook on both generations
mod.hooks:wrap("encounter.species", function(next, enc, ctx)
local rolled = next(enc, ctx)
if rolled and rolled.species == "PIKACHU" and rolled.level == 5 then
rolled.species = "CHARMANDER"
end
return rolled
end)
-- (2) no cart check: whether there is a pack to walk is the whole question
local newShouldSpawn = function(game, ow)
return packSize(game) > 0
end
-- (3) the named seam where there is one, the upvalue where there is not
if PikachuFollower.setShouldSpawn then
PikachuFollower.setShouldSpawn(newShouldSpawn)
else
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
end
end
```
`gen2check` now reports clean, and the mod is shorter than it was on Gen 1
alone. That is the usual shape of this work: two of the three fixes replace
engine surgery with an API that existed the whole time, and only the third
needs a generation branch.
The one change that is *not* a simplification is the hook's contract. A wrapper
takes `(next, ...)` and must call `next` with the arguments it was handed, where
the monkey-patch could do as it liked with them. `encounter.species` transforms
a rolled `{ species, level }` and gets a `ctx` beside it: Gen 1 fills in
`mapId`, `terrain` and `rng`, and Gold adds `daytime`, `environment`, `kind`
(`"wild"` / `"contest"` / `"script"` / `"sweet_scent"`), `tables` and `data`.
So the same
subscription serves both games, and a Gold-only refinement is a field test
rather than a second hook. That is the trade: a narrower seam that both engines
raise, in exchange for not owning a function neither engine promised you.
## Testing
**Headless, without a Gold cache.** The SDK harness takes the generation
directly, and everything after that is the production path -- same loader, same
validate, same topological sort, same merge:
```lua
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
T.eq(run.mod and run.mod.state, "loaded",
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
T.eq(#run.errors, 0, "and loads with no boot errors")
run.release()
```
**Assert the state, not just the error count.** A gate skip is deliberately not
an error: `Loader:_skip` sets `mod.state` and `mod.skipReason` and stays off
`loader.errors`, because neither the mod nor its dependency has a bug. So
`T.eq(#run.errors, 0)` on its own passes for a mod that never ran a line, which
is the one result you were testing to rule out. `run.mod.state` is `"loaded"`
when the entry chunk ran and `"wrong_generation"` when the gate or the
dependency contagion took it, with `run.mod.skipReason` carrying the sentence
the manager would show. Keep the error assertion too: it is what catches a
registry with no Gen 2 home and a require the adapter does not serve, both of
which *do* land on `loader.errors`.
**On a real Gold boot.** Nothing above substitutes for running it. Import Gold
in the launcher, enable your mod, and play the part your mod touches. Be
precise about where the adapter talks to you, because the two channels are not
the same:
- **The log** carries the adapter's own warnings, each attributed to the mod
holding the facade (`[my_mod] Game.renderer has no Gen 2 backing: ...`), so a
member that degraded tells you which one and why. `Gen2Compat.warnOnce` goes
to `Logger.warn` and nowhere else -- these do **not** appear in the manager.
- **The manager's error feed** (`loader.errors`) is a shorter list: a mod that
failed validation, a duplicate mod id, a registry with no Gen 2 target, a
cross-validation problem, and the one adapter-adjacent case, a require for a
Gen 1 module the adapter does not serve. A skipped mod is not on it, and
neither is a degraded member.
So: read the log for coverage problems, and the manager for load problems.
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
## What this guide does not promise
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
out of a much larger engine, and within those 15 the coverage table records
291 backed members against 32 warned and 161 absent. The absent ones are not
a backlog; most are absent because there is no honest Gen 2 answer, and each
one carries its reason. The counts move as the adapter learns something: a
member that turns out to answer nil is demoted from backed to warned or
absent rather than left flattering the table.
- **Absent is not broken, it is not-served.** A nil read is the designed
outcome. If you would rather have an error, test for the member before you
use it.
- **The checker is a static scan.** It cannot follow a require built at
runtime, cannot tie every `debug` call to a module, and says nothing at all
about a member the coverage table does not record. What it *can* do is admit
each of those individually, with a file and a line, as an `unresolved:` note.
Read the notes as part of the report: a clean finding list with notes under
it means "nothing known-broken was found in the part I could follow", and
only a clean finding list with no notes means the scan followed everything.
- **A backed member can still surprise you.** `backed` means the adapter took
responsibility for the Gen 1 call shape, not that Gold behaves identically.
Run `gen2check --notes` once and read the caveats on the members you touch.
- **The adapter is not a compatibility layer for new code.** It exists so mods
written before Gold existed keep working. If you are writing something now,
`mod.game`, `mod.world`, the registries and the hooks mean the same thing in
both games and need none of this.
-96
View File
@@ -1,96 +0,0 @@
# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
## Status
Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
`mod_world_tests.lua`.
**Regeneration required.** The manifest and sprite sheet update by
re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
checkout, then re-importing the Yellow ROM.
## Motivation
Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
when the party mon that knows SURF is a Pikachu. The recomp misses this
in two places:
1. **Extraction.** `SurfingPikachuSprite` is not in
`SpriteSheetPointerTable` — loaded by its own `ld de,` like
`RedBikeSprite`. The extractor never sees it, and the symbol is not
in the Yellow manifest.
2. **Engine rule.** `field.playerSprites.surf` is one static
(`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
is a Pikachu."
## The decision it extends
No prior D-number. Extends the surf-field-move port in
`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
with the player-sprite swap vanilla runs alongside it.
## The exact API delta
Backward-compatible, additive-only.
### `field.playerSprites.surfPikachu`
New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
lands the ride keeps the Seel — no plain on-water Pikachu.
### `Player.surfPikachuSprite`
`Player.new` builds a second `SpriteRenderer` when the field resolves.
`pose()` picks it when `surfing and surfingPikachu`.
### `Player.surfingPikachu` (runtime)
Runtime-only boolean (not persisted); re-derived so a party change
between save and load is honored.
### `OverworldState:syncSurfingPikachu()`
Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
warpToHealPoint, forced-surf tile, setMap boot-restore.
### Importer — `SPRITE_SURFING_PIKACHU`
`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
each gain a parallel extract mirroring `RedBikeSprite`.
### `PaletteFX.spriteObp`
`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
special case, wearing the player's OBP palette so it colors in GBC mode.
## Migration note for existing mods
**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
only resolves on a Yellow import after regeneration. No manifest or
`mod.save` shape changes. An eligibility hook that swaps a rental
SURF-mon still drives the sprite pick via `partyKnows`.
## Parity tests
- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
`surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
`world & maps v2` suite stays green.
- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
+ `Player:pose` across four party shapes (12/12). The existing
`parity_cinnabar_east_surf.lua` (24/24) stays green.
## Deprecation etiquette
Nothing deprecated. Additive: a new `field.playerSprites` key, a new
runtime flag, a new engine method, a new sprite id.
+179
View File
@@ -0,0 +1,179 @@
# Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/launcher_skins_tab.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name.
## Formats
Two load. `skin.lua` wins when a folder has both.
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys:
| Key | Meaning |
| --- | --- |
| `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window |
| `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` |
| `overlayN_descM_overlay` | control art |
| `overlayN_descM_next_target` | page to switch to |
| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides |
| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach |
`x,y` is the centre and `range_x,range_y` are half extents, both normalized.
Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
control that holds both. A `nul` desc is decoration: it draws and never
captures a touch.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
image sits at the overlay opacity, and a pressed control's image swaps to
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
fades it out, and both directions read as a press animation.
**Native `skin.lua`.** This module's own model written back out: one Lua
table, no flat key space, and a separate `imagePressed` per control that a
`.cfg` cannot express. Loaded with an empty environment, so a skin authored by
a stranger cannot reach `love` or `io`. Sizes here are full width and height
rather than RetroArch's half extents, because that is what an editor's numeric
fields mean.
```lua
return {
name = "my_skin",
pages = {
{
name = "main",
image = "img/bezel.png",
fullScreen = true,
viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false },
controls = {
{ bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10,
shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" },
},
},
},
}
```
## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
`left`, `right`.
Engine hotkeys, handled in `Game:touchSkinHotkey`:
| Bind | Effect |
| --- | --- |
| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` |
| `hold_fast_forward`, `fast_forward` | fast forward while held |
| `toggle_fast_forward` | step the speed option |
| `reset` | soft reset to the title |
| `menu_toggle` | open OPTIONS |
`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no
handler yet: a control bound to them draws and does nothing. Anything else,
`rewind` included, is not in the bind table at all, so the control falls back
to decoration and never captures a touch.
As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey.
## The screen viewport
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is
parsed but does not stretch. `overlayN_viewport_expand = true` is an extension
that lets a widescreen bezel take the filling survey-zoom world view instead.
A viewport also implies the faithful-ratio lock. Without it the world pass
expands to fill the cutout and you get more map instead of a Game Boy screen.
Border art often ships with a transparent hole and no `viewport` key. **Detect
screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect.
## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
as well, where the touch overlay itself does not, and a gamepad does not hide
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
the launcher window while the Skins tab is open. A zip is mounted in place, so
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
(`overlay.cfg` is preferred when there are several) and the images it names.
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
| Skin | Source | Shape |
| --- | --- | --- |
| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages |
| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons |
Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a
real television: CC-BY-4.0 upstream, but treat it as a test asset rather than
shipping branding.
## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
that skin. Desktop only: the launcher does not offer it on Android or iOS,
because it wants a mouse, typed coordinates and room for an inspector.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor.
| Preset | Size |
| --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 |
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 |
| Ultrawide 21:9 | 2560x1080 |
| Super Game Boy border | 256x224 |
The Super Game Boy preset locks the viewport to the real screen window,
160x144 at (48,40), so an SGB border cannot be drawn out of register.
**Editing.** Click a control to select it, drag to move, eight handles to
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock. Drop a PNG
or JPG on the window to import art into the skin.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** packs it as one zip
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch.
## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
Image assignment cycles through art already in the skin folder; there is no
file browser, so new art arrives by drag and drop.
+79 -39
View File
@@ -1,38 +1,66 @@
# Build the Nintendo Switch NRO — contributor guide # Build Gen1Recomp for Nintendo Switch
Want to play a release build instead? Download the SD-ready zip and extract it Want to play a release build instead? Download the SD-ready zip and extract it
at your microSD root — see [switch-install.md](switch-install.md). at your microSD root. See [switch-install.md](switch-install.md).
This guide is for contributors who build Gen1Recomp for Switch from source. This guide is for contributors who build Gen1Recomp for Switch from source.
Hardware evidence, MTP operator loops, and deeper notes live in
[switch-development.md](switch-development.md).
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`; > Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware > Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
> evidence: **OLED** (author) and **V1 boot** (community). See > `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
> [switch-development.md](switch-development.md) for known limitations.
--- ---
## Prerequisites by OS ## Prerequisites by OS
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
WSL not cmd.exe or PowerShell (AD-008). WSL, not cmd.exe or PowerShell (AD-008).
### macOS / Linux ### macOS / Linux
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman). 1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
2. Install Switch tools: 2. Install Switch tools (**required for `--fused`**):
```sh ```sh
sudo dkp-pacman -S switch-dev sudo dkp-pacman -S switch-dev
``` ```
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under 3. OTA launcher toolchain, **native or Docker** (either is fine):
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused ```sh
builds can fall back to the pinned image when native tools are missing. bash scripts/switch/install_devkitpro_deps.sh # native
# or install Docker (same pin as fused builds)
```
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
### Native OTA launcher (included in `--fused`)
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
self-updater (`Check.lua`) is disabled on NX. Source:
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
```sh
make -C ports/switch/ota-launcher host-test
# or
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
```
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
required.** OTA launcher: native packages **or** Docker. Both are supported.
Release-like build from repo root:
```sh
scripts/build_switch.sh --fetch --fused --version X.Y.Z
```
See `ports/switch/ota-launcher/README.md` and
`scripts/switch/ota_launcher.manifest`.
### Windows (Git Bash / MSYS2 / WSL) ### Windows (Git Bash / MSYS2 / WSL)
@@ -42,16 +70,15 @@ builds can fall back to the pinned image when native tools are missing.
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or - **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or - **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
WSL if Docker bind-mounts from Git Bash paths misbehave. WSL if Docker bind-mounts from Git Bash paths misbehave.
2. Install `switch-dev` (or rely on Docker fallback see below). 2. Install `switch-dev` (or rely on Docker fallback; see below).
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell. 3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
### What you must install yourself ### What you must install yourself
| You install | Script does **not** install | | You install | Script does **not** install |
| ----------- | --------------------------- | | ----------- | --------------------------- |
| bash, git, zip tooling the repo already expects | | | bash, git, zip tooling the repo already expects | (none) |
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` | | `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
| Docker (optional fused fallback) | Docker Engine |
| A legal `.gb` ROM (to play) | Any ROM or game data | | A legal `.gb` ROM (to play) | Any ROM or game data |
--- ---
@@ -64,12 +91,12 @@ builds can fall back to the pinned image when native tools are missing.
| ---- | ------------ | | ---- | ------------ |
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. | | `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
| `--loose` | Packs `game.love`, copies pinned `love.nro``dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. | | `--loose` | Packs `game.love`, copies pinned `love.nro``dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. | | `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. |
Rules: Rules:
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`. - `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
- `--loose` and `--fused` are **XOR** — pick one packaging path per run. - `--loose` and `--fused` are **XOR**. Pick one packaging path per run.
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA). - `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
### What `--fetch` downloads ### What `--fetch` downloads
@@ -109,13 +136,15 @@ scripts/build_switch.sh --fetch
# Loose pair for iteration (fetch + assemble) # Loose pair for iteration (fetch + assemble)
scripts/build_switch.sh --fetch --loose scripts/build_switch.sh --fetch --loose
# Single fused NRO + SD-ready zip for a release-like artifact # Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact
scripts/build_switch.sh --fetch --fused --version 0.2.0 scripts/build_switch.sh --fetch --fused --version 0.2.0
``` ```
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode). Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip). `gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
(+ `.sha256` sidecar for the zip).
Offline packaging smoke (no network, no nacptool required): Offline packaging smoke (no network, no nacptool required):
@@ -143,7 +172,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`, `tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
or the Switch-related workflow YAML), CI runs: or the Switch-related workflow YAML), CI runs:
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo): 1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
`scripts/switch/selftest_build_switch.sh`, `scripts/switch/selftest_build_switch.sh`,
`scripts/switch/verify_payload.sh --self-test`, `scripts/switch/verify_payload.sh --self-test`,
`luajit tests/switch_ci_workflows_test.lua`, `luajit tests/switch_ci_workflows_test.lua`,
@@ -151,14 +180,10 @@ or the Switch-related workflow YAML), CI runs:
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`, headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
`luajit tests/engine/nx_generated_guard_test.lua`, `luajit tests/engine/nx_generated_guard_test.lua`,
`luajit tests/engine/nx_yellow_boot_test.lua`). `luajit tests/engine/nx_yellow_boot_test.lua`).
2. **Fused NRO build** only on the **canonical** repository 2. **Fused NRO build** only on the **main** repository
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner (`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow (`scripts/build_switch.sh --fetch --fused`), and only when the workflow
head is that repo (same-repo push/PR). **Fork repository** CI never runs head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
still runs) so untrusted head code is not executed on the self-hosted Mac;
iOS device build eligibility is unchanged. Fused also waits for a successful
offline selftest before starting on the Mac runner.
3. On successful PR fused builds, a follow-up workflow posts a PR comment 3. On successful PR fused builds, a follow-up workflow posts a PR comment
linking the Actions artifact named `gen1recomp-switch-nro` linking the Actions artifact named `gen1recomp-switch-nro`
(comment tag `switch-build-result`; see (comment tag `switch-build-result`; see
@@ -169,7 +194,7 @@ Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
### Release hard-fail (`.github/workflows/release.yml`) ### Release hard-fail (`.github/workflows/release.yml`)
GitHub Releases always build Switch on the same self-hosted Mac runner as the GitHub Releases always build Switch on the same self-hosted Mac runner as the
other platforms — this is a **hard gate** (no `continue-on-error`): other platforms. This is a **hard gate** (no `continue-on-error`):
```sh ```sh
scripts/build_switch.sh --fetch --fused --version "<release version>" scripts/build_switch.sh --fetch --fused --version "<release version>"
@@ -181,9 +206,26 @@ A Switch packaging failure fails the entire release job. The release asset is
### Runner provisioning ### Runner provisioning
The self-hosted Mac runner must have **native switch-tools** (`nacptool` / The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
`elf2nro`) **and/or Docker** available. CI and release do not silently run `--fused` preflight fails early with setup steps if it is missing.
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
**One-time setup on the runner** (if not already present):
```sh
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
sudo dkp-pacman -S switch-dev
export DEVKITPRO=/opt/devkitpro
export PATH="$DEVKITPRO/tools/bin:$PATH"
# OTA launcher: pick one
bash scripts/switch/install_devkitpro_deps.sh # native
# or ensure Docker is installed (same pin as fused builds)
```
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
requires DEVKITPRO and either native OTA packages or Docker. Without all of
that, the job fails with the setup steps above. Scripts never auto-run
`dkp-pacman -S` during CI.
--- ---
@@ -194,11 +236,9 @@ These scripts and this guide do **not**:
- Push files to the console (no automated MTP / FTP / SD scripting) - Push files to the console (no automated MTP / FTP / SD scripting)
- Bundle or download any Pokémon ROM - Bundle or download any Pokémon ROM
- Install `dkp-pacman` / `switch-dev` for you - Install `dkp-pacman` / `switch-dev` for you
- Provide `nxlink` / netloader deploy (deferred see [switch-transfer.md](switch-transfer.md)) - Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md))
- Validate **Applet Mode** — use title override (hold **R**) for full memory - Validate **Applet Mode**. Use title override (hold **R**) for full memory
Player install steps: [switch-install.md](switch-install.md). Player install steps: [switch-install.md](switch-install.md).
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows): Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
[switch-transfer.md](switch-transfer.md). [switch-transfer.md](switch-transfer.md).
Hardware depth and evidence: [switch-development.md](switch-development.md),
[switch-hardware-evidence.md](switch-hardware-evidence.md).
-528
View File
@@ -1,528 +0,0 @@
# Nintendo Switch development (love-nx)
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
> extract/merge at microSD root; title override required. See
> [Known limitations](#known-limitations-read-before-reviewing).
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
- Players → [switch-install.md](switch-install.md)
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
This document covers what landed, known limitations, how hardware was tested,
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
the transfer runbook).
## Acknowledgments
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
## Status
| Area | State |
| ---- | ----- |
| Feature | **Available** — playable fused NRO path (issue #531) |
| Runtime | Pinned love-nx **`11.5-nx1`** |
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
### What landed
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
- Payload gates so ROM / generated cache / saves never enter `game.love`
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
### Known gaps / welcome contributions
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
- Switch Lite and fuller Pro Controller / third-party pad matrices
- Applet Mode remains unsupported by design (title override required)
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
## Design references (Dusklight)
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
| Dusklight lesson | How Gen1Recomp applied it |
| ---------------- | ------------------------- |
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
| Prove the lower layer first | `tools/switch-probe` before full launcher |
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
| Full memory matters | Title override; Applet Mode is not the validation path |
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
| Isolate platform code | Capability module instead of Android flag overload |
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
The packaging goal matches Dusklights **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
## Known limitations (read before reviewing)
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
## How we tested
| Layer | What | Where |
| ----- | ---- | ----- |
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
## love-nx 11.5-nx1 (pinned)
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
**Local layout (not committed):**
```text
.bazinga/love-nx/11.5-nx1/
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
```
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
### Fetch instructions
Preferred (automated checksum verify):
```bash
scripts/build_switch.sh --fetch
```
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
[switch-build.md](switch-build.md) for the full mode glossary.
Manual fallback:
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
3. Move both files into that directory.
4. Confirm checksums match the manifest:
```bash
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
.bazinga/love-nx/11.5-nx1/love.elf
```
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
## Loose-mode dist layout
Development builds place `gen1recomp.nro` and `game.love` side by side:
```text
dist/switch/loose/
├── gen1recomp.nro
└── game.love
```
Assemble with:
```bash
scripts/build_switch.sh --loose
```
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
## Transfer & deploy (current contributor loop)
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
[switch-transfer.md](switch-transfer.md). Summary:
| Layer | Intent |
| ----- | ------ |
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
**Still avoided for routine evidence** (keeps SD handling honest):
- Treating `nxlink` / netloader as the release deploy story (deferred)
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
### Manual deploy checklist (today)
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
## OpenMTP + DBI transfer (loose build, Mac evidence example)
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
The numbered Mac loop below reproduces the OLED evidence path.
### On the Switch
1. Close Gen1Recomp if it is running.
2. Open **DBI** from hbmenu.
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
4. Keep DBI on that screen for the entire transfer.
5. Connect the Switch to the Mac with a USB-C data cable.
### On the Mac
1. Close any other MTP clients.
2. Open **OpenMTP** and select the DBI device.
3. In the remote pane, open **`1: SD Card`**.
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
5. Enter **`1: SD Card/switch/gen1recomp/`**.
6. Drag from the local pane:
```text
dist/switch/loose/gen1recomp.nro
dist/switch/loose/game.love
```
7. Wait for the OpenMTP queue to finish completely.
8. Refresh the remote listing and confirm file sizes match the local files.
9. On the Switch, exit MTP responder normally in DBI before launching the app.
Expected layout on SD:
```text
1: SD Card/
└── switch/
└── gen1recomp/
├── gen1recomp.nro
└── game.love
```
## Round-trip SHA-256 verification
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
1. **Before send** — record local hashes:
```bash
shasum -a 256 dist/switch/loose/gen1recomp.nro \
dist/switch/loose/game.love
```
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
3. **Compare** round-trip hashes:
```bash
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
dist/switch/mtp-roundtrip/game.love
```
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
Repeat whenever a cable glitch or interrupted transfer is suspected.
## Title override launch (full memory)
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
1. Confirm the OpenMTP transfer queue finished.
2. Exit MTP responder in DBI; disconnect USB if desired.
3. Hold **`R`** while launching any legitimately installed title.
4. Keep holding until **hbmenu** appears.
5. Confirm hbmenu does **not** show **Applet Mode**.
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
## Phase 0 hardware checklist
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
| Step | Action | Pass | Evidence / notes |
| ---- | ------ | ---- | ---------------- |
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
| P0-1c | Title override → launcher reaches import screen | yes | |
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
## Phase 0 test report template
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
```markdown
## Switch Phase 0 — hardware report
- Operator:
- Date:
- Console model:
- Atmosphère / HOS version:
- gen1recomp commit:
- love-nx tag: 11.5-nx1
- love.nro SHA-256 (local):
- game.love SHA-256 (local, pre-send):
- MTP round-trip SHA-256 (gen1recomp.nro):
- MTP round-trip SHA-256 (game.love):
- Title override used: yes / no
- Applet Mode observed: yes / no (should be no for P0)
- Probe getOS():
- Probe dimensions:
- Probe save directory shown:
- Gamepad events logged: yes / no
- Touch events logged: yes / no
- Unpatched launcher boot: pass / fail
- Joy-Con launcher navigation: pass / fail / not tested
- Notes:
```
## Fast dev loop (loose mode)
While iterating on Lua/assets:
1. Edit on Mac; run `scripts/test.sh --quick`.
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
3. Close Gen1Recomp on Switch.
4. DBI → `Run MTP responder`.
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
6. Replace **only** `game.love`; wait for queue + refresh listing.
7. Exit MTP responder; launch via title override.
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
```bash
scripts/test.sh --quick
scripts/build.sh mac --no-notarize
scripts/build_switch.sh --loose
shasum -a 256 .bazinga/work/game.love
```
## Controller input mapping (NX)
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
| Path | Control | Mapping |
| ---- | ------- | ------- |
| `gamepadpressed` | D-pad / left stick | move |
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
| `gamepadpressed` | `start` / `back` | Start / Select (+ / ) |
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start ( / +) |
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
## ROM inbox (NX)
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
| Already ready | Same SHA already imported → “No new ROM found.” |
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
## Mod zip inbox (NX)
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/mods/` |
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.zip` only |
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
## Save `.sav` inbox (NX)
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** folder |
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
## Joy-Con display chords (Select + face)
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / ) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
| Chord (Nintendo UX) | Engine key | Stock engine effect |
| ------------------- | ---------- | ------------------- |
| Select + **A** | `2` | COLORS cycle |
| Select + **B** | `3` | TILT / perspective |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
**OPTIONS → PERFORMANCE** clamps the ports own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
## Lua error log (save directory)
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
## Native crash triage (love-nx / Atmosphère)
love-nx native faults land under the consoles `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
```bash
# Example: aarch64-none-elf-addr2line from devkitPro
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
```
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operators hardware notes.
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
## P0 / P1 hardware matrix (ADR §9)
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
| ID | Requirement | Status | Evidence |
| -- | ----------- | ------ | -------- |
| P0-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
| P0-02 | MTP inbox import path shown | **pass** | T12 |
| P0-03 | Rescan imports ROM | **pass** | T12 |
| P0-04 | Canonical hash routes version | **pass** | T12 |
| P0-05 | Source dump retained in inbox | **pass** | T12 |
| P0-06 | Play reaches game after import | **pass** | T12 |
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
| P0-09 | Save survives quit + reopen | **pass** | T19 |
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
| P1-04 | Reboot persistence | **pass** | T19 |
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
## Review guidance
Maintainers may review as one PR or split later. Suggested slices (optional):
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
### Slice 1 — Platform + import (`platform/import`)
- `src/core/Platform.lua`, `conf.lua` NX branch
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
- Docs: inbox/MTP import sections only
### Slice 2 — Input + lifecycle (`input/lifecycle`)
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
- Tests: input/diagnostics suites
- Docs: controller mapping, suspend/audio notes
### Slice 3 — Build + docs (`build/docs`)
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
**Pre-merge checklist:**
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
- [ ] P0 matrix rows marked pass only with linked hardware evidence
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
-187
View File
@@ -1,187 +0,0 @@
# Switch hardware evidence (Phase 0 + import + input)
> **Hardware evidence log.** Author passes below were recorded on **one
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
> separate community row records Switch V1 / Erista boot. These rows do
> **not** claim Lite, docked soak, or automated install. See
> `docs/switch-development.md` for status and limitations.
**love-nx:** `11.5-nx1`
**Author console:** Switch OLED
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
**Date (author rows):** 2026-08-01
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
---
## Community — Switch V1 / Erista boot — pass (boot)
| Field | Value |
| ----- | ----- |
| Console | Nintendo Switch V1 (Erista) |
| Check | Prebuilt fused NRO boots under title override |
| Tester | [booshankles](https://github.com/booshankles) |
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
---
## Phase 0 — probe (T4) — pass
| Field | Value |
| ----- | ----- |
| Commit (import era) | `df7cea4` |
| `getOS()` / `love._os` | `NX` |
| Dimensions | 1280×720 |
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
| Artifact | SHA-256 |
| -------- | ------- |
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
---
## T12 — Red import + Play — pass
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
---
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
### Round 1 @ `7504753` — partial
| Check | Result |
| ----- | ------ |
| Launcher / overworld (Joy-Con only) | **pass** |
| Naming player/rival | **fail** (dual-path a+b; see below) |
| Touch required | **no** |
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
### Round 2 @ `2699c9a` — pass (Nintendo UX)
| Field | Value |
| ----- | ----- |
| Commit tested | `2699c9a` |
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
| Touch required | **no** |
| Check | Result |
| ----- | ------ |
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
| Naming — rival | **pass** (same) |
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
T16 hardware gate: **closed**.
---
## T19 — save / suspend — pass
| Check | Result |
| ----- | ------ |
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
| Full console reboot persistence | **pass** (operator 2026-08-01) |
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
---
## T24 — fused NRO alone + NRO-only update — **pass**
| Field | Value |
| ----- | ----- |
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
| Deploy | isolated folder, no adjacent `game.love` |
| Boot fused | **pass** |
| ROM import | **pass** |
| Play **Red** | **pass** |
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
| NRO-only replace | **pass** — saves retained; app still boots/plays |
| Touch required | no |
T24 hardware gate: **closed**.
---
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
| Field | Value |
| ----- | ----- |
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
| Host | macOS + native switch-tools (or Docker fallback if used) |
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
| love-nx | `11.5-nx1` (manifest checksums match) |
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
| Console | Switch OLED |
| Operator | Andrew |
| Date | 2026-08-01 |
| Check | Result |
| ----- | ------ |
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
| Copy NRO to SD folder per install doc | **pass** (operator) |
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
---
## NXMOD-12 — Community mod zip OLED smoke — **pass**
Closed from existing OLED photo evidence on issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
with launcher MODS + overworld shots). Photos live on the orphan branch
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
of the operator fork — **not** committed to this repo. Do **not** commit
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
this entry only proves the MODS inbox + Play path on OLED.
| Field | Value |
| ----- | ----- |
| Status | **pass** |
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
| love-nx tag | `11.5-nx1` |
| Console | Switch OLED |
| Mod | community release `.zip` (not vendored; not named here) |
| Zip committed to git? | **no** |
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
| Operator | Andrew |
| Date | 2026-08-01 |
### Checklist
| Step | Pass / fail / pending | Notes |
| ---- | --------------------- | ----- |
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
### Evidence notes
```text
Operator: Andrew
Date: 2026-08-01
Commit tested: feat/switch-nx era documented on issue #531
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
```
+65 -25
View File
@@ -2,15 +2,11 @@
Every GitHub Release that includes Switch support ships an SD-ready zip: Every GitHub Release that includes Switch support ships an SD-ready zip:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install `gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
**or** update same steps), launch with **title override**, then import your or update, same steps), launch with **title override**, then import your
own legal `.gb` ROM. own legal `.gb` ROM.
> You need a console that can run Switch homebrew (custom firmware / hbmenu). > You need a console that can run Switch homebrew (custom firmware / hbmenu).
> This project does not help you set that up. Tracks issue > This project does not help you set that up.
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
> by the community. Lite and other setups welcome more reports.
> See [switch-development.md](switch-development.md) for limitations.
Prefer building from source? See [switch-build.md](switch-build.md). Prefer building from source? See [switch-build.md](switch-build.md).
@@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles).
Extract the zip at the **root** of the microSD so you get: Extract the zip at the **root** of the microSD so you get:
```text ```text
sdmc:/switch/gen1recomp/gen1recomp.nro sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
sdmc:/switch/gen1recomp/version.txt
sdmc:/switch/gen1recomp/pokemon-love2d/imports/ sdmc:/switch/gen1recomp/pokemon-love2d/imports/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/ sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/... sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
``` ```
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
launcher).
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**. responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
@@ -42,17 +44,44 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
### Updating ### Updating
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small #### Native OTA launcher (in-console)
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
options live under `pokemon-love2d/`**do not delete that folder** when Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
updating, or you will lose progress. LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
When a newer release exists, the launcher downloads the same install zip
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
If you are up to date or offline, it skips straight to the game with no
prompt. If an update is available, you get a short prompt styled like the
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
wire format.
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
the installed forwarder at creation time. After an OTA (or zip) update, the
`.nro` on the microSD already has the new version, but the HOME shortcut
keeps the old badge until you **reinstall the forwarder once** in Sphaira
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
hbmenu always shows the live file version.
#### Manual zip (fallback)
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves,
imported ROMs, mods, and options live under `pokemon-love2d/`. **Do not
delete that folder** when updating, or you will lose progress.
## 3. Launch with title override ## 3. Launch with title override
**Applet Mode is not supported** for this game (not enough memory). **Applet Mode is not supported** for this game (not enough memory).
1. On the Switch HOME menu, highlight any installed title. 1. On the Switch HOME menu, highlight any installed title.
2. Hold **R** and launch that title — this opens hbmenu with full memory 2. Hold **R** and launch that title. This opens hbmenu with full memory
(title override). (title override).
3. From hbmenu, open `gen1recomp`. 3. From hbmenu, open `gen1recomp`.
@@ -66,14 +95,14 @@ This project ships **no** game data. On first launch:
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the launcher also shows the live save-dir path). All three can sit in the
same folder. same folder.
2. Use **Scan again** on that games tab (Red / Blue / Yellow). Rescan 2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only — a Red dump never imports matches by ROM SHA-1 for the open tab only. A Red dump never imports
from the Yellow tab (and vice versa). from the Yellow tab (and vice versa).
## 5. Import / Export a raw `.sav` ## 5. Import / Export a raw `.sav`
Continue a cart or PC battery save (or pull a slot off-console) via MTP / Continue a cart or PC battery save (or pull a slot off-console) via MTP /
SD / FTP same transfer methods as ROMs. Paths are **per game**: SD / FTP, same transfer methods as ROMs. Paths are **per game**:
| Game | Import inbox | Export folder | | Game | Import inbox | Export folder |
| ---- | ------------ | ------------- | | ---- | ------------ | ------------- |
@@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**:
| Blue | `imports/saves/blue/` | `exports/blue/` | | Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` | | Yellow | `imports/saves/yellow/` | `exports/yellow/` |
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.) (Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir 1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
([switch-transfer.md](switch-transfer.md)). ([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab** 2. With the game's ROM already imported, open **that game's tab**
**SAVE FILES****Import save**. Only that folder is scanned. **SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its 3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots. content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place. Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file 4. To pull a slot off the console, use **Export save**, then copy the file
from that games **`exports/<game>/`** folder via MTP / SD / FTP. from that game's **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients Do not put `.sav` files into git. Prefer clean copies. Some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves. create `._*.sav` AppleDouble sidecars that are not real saves.
## Controls ## Controls
@@ -130,12 +159,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves.
Mods install from a zip inbox (same transfer methods as ROMs): Mods install from a zip inbox (same transfer methods as ROMs):
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the 1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
launcher shows (MTP / SD / FTP [switch-transfer.md](switch-transfer.md)). launcher shows (MTP / SD / FTP. See [switch-transfer.md](switch-transfer.md)).
2. In the launcher, open **MODS****Scan again** → enable the mod → 2. In the launcher, open **MODS****Scan again** → enable the mod →
**Play**. **Play**.
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port mod zips into git. Community mods ship their own OPTIONS / rebinds. This port
does not document third-party control tables. does not document third-party control tables.
### Joy-Con shortcuts (Select + face) ### Joy-Con shortcuts (Select + face)
@@ -153,12 +182,23 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) | | Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE**
`LOW` or `BALANCED`. Full chord notes for contributors: `LOW` or `BALANCED`.
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
## Limitations
- You need homebrew (custom firmware, hbmenu). This project does not set that
up.
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
not supported. The game needs full memory.
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
is no automated deploy.
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
**FIND MODS** stay off on Switch.
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
Other models may work but are less tested.
## Prefer building it yourself? ## Prefer building it yourself?
Building the fused NRO (and SD-ready zip) from source is covered in Building the fused NRO (and SD-ready zip) from source is covered in
[switch-build.md](switch-build.md). Copying artifacts and inbox files [switch-build.md](switch-build.md). Copying artifacts and inbox files
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md). (MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
+19 -22
View File
@@ -1,17 +1,16 @@
# Switch file transfer (MTP / SD / FTP) # Switch file transfer (MTP / SD / FTP)
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Switch.
Switch. **Any method is valid** if the bytes land in the destinations below. **Any method is valid** if the bytes land in the destinations below.
This is the home runbook for contributors on **macOS, Linux, and Windows**. This is the runbook for contributors on **macOS, Linux, and Windows**.
Player install (what to download, title override) stays in Player install (what to download, title override) stays in
[switch-install.md](switch-install.md). Packaging stays in [switch-install.md](switch-install.md). Packaging stays in
[switch-build.md](switch-build.md). Hardware evidence lives in [switch-build.md](switch-build.md).
[switch-hardware-evidence.md](switch-hardware-evidence.md).
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later > **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat > for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as
> netloader as the release or ROM/mod install path. > the release or ROM/mod install path.
--- ---
@@ -23,7 +22,7 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it | | Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) | | ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** | | Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** | | Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) | | Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` | | Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir | | Lua error log | `lua-error.log` in the save dir |
@@ -34,7 +33,7 @@ files, or third-party mod zips to git.
--- ---
## Canonical methods ## Transfer methods
### 1. MTP (DBI responder + host client) ### 1. MTP (DBI responder + host client)
@@ -47,8 +46,8 @@ before launching.
#### macOS (example: OpenMTP) #### macOS (example: OpenMTP)
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED [OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
hardware evidence — **one contributor example**, not a Mac-only product rule. macOS, not a Mac-only requirement.
1. Quit other MTP clients. 1. Quit other MTP clients.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**. 2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
@@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
5. Wait for the queue; refresh; exit MTP responder; title-override launch. 5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`, macOS clients often create AppleDouble sidecars (`._Something.zip`,
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the `._cart.gb`, `._foo.sav`). Those are not real archives or saves. The
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav` launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
fails to open. fails to open.
#### Linux #### Linux
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK 1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
desktops, or your distros KDE MTP stack). desktops, or your distro's KDE MTP stack).
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select 2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
the Switch / DBI device → **`1: SD Card`**. the Switch / DBI device → **`1: SD Card`**.
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/` 3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
and the save-dir inboxes as above. and the save-dir inboxes as above.
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports 4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
device is busy, close the file managers MTP mount (or the CLI mount) "device is busy", close the file manager's MTP mount (or the CLI mount)
and retry with a single client. and retry with a single client.
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch. 5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above. card reader) or **FTP** instead. Same destinations in the table above.
#### Windows #### Windows
@@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above.
5. Safely disconnect; exit MTP on the Switch; title-override launch. 5. Safely disconnect; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above. card reader) or **FTP** instead. Same destinations in the table above.
### 2. Direct SD (Hekate UMS or card reader) ### 2. Direct SD (Hekate UMS or card reader)
@@ -109,9 +108,9 @@ Do not yank the card or unplug UMS mid-write.
### 3. FTP (any SD-exposing Switch FTP) ### 3. FTP (any SD-exposing Switch FTP)
Any homebrew FTP server that can write the microSD is fine — for example Any homebrew FTP server that can write the microSD is fine. For example
**DBIs own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations **DBI's own FTP**, **sys-ftpd-light**, or **Sphaira**. Names are illustrations
only; pick what your CFW setup already uses). only; pick what your CFW setup already uses.
1. Start the FTP server on the Switch; note IP/port/credentials from that app. 1. Start the FTP server on the Switch; note IP/port/credentials from that app.
2. From the host, connect with any FTP client and upload to the same 2. From the host, connect with any FTP client and upload to the same
@@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
| Symptom | What to try | | Symptom | What to try |
| ------- | ----------- | | ------- | ----------- |
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) | | Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
| Zip/ROM/`.sav` could not be opened | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` | | Zip/ROM/`.sav` "could not be opened" | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch | | Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
| App opens in Applet Mode | Use title override (hold **R**), not Album | | App opens in Applet Mode | Use title override (hold **R**), not Album |
@@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
- Players: [switch-install.md](switch-install.md) - Players: [switch-install.md](switch-install.md)
- Builders: [switch-build.md](switch-build.md) - Builders: [switch-build.md](switch-build.md)
- Status / hardware matrix: [switch-development.md](switch-development.md)
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
-65
View File
@@ -1,65 +0,0 @@
# Tiled map editing (mod authoring)
`tools/tiled_export.py` turns the imported ROM cache into a
[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a
real map editor and exported back out as a mod. The original had no map
editor at all; the port's own map data is plain Lua, which is what makes
this a data path rather than an asset path.
Editing is done in our own Tiled build,
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
which ships the `gen1-mod-export` extension the workspace relies on. Grab it
from that repo's releases; upstream Tiled opens the workspace but cannot
export a mod out of it.
```sh
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
```
Then open `build/tiled/gen1.tiled-project` in that build of Tiled.
- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and
`kanto.world` places the 36 connected overworld maps at their real
connection offsets. That world is pre-loaded (seeded into the workspace's
Tiled session), so opening any one overworld map draws its neighbors around
it and you scroll and edit straight across the seams. Everything else is a
double-click away in Tiled's project panel.
- **Extending Kanto wires both ends.** A connection lives on both maps, so
hooking a new map onto a base map also emits the return connection as a
patch on that base map, keeping its other directions intact. The return
offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy
`back.offset == -offset`.
- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled
tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet,
so a tile layer *is* the map's `blocks` array. Warps, signs and objects
sit on the 16px cell grid in object layers, which is the grid the engine
addresses them on.
- **Collision is visible.** View > Show Tile Collision Shapes draws the real
walkability: a rectangle covers each cell whose feet tile is not in the
tileset's `walkable` list, which is the rule `src/world/Map.lua` applies.
- **Maps are shown in their real colors.** Each map is atlased in the SGB
palette it renders with, so Cerulean is blue and Lavender is purple in the
editor exactly as in game. Vanilla resolves that through a cascade with
interiors inheriting the last outdoor map, so the workspace mirrors the
cascade and walks the warp graph to colour interiors. Changing a map's
`palette` exports `palette = "..."` on the record, which beats the cascade,
and the editor offers the real palette names as a dropdown.
- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks
as raw 8x8 tiles, four by four, so new blocks can be composed there;
per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`,
`doorTiles` and the rest.
- **Export is a diff, not a fork of the data.** The `gen1-mod-export`
extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole
loadable mod folder. An edited vanilla map diffs against the imported data
and emits `mod.content.maps:patch` carrying *only* the fields that moved, so
a mod covers the parts it changes and leaves the rest to the base game; a
new map gets `:register` at an index of 1000 or above. An unchanged map
exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`.
- **Or the whole record, on request.** Ticking `exactExport` on a map switches
it to `mod.content.maps:override`, pinning the map to exactly what the
editor shows. It is off by default because an override wins outright over
any other mod patching that map, where a patch composes.
No ROM-derived art travels into an exported mod: a tileset still drawing on
the player's own imported sheet references that path rather than shipping the
pixels, and only a sheet the author supplied is copied in.
+31 -6
View File
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
## Version.lua fields ## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the `src/core/Version.lua` carries four fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched): untouched):
@@ -37,6 +37,11 @@ untouched):
as a valid payload to chainload). as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable - `shell` - the native-shell contract this build's fused executable
implements. implements.
- `payloadHost` - the native host family an in-place payload targets. Ordinary
LÖVE packages use `"love"`. A specialized native package uses a distinct,
stable identifier and accepts only payloads carrying that same identifier.
A missing field defaults to `"love"`, preserving compatibility with payloads
released before this field existed.
- `minShell` - the lowest shell contract required to *run* this payload. - `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped Bump `minShell` only when a payload needs something the currently-shipped
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
installer instead. Do not bump `minShell` for an ordinary Lua/data release; installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for. that is exactly the case the updater exists to avoid a reinstall for.
Change `payloadHost` only when the packaged Lua depends on a different native
host family. This is separate from `minShell`: the host name answers *which*
native integration the payload targets, while the shell number answers *which
revision* of that integration it requires. A mismatched-host payload is never
mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets ## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives Each tagged release `vX.Y.Z` carries the existing per-platform archives
@@ -109,18 +120,32 @@ bundled game, in that case.
## Known limitations ## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the - **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop global `love.run` from the payload's `main.lua` does not affect the loop
already driving the frame. A payload that must change `love.run` itself already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior. than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua` - **Android and iOS use the native download bridge, not curl.** Neither
shells out to curl for both the release check and the download; curl is platform ships curl, so the old `check_worker.lua` path (shell out to curl)
absent on Android, so `Check` degrades to `status = "error"` there (the always landed on `error` and the launcher chip's "Check for updates" tap
launcher UI hides on that status) and the player is directed to the was a no-op. The worker now talks through `HostShell`, the same transport
releases page via `Check.releaseUrl()` instead. as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
APK or IPA can therefore check GitHub and fetch the `.love` payload
in-app. If neither transport exists, the worker reports `needs_full` and
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when - **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the `love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source `"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo. checkout is always "the game" itself; updating it means pulling the repo.
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
download `.love` payloads. In-console OTA uses the **native OTA launcher**
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# After first boot of a compatible Linux ARM handheld (or when PortMaster is installed), reinsert the
# SD card and run this to install gen1recomp-sbc + Red/Blue ROMs into Roms/PORTS.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$ROOT/.bazinga/work/linux-arm-sbc-install"
DECPREP="${DECPREP:-$ROOT/../decprep}"
ZIP="$ROOT/dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Find a mounted handheld userdata volume with a ROMs or Apps directory.
find_roms_root() {
local v candidate
for v in /Volumes/*; do
[ -d "$v" ] || continue
# Prefer a volume that already has Roms/ or Apps/
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ] || [ -d "$v/Apps" ]; then
echo "$v"
return 0
fi
done
# Fallback: common removable-volume labels
for v in /Volumes/SDCARD /Volumes/sdcard /Volumes/NO\ NAME /Volumes/ROMS; do
if [ -d "$v" ]; then
echo "$v"
return 0
fi
done
return 1
}
say "looking for handheld SD volume"
ROMS_ROOT="$(find_roms_root)" || fail "no SD volume mounted. boot the handheld once, power it off, reinsert the SD, then rerun."
say "using: $ROMS_ROOT"
# Resolve the device PortMaster ports directory
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
PORTS="$ROMS_ROOT/Roms/PORTS"
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
PORTS="$ROMS_ROOT/roms/PORTS"
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
PORTS="$ROMS_ROOT/Roms/ports"
elif [ -d "$ROMS_ROOT/PORTS" ]; then
PORTS="$ROMS_ROOT/PORTS"
else
mkdir -p "$ROMS_ROOT/Roms/PORTS"
PORTS="$ROMS_ROOT/Roms/PORTS"
fi
say "PORTS: $PORTS"
# Refresh staged payload
mkdir -p "$STAGE/PORTS"
if [ -f "$ZIP" ]; then
rm -rf "$STAGE/PORTS/gen1recomp-sbc.sh" "$STAGE/PORTS/gen1recomp-sbc" "$STAGE/PORTS/port.json" \
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
else
fail "missing $ZIP — run ./build-linux-arm-sbc.sh first"
fi
# Ensure ROMs are in lovegame (Choose ROM scans this folder on minimal images)
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
say "copying gen1recomp port"
rm -rf "$PORTS/gen1recomp-sbc" "$PORTS/gen1recomp-sbc.sh"
cp -R "$STAGE/PORTS/gen1recomp-sbc" "$PORTS/"
cp -f "$STAGE/PORTS/gen1recomp-sbc.sh" "$PORTS/"
cp -f "$STAGE/PORTS/port.json" "$PORTS/"
cp -f "$STAGE/PORTS/README.md" "$PORTS/"
chmod +x "$PORTS/gen1recomp-sbc.sh" "$PORTS/gen1recomp-sbc/bin/love.aarch64"
# Also drop carts in the stock GB folder for the emulator library
GB_DIR=""
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
done
if [ -n "$GB_DIR" ]; then
say "copying .gb into $GB_DIR"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
fi
sync
say "installed:"
ls -lh "$PORTS/gen1recomp-sbc.sh"
ls -lh "$PORTS/gen1recomp-sbc/lovegame/"*.gb
say "eject the SD, insert it in the handheld, open Ports → gen1recomp-sbc, Choose ROM."
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Mike Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
-188
View File
@@ -1,188 +0,0 @@
-- modules/Behavior.lua
--
-- Base module for the pluggable behavior system that drives the Behavior &
-- Mode Unification refactor.
--
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
-- hard-coded in Element.lua.
--
-- Element.new iterates a registry of behavior prototypes and auto-attaches
-- whichever return true from `shouldAttach(props)`. Element therefore never
-- needs to know what an individual behavior does — only that it conforms to
-- this interface.
--
-- Design constraints (locked — tasks 02-13 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
-- be mirrored by every concrete behavior.
-- * Immutable instances — behavior tables are produced once and treated as
-- read-only. Per-element runtime state lives on the element (or a subsystem
-- the behavior attaches), NEVER on the behavior instance itself, so a single
-- behavior instance can be shared across many elements.
--
-- Lifecycle hook contract (each receives the owning element as first argument):
-- onAttach(element) — called once when the behavior is attached
-- (element fully constructed). Allocate
-- subsystems / register listeners here.
-- onDetach(element) — called once when the behavior is detached
-- (element destroyed / mode switch). Tear
-- down anything onAttach created.
-- onUpdate(element, dt) — called every frame from Element:update.
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
-- is the draw context (viewport transform,
-- scissor state, theme renderer, ...).
-- saveState(element) -> state — called during Element save-state; returns
-- a serializable snapshot (or nil) so the
-- behavior's runtime state survives the
-- immediate-mode recreation cycle.
-- restoreState(element, state) — called after reconstruction with the
-- snapshot previously returned by saveState.
--
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
-- an element's props table, return true if
-- this behavior should be auto-attached.
-- Defaults to false (opt-in).
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
--- predicate. All hooks are always present (custom override or no-op default).
---@class Behavior
---@field onAttach fun(element:table)
---@field onDetach fun(element:table)
---@field onUpdate fun(element:table, dt:number)
---@field onDraw fun(element:table, ctx:table)
---@field saveState fun(element:table):any
---@field restoreState fun(element:table, state:any)
---@field shouldAttach fun(props:table):boolean
local Behavior = {}
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
Behavior.HOOK_NAMES = {
"onAttach",
"onDetach",
"onUpdate",
"onDraw",
"saveState",
"restoreState",
}
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
-- (drawLayer) may hold any value.
local ALLOWED_KEYS = {
onAttach = true,
onDetach = true,
onUpdate = true,
onDraw = true,
saveState = true,
restoreState = true,
shouldAttach = true,
drawLayer = true,
}
-- Spec keys whose values are NOT required to be functions (passive metadata
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
local NON_FUNCTION_KEYS = {
drawLayer = true,
}
-- Default no-op hook. Behaviors override only the hooks they need; every other
-- hook resolves to this so dispatch sites never have to nil-check.
local function noop() end
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
-- by providing its own predicate. This is the safe default — a behavior with no
-- opinion about which elements it applies to stays inert in the auto-attach
-- pass (it can still be attached explicitly by name in a later task).
local function defaultShouldAttach()
return false
end
-- Module-level default predicate exposed for callers/tests that want to
-- reference the base default directly without constructing an instance.
Behavior.shouldAttach = defaultShouldAttach
--- Factory: create a frozen behavior instance from a spec table.
---
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
--- plus `shouldAttach`; each value (when present) must be a function. The
--- returned table contains every lifecycle hook (custom override OR no-op) and
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
--- sites can call any hook unconditionally without nil-checking.
---
--- Unknown spec keys and non-function values raise an error immediately so
--- mistakes fail fast at construction rather than as silent no-ops later.
---
---@param spec table|nil spec table overriding select hooks / shouldAttach
---@return Behavior
function Behavior.new(spec)
spec = spec or {}
-- Validate spec keys up front so typos surface here, not as silent no-ops.
for key, value in pairs(spec) do
if not ALLOWED_KEYS[key] then
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
end
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
end
end
local instance = {}
-- Populate every lifecycle hook: custom override when provided, no-op default
-- otherwise. Guarantees `instance.hook` is always callable.
for _, hook in ipairs(Behavior.HOOK_NAMES) do
instance[hook] = spec[hook] or noop
end
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
instance.drawLayer = spec.drawLayer
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
-- objects; runtime state belongs on the element, never on the behavior.
-- (Reassigning an existing hook is still possible via direct index write —
-- Lua metatables cannot intercept that — but the freeze communicates intent
-- and catches accidental field additions.)
local mt = {
__newindex = function(_, key)
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
end,
--- Mark the metatable so consumers can detect a Behavior instance.
---@return string
__tostring = function()
return "Behavior"
end,
__metatable = "Behavior",
}
setmetatable(instance, mt)
return instance
end
--- Type guard: returns true if `value` is a Behavior instance produced by
--- `Behavior.new`. Used by Element's attach path to validate registry entries
--- without depending on identity.
---@param value any
---@return boolean
function Behavior.isBehavior(value)
return type(value) == "table" and getmetatable(value) == "Behavior"
end
return Behavior
-686
View File
@@ -1,686 +0,0 @@
-- Lua 5.2+ compatibility for unpack
local unpack = table.unpack or unpack
-- Warning cache to prevent duplicate warnings for the same element
local warningCache = {}
local Cache = {
canvases = {},
quads = {},
blurInstances = {}, -- Cache blur instances by quality
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
MAX_CANVAS_SIZE = 20,
MAX_QUAD_SIZE = 20,
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
}
--- Round canvas size to nearest bucket for better reuse
---@param size number Size to bucket
---@return number bucketSize Bucketed size
local function bucketSize(size)
if size <= 128 then
return math.ceil(size / 32) * 32
elseif size <= 512 then
return math.ceil(size / 64) * 64
elseif size <= 1024 then
return math.ceil(size / 128) * 128
else
return math.ceil(size / 256) * 256
end
end
--- Get or create a canvas from cache
---@param width number Canvas width
---@param height number Canvas height
---@return love.Canvas canvas The cached or new canvas
function Cache.getCanvas(width, height)
-- Use bucketed sizes for better cache reuse
local bucketedWidth = bucketSize(width)
local bucketedHeight = bucketSize(height)
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
if not Cache.canvases[key] then
Cache.canvases[key] = {}
end
local cache = Cache.canvases[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.canvas
end
end
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
table.insert(cache, { canvas = canvas, inUse = true })
if #cache > Cache.MAX_CANVAS_SIZE then
local removed = table.remove(cache, 1)
if removed and removed.canvas then
removed.canvas:release()
end
end
return canvas
end
--- Release a canvas back to the cache
---@param canvas love.Canvas Canvas to release
function Cache.releaseCanvas(canvas)
for _, sizeCache in pairs(Cache.canvases) do
for _, entry in ipairs(sizeCache) do
if entry.canvas == canvas then
entry.inUse = false
return
end
end
end
end
--- Get or create a quad from cache
---@param x number X position
---@param y number Y position
---@param width number Quad width
---@param height number Quad height
---@param sw number Source width
---@param sh number Source height
---@return love.Quad quad The cached or new quad
function Cache.getQuad(x, y, width, height, sw, sh)
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
if not Cache.quads[key] then
Cache.quads[key] = {}
end
local cache = Cache.quads[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.quad
end
end
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
table.insert(cache, { quad = quad, inUse = true })
if #cache > Cache.MAX_QUAD_SIZE then
table.remove(cache, 1)
end
return quad
end
--- Release a quad back to the cache
---@param quad love.Quad Quad to release
function Cache.releaseQuad(quad)
for _, keyCache in pairs(Cache.quads) do
for _, entry in ipairs(keyCache) do
if entry.quad == quad then
entry.inUse = false
return
end
end
end
end
--- Generate cache key for blurred canvas
---@param elementId string Element ID
---@param x number X position
---@param y number Y position
---@param width number Width
---@param height number Height
---@param radius number Blur radius
---@param quality number Blur quality
---@param isBackdrop boolean Whether this is backdrop blur
---@return string key Cache key
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
return string.format(
"%s:%d:%d:%d:%d:%.1f:%d:%s",
elementId,
x,
y,
width,
height,
radius,
quality,
tostring(isBackdrop)
)
end
--- Get cached blurred canvas
---@param key string Cache key
---@return love.Canvas|nil canvas Cached canvas or nil
function Cache.getBlurredCanvas(key)
local entry = Cache.blurredCanvases[key]
if entry then
entry.lastUsed = os.time()
return entry.canvas
end
return nil
end
--- Store blurred canvas in cache
---@param key string Cache key
---@param canvas love.Canvas Canvas to cache
function Cache.setBlurredCanvas(key, canvas)
-- Limit cache size
local count = 0
for _ in pairs(Cache.blurredCanvases) do
count = count + 1
end
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
-- Remove oldest entry
local oldestKey = nil
local oldestTime = math.huge
for k, v in pairs(Cache.blurredCanvases) do
if v.lastUsed < oldestTime then
oldestTime = v.lastUsed
oldestKey = k
end
end
if oldestKey then
if Cache.blurredCanvases[oldestKey].canvas then
Cache.blurredCanvases[oldestKey].canvas:release()
end
Cache.blurredCanvases[oldestKey] = nil
end
end
Cache.blurredCanvases[key] = {
canvas = canvas,
lastUsed = os.time(),
}
end
--- Clear blurred canvas cache for specific element
---@param elementId string Element ID to clear cache for
function Cache.clearBlurredCanvasesForElement(elementId)
for key, entry in pairs(Cache.blurredCanvases) do
if key:match("^" .. elementId .. ":") then
if entry.canvas then
entry.canvas:release()
end
Cache.blurredCanvases[key] = nil
end
end
end
--- Clear all caches
function Cache.clear()
-- Release all blurred canvases
for _, entry in pairs(Cache.blurredCanvases) do
if entry.canvas then
entry.canvas:release()
end
end
Cache.canvases = {}
Cache.quads = {}
Cache.blurInstances = {}
Cache.blurredCanvases = {}
warningCache = {} -- Clear warning cache on cache clear
end
-- ============================================================================
-- SHADER BUILDER
-- ============================================================================
local ShaderBuilder = {}
--- Build Gaussian blur shader with given parameters
---@param taps number Number of samples (must be odd, >= 3)
---@param offset number Offset value
---@param offsetType string "weighted" or "center"
---@param sigma number Sigma value for Gaussian distribution
---@return love.Shader shader The compiled blur shader
function ShaderBuilder.build(taps, offset, offsetType, sigma)
taps = math.floor(taps)
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
sigma = math.max(sigma, 1)
local steps = (taps + 1) / 2
local gOffsets = {}
local gWeights = {}
for i = 1, steps do
gOffsets[i] = offset * (i - 1)
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
end
local offsets = {}
local weights = {}
for i = #gWeights, 2, -2 do
local oA, oB = gOffsets[i], gOffsets[i - 1]
local wA, wB = gWeights[i], gWeights[i - 1]
wB = oB == 0 and wB / 2 or wB
local weight = wA + wB
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
weights[#weights + 1] = weight
end
local code = {
[[
extern vec2 direction;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
}
local norm = 0
if #gWeights % 2 == 0 then
code[#code + 1] = "vec4 c = vec4( 0.0 );"
else
local weight = gWeights[1]
norm = norm + weight
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
end
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
for i = 1, #offsets do
local offset = offsets[i]
local weight = weights[i]
norm = norm + weight * 2
code[#code + 1] = string.format(template, weight, offset, offset)
end
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
local shaderCode = table.concat(code)
return love.graphics.newShader(shaderCode)
end
--- Get or create a blur instance from cache
---@param quality number Quality level (1-10)
---@return table blurData Cached blur data {shader, taps}
function Cache.getBlurInstance(quality)
if not Cache.blurInstances[quality] then
local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps)
if taps % 2 == 0 then
taps = taps + 1
end
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
Cache.blurInstances[quality] = {
shader = shader,
taps = taps,
}
end
return Cache.blurInstances[quality]
end
---@class BlurProps
---@field quality number? Quality level (1-10, default: 5)
---@class Blur
---@field shader love.Shader The blur shader
---@field quality number Quality level (1-10)
---@field taps number Number of shader taps
---@field _ErrorHandler table? Reference to ErrorHandler module
local Blur = {}
Blur.__index = Blur
--- Check if we should warn about large blur area in immediate mode
---@param elementId string|nil Element ID for caching warnings
---@param width number Blur area width
---@param height number Blur area height
---@param blurType string "content" or "backdrop"
local function checkLargeBlurWarning(elementId, width, height, blurType)
-- Skip if no ErrorHandler available
if not Blur._ErrorHandler then
return
end
-- Skip if not in immediate mode
if not Blur._blurOptimizations then
return
end
-- Calculate blur area
local area = width * height
-- Skip if area is below threshold
if area <= Cache.LARGE_BLUR_THRESHOLD then
return
end
-- Generate warning key (use elementId if available, otherwise use dimensions)
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
-- Skip if already warned for this element/area
if warningCache[warningKey] then
return
end
-- Mark as warned
warningCache[warningKey] = true
-- Issue warning
local message =
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
local suggestion =
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
Blur._ErrorHandler:warn("Blur", "PERF_003", {
area = string.format("%.0fx%.0f", width or 0, height or 0),
})
end
--- Create a new blur effect instance
---@param props BlurProps? Blur configuration
---@return Blur blur The new blur instance
function Blur.new(props)
props = props or {}
local quality = props.quality or 5
quality = math.max(1, math.min(10, quality))
-- Get cached blur instance for this quality level
local blurData = Cache.getBlurInstance(quality)
local self = setmetatable({}, Blur)
self.shader = blurData.shader
self.quality = quality
self.taps = blurData.taps
return self
end
--- Apply blur to a region of the screen
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param drawFunc function Function to draw content to be blurred
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
if type(drawFunc) ~= "function" then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_001")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
drawFunc()
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
drawFunc()
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(nil, width, height, "content")
-- Calculate offset multiplier based on radius and quality
-- Higher quality = more samples = smaller steps for same radius
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.push()
love.graphics.origin()
love.graphics.translate(-x, -y)
drawFunc()
love.graphics.pop()
love.graphics.setShader(self.shader)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
end
--- Apply backdrop blur effect (blur content behind a region)
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Get the current quality level
---@return number quality Quality level (1-10)
function Blur:getQuality()
return self.quality
end
--- Get the number of shader taps
---@return number taps Number of shader taps
function Blur:getTaps()
return self.taps
end
--- Clear all caches (call on window resize or memory cleanup)
function Blur.clearCache()
Cache.clear()
end
--- Apply backdrop blur with caching support
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
---@param elementId string|nil Element ID for caching (nil disables caching)
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
-- If caching is disabled or no element ID, fall back to regular apply
if not Blur._blurOptimizations or not elementId then
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
end
-- Generate cache key
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
-- Check cache
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
if cachedCanvas then
-- Draw cached blur
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(cachedCanvas, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
return
end
-- Not cached, render and cache
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(elementId, width, height, "backdrop")
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
-- Cache the result
local cachedResult = love.graphics.newCanvas(width, height)
love.graphics.setCanvas(cachedResult)
love.graphics.clear()
love.graphics.setShader()
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.draw(canvas1, 0, 0)
Cache.setBlurredCanvas(cacheKey, cachedResult)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Clear blur cache for specific element
---@param elementId string Element ID
function Blur.clearElementCache(elementId)
Cache.clearBlurredCanvasesForElement(elementId)
end
--- Initialize Blur module with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
function Blur.init(deps)
if type(deps) == "table" then
Blur._ErrorHandler = deps.ErrorHandler
Blur._blurOptimizations = deps.immediateModeOptimizations or false
end
end
Blur.Cache = Cache
Blur.ShaderBuilder = ShaderBuilder
return Blur
-385
View File
@@ -1,385 +0,0 @@
--- Utility module for parsing and evaluating CSS-like calc() expressions
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
---@class Calc
local Calc = {}
--- Initialize Calc module with dependencies
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
function Calc.init(deps)
Calc._ErrorHandler = deps.ErrorHandler
end
--- Token types for lexical analysis
local TokenType = {
NUMBER = "NUMBER",
UNIT = "UNIT",
PLUS = "PLUS",
MINUS = "MINUS",
MULTIPLY = "MULTIPLY",
DIVIDE = "DIVIDE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
EOF = "EOF",
}
--- Tokenize a calc expression string into tokens
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
---@return CalcToken[]? tokens Array of tokens with type, value, unit
---@return string? error Error message if tokenization fails
local function tokenize(expr)
local tokens = {}
local i = 1
local len = #expr
while i <= len do
local char = expr:sub(i, i)
-- Skip whitespace
if char:match("%s") then
i = i + 1
-- Number (including decimals, but NOT negative - handled separately below)
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
local numStr = ""
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
-- Operators
elseif char == "+" then
table.insert(tokens, { type = TokenType.PLUS })
i = i + 1
elseif char == "-" then
-- Check if this is a negative number or subtraction
-- It's a negative number if previous token is an operator or opening paren
local prevToken = tokens[#tokens]
if
not prevToken
or prevToken.type == TokenType.PLUS
or prevToken.type == TokenType.MINUS
or prevToken.type == TokenType.MULTIPLY
or prevToken.type == TokenType.DIVIDE
or prevToken.type == TokenType.LPAREN
then
-- This is a negative number, continue to number parsing
local numStr = "-"
i = i + 1
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
else
-- This is subtraction operator
table.insert(tokens, { type = TokenType.MINUS })
i = i + 1
end
elseif char == "*" then
table.insert(tokens, { type = TokenType.MULTIPLY })
i = i + 1
elseif char == "/" then
table.insert(tokens, { type = TokenType.DIVIDE })
i = i + 1
elseif char == "(" then
table.insert(tokens, { type = TokenType.LPAREN })
i = i + 1
elseif char == ")" then
table.insert(tokens, { type = TokenType.RPAREN })
i = i + 1
else
return nil, "Unexpected character: " .. char
end
end
table.insert(tokens, { type = TokenType.EOF })
return tokens
end
--- Parser for calc expressions using recursive descent
---@class Parser
---@field tokens CalcToken[] Array of tokens
---@field pos number Current token position
local Parser = {}
Parser.__index = Parser
--- Create a new parser
---@param tokens CalcToken[] Array of tokens
---@return Parser
function Parser.new(tokens)
local self = setmetatable({}, Parser)
self.tokens = tokens
self.pos = 1
return self
end
--- Get current token
---@return CalcToken token Current token
function Parser:current()
return self.tokens[self.pos]
end
--- Advance to next token
function Parser:advance()
self.pos = self.pos + 1
end
--- Parse expression (handles + and -)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseExpression()
local left = self:parseTerm()
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
local op = self:current().type
self:advance()
local right = self:parseTerm()
left = {
type = op == TokenType.PLUS and "add" or "subtract",
left = left,
right = right,
}
end
return left
end
--- Parse term (handles * and /)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseTerm()
local left = self:parseFactor()
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
local op = self:current().type
self:advance()
local right = self:parseFactor()
left = {
type = op == TokenType.MULTIPLY and "multiply" or "divide",
left = left,
right = right,
}
end
return left
end
--- Parse factor (handles numbers and parentheses)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseFactor()
local token = self:current()
if token.type == TokenType.NUMBER then
self:advance()
return {
type = "number",
value = token.value,
unit = token.unit,
}
elseif token.type == TokenType.LPAREN then
self:advance()
local expr = self:parseExpression()
if self:current().type ~= TokenType.RPAREN then
error("Expected closing parenthesis")
end
self:advance()
return expr
else
error("Unexpected token: " .. token.type)
end
end
--- Parse the tokens into an AST
---@return CalcASTNode ast Abstract syntax tree
function Parser:parse()
local ast = self:parseExpression()
if self:current().type ~= TokenType.EOF then
error("Unexpected tokens after expression")
end
return ast
end
--- Create a calc expression object that can be resolved later
--- This is the main API function that users call
---@param expr string The calc expression (e.g., "50% - 10vw")
---@return CalcObject calcObject A calc expression object with AST
function Calc.new(expr)
-- Tokenize
local tokens, err = tokenize(expr)
if not tokens then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = err,
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = err,
}
end
-- Parse
local parser = Parser.new(tokens)
local success, ast = pcall(function()
return parser:parse()
end)
if not success then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = ast, -- ast contains error message on failure
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = ast,
}
end
return {
_isCalc = true,
_expr = expr,
_ast = ast,
}
end
--- Check if a value is a calc expression
---@param value any The value to check
---@return boolean isCalc True if value is a calc expression
function Calc.isCalc(value)
return type(value) == "table" and value._isCalc == true
end
--- Resolve a calc expression to pixel value
---@param calcObj CalcObject The calc expression object
---@param viewportWidth number Viewport width in pixels
---@param viewportHeight number Viewport height in pixels
---@param parentSize number? Parent dimension for percentage units
---@return number resolvedValue Resolved pixel value
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
if not calcObj._ast then
-- Error during parsing, return 0
return 0
end
--- Evaluate AST node recursively
---@param node table AST node
---@return number value Evaluated value in pixels
local function evaluate(node)
if node.type == "number" then
-- Convert unit to pixels
local value = node.value
local unit = node.unit
if unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "LAY_003", {
unit = "%",
issue = "parent dimension not available",
})
end
return 0
end
return (value / 100) * parentSize
elseif unit == "vw" then
return (value / 100) * viewportWidth
elseif unit == "vh" then
return (value / 100) * viewportHeight
else
return 0
end
elseif node.type == "add" then
return evaluate(node.left) + evaluate(node.right)
elseif node.type == "subtract" then
return evaluate(node.left) - evaluate(node.right)
elseif node.type == "multiply" then
return evaluate(node.left) * evaluate(node.right)
elseif node.type == "divide" then
local divisor = evaluate(node.right)
if divisor == 0 then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = calcObj._expr,
error = "Division by zero",
})
end
return 0
end
return evaluate(node.left) / divisor
else
return 0
end
end
return evaluate(calcObj._ast)
end
return Calc
-346
View File
@@ -1,346 +0,0 @@
---@class Color
local Color = {}
Color.__index = Color
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler}
function Color.init(deps)
if type(deps) == "table" then
Color._ErrorHandler = deps.ErrorHandler
end
end
--- Build type-safe color objects with automatic validation and clamping
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
---@param r number? Red component (0-1), defaults to 0
---@param g number? Green component (0-1), defaults to 0
---@param b number? Blue component (0-1), defaults to 0
---@param a number? Alpha component (0-1), defaults to 1
---@return Color color The new color instance
function Color.new(r, g, b, a)
-- Sanitize and clamp color components
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
-- FFI structs don't support metatables/methods without wrapping
-- The wrapping overhead negates the FFI benefits
local self = setmetatable({}, Color)
self.r = sanitizedR or 0
self.g = sanitizedG or 0
self.b = sanitizedB or 0
self.a = sanitizedA or 1
return self
end
--- Extract individual color channels for use with love.graphics.setColor()
--- Use this to pass colors to LÖVE's rendering functions
---@return number r Red component (0-1)
---@return number g Green component (0-1)
---@return number b Blue component (0-1)
---@return number a Alpha component (0-1)
function Color:toRGBA()
return self.r, self.g, self.b, self.a
end
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
--- Use this to work with colors from design tools that export hex values
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
---@return Color color The parsed color (returns white on error with warning)
function Color.fromHex(hexWithTag)
-- Validate input type
if type(hexWithTag) ~= "string" then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = tostring(hexWithTag),
issue = "not a string",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1)
end
local hex = hexWithTag:gsub("#", "")
if #hex == 6 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
if not r or not g or not b then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, 1)
elseif #hex == 8 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
local a = tonumber("0x" .. hex:sub(7, 8))
if not r or not g or not b or not a then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, a / 255)
else
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
expected = "#RRGGBB or #RRGGBBAA",
hexLength = #hex,
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
end
--- Verify and sanitize individual color components to prevent rendering errors
--- Use this to safely process user input or external color data
---@param value any Value to validate
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
---@return boolean valid True if valid
---@return number? clamped Clamped value in 0-1 range, nil if invalid
function Color.validateColorChannel(value, max)
max = max or 1
if type(value) ~= "number" then
return false, nil
end
-- Check for NaN
if value ~= value then
return false, nil
end
-- Check for Infinity
if value == math.huge or value == -math.huge then
return false, nil
end
-- Normalize to 0-1 range
local normalized = value
if max == 255 then
normalized = value / 255
end
-- Clamp to valid range
normalized = math.max(0, math.min(1, normalized))
return true, normalized
end
--- Validate hex color format
---@param hex string Hex color string (with or without #)
---@return boolean valid True if valid format
---@return string? error Error message if invalid, nil if valid
function Color.validateHexColor(hex)
if type(hex) ~= "string" then
return false, "Hex color must be a string"
end
-- Remove # prefix
local cleanHex = hex:gsub("^#", "")
-- Check length (3, 6, or 8 characters)
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
end
-- Check for valid hex characters
if not cleanHex:match("^[0-9A-Fa-f]+$") then
return false, "Invalid hex characters. Use only 0-9, A-F"
end
return true, nil
end
--- Validate RGB/RGBA color values
---@param r number Red component
---@param g number Green component
---@param b number Blue component
---@param a number? Alpha component (optional, defaults to max)
---@param max number? Maximum value (255 or 1), defaults to 1
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateRGBColor(r, g, b, a, max)
max = max or 1
a = a or max
local rValid = Color.validateColorChannel(r, max)
local gValid = Color.validateColorChannel(g, max)
local bValid = Color.validateColorChannel(b, max)
local aValid = Color.validateColorChannel(a, max)
if not rValid then
return false, string.format("Invalid red channel: %s", tostring(r))
end
if not gValid then
return false, string.format("Invalid green channel: %s", tostring(g))
end
if not bValid then
return false, string.format("Invalid blue channel: %s", tostring(b))
end
if not aValid then
return false, string.format("Invalid alpha channel: %s", tostring(a))
end
return true, nil
end
--- Check if a value is a valid color format
---@param value any Value to check
---@return string? format Format type ("hex", "named", "table"), nil if invalid
function Color.isValidColorFormat(value)
local valueType = type(value)
-- Check for hex string
if valueType == "string" then
if value:match("^#?[0-9A-Fa-f]+$") then
local valid = Color.validateHexColor(value)
if valid then
return "hex"
end
end
return nil
end
-- Check for table format
if valueType == "table" then
-- Check for Color instance
if getmetatable(value) == Color then
return "table"
end
-- Check for array format {r, g, b, a}
if value[1] and value[2] and value[3] then
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
if valid then
return "table"
end
end
-- Check for named format {r=, g=, b=, a=}
if value.r and value.g and value.b then
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
if valid then
return "table"
end
end
return nil
end
return nil
end
--- Convert any color format to a valid Color object with graceful fallbacks
--- Use this to robustly handle colors from any source without crashes
---@param value any Color value to sanitize (hex, named, table, or Color instance)
---@param default Color? Default color if invalid (defaults to black)
---@return Color color Sanitized color instance (guaranteed non-nil)
function Color.sanitizeColor(value, default)
default = default or Color.new(0, 0, 0, 1)
local format = Color.isValidColorFormat(value)
if not format then
return default
end
-- Handle hex format
if format == "hex" then
local cleanHex = value:gsub("^#", "")
-- Expand 3-digit hex to 6-digit
if #cleanHex == 3 then
cleanHex = cleanHex:gsub("(.)", "%1%1")
end
-- Try to parse
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
if success then
return result
else
return default
end
end
if format == "table" then
-- Color instance
if getmetatable(value) == Color then
return value
end
-- Array format
if value[1] then
local _, r = Color.validateColorChannel(value[1], 1)
local _, g = Color.validateColorChannel(value[2], 1)
local _, b = Color.validateColorChannel(value[3], 1)
local _, a = Color.validateColorChannel(value[4] or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
-- Named format
if value.r then
local _, r = Color.validateColorChannel(value.r, 1)
local _, g = Color.validateColorChannel(value.g, 1)
local _, b = Color.validateColorChannel(value.b, 1)
local _, a = Color.validateColorChannel(value.a or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
end
return default
end
--- Universally convert any color format (hex, named, table) into a Color object
--- Use this as your main color input handler to accept flexible color specifications
---@param value any Color value (hex string, named color, table, or Color instance)
---@return Color color Parsed color instance (defaults to black on error)
function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end
--- Smoothly transition between two colors for animations and gradients
--- Use this to create color-based animations without manual channel calculations
---@param colorA Color Starting color
---@param colorB Color Ending color
---@param t number Interpolation factor (0-1)
---@return Color color Interpolated color
function Color.lerp(colorA, colorB, t)
-- Sanitize inputs
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
colorA = Color.new(0, 0, 0, 1)
end
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
colorB = Color.new(0, 0, 0, 1)
end
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
t = 0
end
-- Clamp t to 0-1 range
t = math.max(0, math.min(1, t))
-- Linear interpolation for each channel
local oneMinusT = 1 - t
local r = colorA.r * oneMinusT + colorB.r * t
local g = colorA.g * oneMinusT + colorB.g * t
local b = colorA.b * oneMinusT + colorB.b * t
local a = colorA.a * oneMinusT + colorB.a * t
return Color.new(r, g, b, a)
end
return Color
-596
View File
@@ -1,596 +0,0 @@
---@class Context
local modulePath = (...):match("(.-)[^%.]+$")
local ZIndex = require(modulePath .. "ZIndex")
local Element = require(modulePath .. "Element")
local Context = {
topElements = {},
-- Base scale configuration
baseScale = nil, -- {width: number, height: number}
-- Current scale factors
scaleFactors = { x = 1.0, y = 1.0 },
defaultTheme = nil,
_focusedElement = nil,
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
_activeEventElement = nil,
_cachedViewport = { width = 0, height = 0 },
-- Immediate mode state
_immediateMode = false,
_frameNumber = 0,
_currentFrameElements = {},
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
_frameStarted = false,
_autoBeganFrame = false,
-- Z-index ordered element tracking for immediate mode
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
-- Focus management guard
_settingFocus = false,
-- Hook called whenever focus changes: function(element) or nil
_onFocusChanged = nil,
-- Navigation state
_navigationContext = {
lastFocusedElement = nil, -- For returning from modals
navigationMode = "sequential", -- "sequential" or "directional"
containerElement = nil, -- Current navigation container
},
initialized = false,
-- Expose internal hit-testing helpers for unit testing only.
-- These are populated below after their local definitions. They are NOT part
-- of the public API and must not be relied on by callers; they exist so the
-- shared hit-test core (the single place display:none guarding lives) can be
-- exercised directly by the test suite. Subsequent unified-event-routing
-- tasks consume these locals through the mode-agnostic query functions.
_test = {
pointHitsElement = nil,
elementHasScrollableOverflow = nil,
},
-- Debug draw overlay
_debugDraw = false,
_debugDrawKey = nil,
-- Initialization state tracking
---@type "uninitialized"|"initializing"|"ready"
_initState = "uninitialized",
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
_initQueue = {},
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
-- + realloc + sort for every interactive element sharing the same cursor.
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
-- each flexlove.update (both modes) and in clearFrameElements (immediate
-- mid-frame rebuild). It also self-invalidates when the topElements table
-- reference changes (tests replace it per-case; immediate-mode beginFrame
-- reassigns it each frame), so direct callers that never go through
-- flexlove.update still see fresh results across tree swaps.
_interactiveLookupCache = {
valid = false,
x = nil,
y = nil,
result = nil,
topElementsRef = nil,
frameNumber = -1,
},
}
--- Check if a point hits an element, accounting for scroll offsets and display:none.
--- All mode-agnostic query functions use this as their single hit-test entry point,
--- ensuring fixes like display:none guarding apply everywhere.
---
--- This is the single canonical place where `element.display == false` short-
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
--- the caller's responsibility: callers walk the parent chain (using
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
--- accumulated scroll offset in here. Keeping the parent walk outside this core
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
--- z-index list) callers share the exact same primitive bounds/display logic.
---@param element Element
---@param mx number Screen X coordinate
---@param my number Screen Y coordinate
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
---@return boolean hits
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
-- Skip display:none elements entirely
if element.display == false then
return false
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
local adjustedX = mx + scrollOffsetX
local adjustedY = my + scrollOffsetY
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
end
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
--- overflow values that clip/translate descendant content and therefore require
--- scroll-offset compensation when hit testing descendants.
---@param element Element
---@return boolean
local function elementHasScrollableOverflow(element)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
return overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
end
-- Expose the two core helpers for unit testing only (see Context._test above).
Context._test.pointHitsElement = pointHitsElement
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
-- Public exposure of the canonical hit-test primitive so other modules
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
-- tree walks) can share the single implementation of bounds + display:none
-- guarding instead of duplicating the `display == false` check inline.
-- This keeps "display == false" in exactly one place for hit-testing.
Context.pointHitsElement = pointHitsElement
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
--- Find the first scrollable element at a screen position, regardless of mode.
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
--- that previously lived inline in `flexlove.wheelmoved`:
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
--- * retained mode — recursed through `Context.topElements` with a private
--- `findScrollableAtPosition(elements, x, y)` helper.
--- Both paths now collapse into this single function, which routes every
--- hit test through `pointHitsElement` (the single place `display == false`
--- is guarded) and every scroll-offset decision through
--- `elementHasScrollableOverflow`. As a result display:none elements are never
--- returned in either mode, fixing the latent bug where the immediate-mode
--- path's `isPointInElement` did not skip display:none elements.
---
--- The retained-mode branch intentionally mirrors the original
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
--- children checked before self) but is upgraded to thread accumulated scroll
--- offsets through `pointHitsElement` so nested scrolled containers are tested
--- against their visible position. The original helper is removed once
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The scrollable element, or nil
function Context.findScrollableAtPosition(x, y)
if Context.isImmediateMode() then
-- Immediate mode: iterate the z-index ordered list (reverse order =
-- topmost first). pointHitsElement supplies the bounds + display guard.
for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i]
if pointHitsElement(element, x, y) then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
else
-- Retained mode: recursive tree walk from topElements. Children are
-- checked before self (deepest scrollable wins); accumulated scroll
-- offsets are threaded through pointHitsElement so descendants of
-- scrolled containers are hit-tested against their translated position.
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
for i = #elements, 1, -1 do
local element = elements[i]
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
if #element.children > 0 then
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
if childResult then
return childResult
end
end
-- No descendant was scrollable — check self.
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
end
return findInTree(Context.topElements)
end
end
--- Check whether immediate mode is active.
--- This is the single canonical accessor for the mode flag consumed throughout
--- the framework. Mode-aware branches elsewhere call this instead of reading
--- `Context._immediateMode` directly, so the literal mode flag only appears
--- here (its definition) and in StateManager (its mirrored storage) — never
--- scattered across Element / behaviors / managers (behavior-mode-unification
--- task 11).
---@return boolean
function Context.isImmediateMode()
return Context._immediateMode
end
---@return number, number -- scaleX, scaleY
function Context.getScaleFactors()
return Context.scaleFactors.x, Context.scaleFactors.y
end
--- Register an element in the z-index ordered tree (for immediate mode)
---@param element Element The element to register
function Context.registerElement(element)
if not Context.isImmediateMode() then
return
end
table.insert(Context._zIndexOrderedElements, element)
end
function Context.clearFrameElements()
Context._zIndexOrderedElements = {}
Context.clearInteractiveCache()
end
--- Compute the composite z-index key for an element.
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
---
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
--- remaining 3 digits without interfering with the depth component.
---
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
---
--- This is the SINGLE canonical z-index ordering function, used by both
--- sortElementsByZIndex (the immediate-mode flat list sort) and
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
--- on the same key ensures the interactive topmost element matches the visual
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
--- z=0 BottomBar even when both buttons default to own z=0.
local function getEffectiveZIndex(elem)
local ownZ = elem.z or 0
local rootZ = ownZ
local depth = 0
local current = elem.parent
while current do
rootZ = current.z or 0
depth = depth + 1
current = current.parent
end
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
end
-- Public exposure so FlexLove.getElementAtPosition shares the single
-- implementation instead of duplicating the parent-chain walk as a closure.
Context.getEffectiveZIndex = getEffectiveZIndex
--- Sort elements by z-index (called after all elements are registered)
function Context.sortElementsByZIndex()
-- Precompute the composite key ONCE per element so the sort comparator is a
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
-- O(N log N) comparison. This function runs every frame in immediate mode.
local elements = Context._zIndexOrderedElements
local zIndices = {}
for i = 1, #elements do
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
end
table.sort(elements, function(a, b)
return zIndices[a] < zIndices[b]
end)
end
--- Find the topmost interactive element at a screen position, regardless of mode.
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
--- mechanism — both are now funneled through this single entry point.
---
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
--- in immediate mode). In retained mode this provides the same role as the
--- _activeEventElement set by flexlove.getElementAtPosition().
---
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The topmost interactive element, or nil
function Context.findInteractiveAtPosition(x, y)
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
-- element under the same cursor, but the result for a given (x,y) is
-- identical across all of them within a single update pass. Returning a
-- cached element restores the old 1x/frame cost of the _activeEventElement
-- mechanism that task 05 replaced. Cache auto-invalidates when the
-- topElements table reference changes (so tests and mid-frame rebuilds get
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
local cache = Context._interactiveLookupCache
if
cache.valid
and cache.x == x
and cache.y == y
and cache.topElementsRef == Context.topElements
and cache.frameNumber == Context._frameNumber
then
return cache.result
end
local interactiveCandidates = {}
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
return
end
-- Check if this element is interactive
if element.onEvent or element.themeComponent or element.editable then
table.insert(interactiveCandidates, element)
end
-- Recurse into children with accumulated scroll offset
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
for _, child in ipairs(element.children) do
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
end
end
-- Always traverse the tree (works in both modes — topElements exists always)
for _, element in ipairs(Context.topElements) do
collectInteractive(element)
end
-- Sort by composite z-index descending — topmost wins. The composite key
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
-- topmost element matches the visual draw order. This is critical for the
-- game's multi-window layout: a button inside a z=50 MainMenu window must
-- occlude a button inside a z=0 BottomBar even when both buttons default to
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
-- distinguish them, so the wrong window's button could win, leaving the
-- visible button's isActiveElement=false and clicks/hover dead.
local zIndices = {}
for _, el in ipairs(interactiveCandidates) do
zIndices[el] = getEffectiveZIndex(el)
end
table.sort(interactiveCandidates, function(a, b)
return zIndices[a] > zIndices[b]
end)
local result = interactiveCandidates[1]
cache.x = x
cache.y = y
cache.result = result
cache.topElementsRef = Context.topElements
cache.frameNumber = Context._frameNumber
cache.valid = true
return result
end
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
--- rebuild). After invalidation the next lookup recomputes fresh.
function Context.clearInteractiveCache()
local cache = Context._interactiveLookupCache
cache.valid = false
cache.x = nil
cache.y = nil
cache.result = nil
cache.topElementsRef = nil
cache.frameNumber = -1
end
--- Set the focused element (centralizes focus management)
--- Automatically blurs the previously focused element if different
---@param element Element|nil The element to focus (nil to clear focus)
function Context.setFocused(element)
if Context._focusedElement == element then
return -- Already focused
end
-- Prevent re-entry during focus change
if Context._settingFocus then
return
end
Context._settingFocus = true
-- Save reference to previously focused element before updating
local oldFocusedElement = Context._focusedElement
-- Blur previously focused element
if oldFocusedElement and oldFocusedElement ~= element then
if oldFocusedElement._textEditor then
oldFocusedElement._textEditor:blur(oldFocusedElement)
end
end
-- Set new focused element and persist its id for immediate-mode rehydration
Context._focusedElement = element
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
-- Notify any registered focus change hook (e.g. FocusIndicator)
if Context._onFocusChanged then
Context._onFocusChanged(element)
end
-- Focus the new element's text editor if it has one
if element and element._textEditor then
element._textEditor._focused = true
end
Context._settingFocus = false
end
--- Recursively search for an element by id in an element tree
---@param root Element The root element to start searching from
---@param targetId string The id to search for
---@return Element|nil The element with the matching id, or nil if not found
local function findElementById(root, targetId)
if root.id == targetId then
return root
end
for _, child in ipairs(root.children or {}) do
local found = findElementById(child, targetId)
if found then
return found
end
end
return nil
end
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
--- Called at the start of getFocused() in immediate mode so stale references
--- are always replaced with the current-frame object before use.
function Context._rehydrateFocus()
if not Context._focusedElementId then
Context._focusedElement = nil
return
end
-- First, try a fast linear search through all registered elements
for _, elem in ipairs(Context._zIndexOrderedElements) do
if elem.id == Context._focusedElementId then
Context._focusedElement = elem
return
end
end
-- If not found, recursively search from top-level elements
-- This handles cases where elements may not be in _zIndexOrderedElements
for _, topLevel in ipairs(Context.topElements or {}) do
local found = findElementById(topLevel, Context._focusedElementId)
if found then
Context._focusedElement = found
return
end
end
-- Element with that id is not present this frame (e.g. screen changed)
Context._focusedElement = nil
end
--- Get the currently focused element
---@return Element|nil The focused element, or nil if none
function Context.getFocused()
if Context.isImmediateMode() then
Context._rehydrateFocus()
end
return Context._focusedElement
end
--- Clear focus from any element
function Context.clearFocus()
Context._focusedElementId = nil
Context.setFocused(nil)
end
--- Get all focusable elements in tab order, regardless of mode.
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
--- In retained mode it walks the element tree (DOM order).
--- In both modes, display:none elements are excluded.
---@return table<Element> List of focusable elements in tab order
function Context.getFocusableElements()
local focusable = {}
local function isFocusable(elem)
if elem.display == false then
return false
end
-- Use Element:isFocusable() for consistent behavior
return Element.isFocusable(elem)
end
local function collectFromTree(elements)
for _, elem in ipairs(elements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
if #elem.children > 0 then
collectFromTree(elem.children)
end
end
end
if Context._immediateMode then
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
-- which approximates tab order for most UIs.
for _, elem in ipairs(Context._zIndexOrderedElements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
end
else
-- Retained mode: walk the top element trees in DOM order
collectFromTree(Context.topElements)
end
return focusable
end
-- ====================
-- Navigation Context
-- ====================
--- Push current focus onto stack (for modals/dialogs)
---@param element Element?
function Context.pushFocusStack(element)
Context._navigationContext.lastFocusedElement = Context._focusedElement
if element then
Context.setFocused(element)
end
end
--- Pop focus from stack (return from modal)
---@return Element?
function Context.popFocusStack()
local previous = Context._navigationContext.lastFocusedElement
Context._navigationContext.lastFocusedElement = nil
Context.setFocused(previous)
return previous
end
--- Set navigation container (scope for tab navigation)
---@param element Element?
function Context.setNavigationContainer(element)
Context._navigationContext.containerElement = element
end
--- Get navigation container
---@return Element?
function Context.getNavigationContainer()
return Context._navigationContext.containerElement
end
return Context
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
-- Extracted from utils so utils stays under its LOC budget; re-exported as
-- `utils.enums` for backward compatibility.
local enums = {
---@enum TextAlign
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
---@enum TextAlignVertical
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
---@enum Positioning
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
---@enum FlexDirection
FlexDirection = {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
ROW = "row",
COLUMN = "column",
HORIZONTAL_REVERSE = "horizontal-reverse",
VERTICAL_REVERSE = "vertical-reverse",
ROW_REVERSE = "row-reverse",
COLUMN_REVERSE = "column-reverse",
},
---@enum JustifyContent
JustifyContent = {
FLEX_START = "flex-start",
CENTER = "center",
SPACE_AROUND = "space-around",
FLEX_END = "flex-end",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum JustifySelf
JustifySelf = {
AUTO = "auto",
FLEX_START = "flex-start",
CENTER = "center",
FLEX_END = "flex-end",
SPACE_AROUND = "space-around",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum AlignItems
AlignItems = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignSelf
AlignSelf = {
AUTO = "auto",
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignContent
AlignContent = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
SPACE_BETWEEN = "space-between",
SPACE_AROUND = "space-around",
},
---@enum FlexWrap
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
---@enum TextSize
TextSize = {
XXS = "xxs",
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
XXL = "xxl",
XL3 = "3xl",
XL4 = "4xl",
},
---@enum ImageRepeat
ImageRepeat = {
NO_REPEAT = "no-repeat",
REPEAT = "repeat",
REPEAT_X = "repeat-x",
REPEAT_Y = "repeat-y",
SPACE = "space",
ROUND = "round",
},
---@enum ARIA Role (accessibility roles for screen readers)
ARIA = {
-- Widget roles
BUTTON = "button",
CHECKBOX = "checkbox",
LINK = "link",
MENUITEM = "menuitem",
MENUITEMCHECKBOX = "menuitemcheckbox",
MENUITEMRADIO = "menuitemradio",
PROGRESSBAR = "progressbar",
RADIO = "radio",
SCROLLBAR = "scrollbar",
SLIDER = "slider",
SPINBUTTON = "spinbutton",
SWITCH = "switch",
TAB = "tab",
TABLIST = "tablist",
TABPANEL = "tabpanel",
TEXTBOX = "textbox",
TOOLTIP = "tooltip",
TREEITEM = "treeitem",
COMBOBOX = "combobox",
GRID = "grid",
GRIDCELL = "gridcell",
LISTBOX = "listbox",
LISTITEM = "listitem",
MENU = "menu",
MENUBAR = "menubar",
TREE = "tree",
TREEGRID = "treegrid",
WINDOW = "window",
DIALOG = "dialog",
ALERTDIALOG = "alertdialog",
-- Landmark roles
BANNER = "banner",
COMPLEMENTARY = "complementary",
CONTENTINFO = "contentinfo",
FORM = "form",
MAIN = "main",
NAVIGATION = "navigation",
REGION = "region",
SEARCH = "search",
-- Live region roles
ALERT = "alert",
LOG = "log",
MARQUEE = "marquee",
STATUS = "status",
TIMERTIME = "timer",
-- Document structure roles
ARTICLE = "article",
BLOCKQUOTEBLOCKQUOTE = "blockquote",
CAPTION = "caption",
CODE = "code",
DEFINITION = "definition",
DELETED = "deletion",
DIRECTORY = "directory",
DIVISION = "division",
EMphasis = "emphasis",
HEADING = "heading",
INSERTED = "insertion",
LIST = "list",
MARK = "mark",
MATH = "math",
NONE = "none",
PARAGRAPH = "paragraph",
PRESENTATION = "presentation",
SEPARATOR = "separator",
STRONG = "strong",
SUBSCRIPT = "subscript",
SUPERSCRIPT = "superscript",
TERM = "term",
TIME = "time",
VARIABLE = "variable",
},
}
return { enums = enums }
File diff suppressed because it is too large Load Diff
-843
View File
@@ -1,843 +0,0 @@
---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean?
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
---@field _pressed table<number, boolean>
---@field _lastClickTime number?
---@field _lastClickButton number?
---@field _clickCount number
---@field _dragStartX table<number, number>
---@field _dragStartY table<number, number>
---@field _lastMouseX table<number, number>
---@field _lastMouseY table<number, number>
---@field _touches table<string, table> -- Multi-touch state per touch ID
---@field _touchStartPositions table<string, table> -- Touch start positions
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean
---@field _scrollbarPressHandled boolean
---@field _InputEvent table
---@field _utils table
---@field _Performance Performance? Performance module dependency
---@field _ErrorHandler ErrorHandler
local EventHandler = {}
EventHandler.__index = EventHandler
--- Initialize module with shared dependencies
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
function EventHandler.init(deps)
EventHandler._Performance = deps.Performance
EventHandler._ErrorHandler = deps.ErrorHandler
EventHandler._InputEvent = deps.InputEvent
EventHandler._utils = deps.utils
EventHandler._Context = deps.Context
end
---@param config table Configuration options
---@return EventHandler
function EventHandler.new(config)
config = config or {}
local self = setmetatable({}, EventHandler)
self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred
self.onTouchEvent = config.onTouchEvent
self.onTouchEventDeferred = config.onTouchEventDeferred or false
self.onGesture = config.onGesture
self.onGestureDeferred = config.onGestureDeferred or false
self.touchEnabled = config.touchEnabled ~= false -- Default true
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
self._pressed = config._pressed or {}
self._lastClickTime = config._lastClickTime
self._lastClickButton = config._lastClickButton
self._clickCount = config._clickCount or 0
-- FocusIndicator reference (set after initialization)
self._FocusIndicator = nil
self._dragStartX = config._dragStartX or {}
self._dragStartY = config._dragStartY or {}
self._lastMouseX = config._lastMouseX or {}
self._lastMouseY = config._lastMouseY or {}
-- Multi-touch tracking
self._touches = config._touches or {}
self._touchStartPositions = config._touchStartPositions or {}
self._lastTouchPositions = config._lastTouchPositions or {}
self._touchHistory = config._touchHistory or {}
self._hovered = config._hovered or false
self._scrollbarPressHandled = false
return self
end
--- Get state for persistence (for immediate mode)
---@return table State data
function EventHandler:getState()
return {
_pressed = self._pressed,
_lastClickTime = self._lastClickTime,
_lastClickButton = self._lastClickButton,
_clickCount = self._clickCount,
_dragStartX = self._dragStartX,
_dragStartY = self._dragStartY,
_lastMouseX = self._lastMouseX,
_lastMouseY = self._lastMouseY,
_touches = self._touches,
_touchStartPositions = self._touchStartPositions,
_lastTouchPositions = self._lastTouchPositions,
_touchHistory = self._touchHistory,
_hovered = self._hovered,
}
end
--- Restore state from persistence (for immediate mode)
---@param state table State data
function EventHandler:setState(state)
if not state then
return
end
self._pressed = state._pressed or {}
self._lastClickTime = state._lastClickTime
self._lastClickButton = state._lastClickButton
self._clickCount = state._clickCount or 0
self._dragStartX = state._dragStartX or {}
self._dragStartY = state._dragStartY or {}
self._lastMouseX = state._lastMouseX or {}
self._lastMouseY = state._lastMouseY or {}
self._touches = state._touches or {}
self._touchStartPositions = state._touchStartPositions or {}
self._lastTouchPositions = state._lastTouchPositions or {}
self._touchHistory = state._touchHistory or {}
self._hovered = state._hovered or false
end
--- Process mouse button events in the update cycle
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param isHovering boolean Whether mouse is over element
---@param isActiveElement boolean Whether this is the top element at mouse position
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_mouse")
end
-- Check if currently dragging (allows drag continuation even if occluded)
local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and love.mouse.isDown(button) then
isDragging = true
break
end
end
-- Check if any button is currently pressed (tracked state)
local hasTrackedPress = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] then
hasTrackedPress = true
break
end
end
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.editable
or element._selectState
or element.selectOption
)
and element.visibility ~= "hidden"
and not element.disabled
and (isActiveElement or isDragging or hasTrackedPress)
if not canProcessEvents then
-- If not hovering and no buttons are physically pressed, reset all pressed states
-- This ensures the pressed state is cleared when mouse leaves without button held
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Track hover state changes even when events can't be processed
-- Fire synthetic unhover when element becomes disabled while hovered
if element.disabled and self._hovered then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
elseif self._hovered and not isHovering then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
end
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
return
end
-- Track hover state changes and fire hover/unhover events BEFORE button processing
-- This ensures hover fires before press when mouse first enters element
local wasHovered = self._hovered
local isHoveringAndActive = isHovering and isActiveElement
if isHoveringAndActive and not wasHovered then
-- Just started hovering - fire hover event
self._hovered = true
local modifiers = EventHandler._utils.getModifiers()
local hoverEvent = EventHandler._InputEvent.new({
type = "hover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, hoverEvent)
elseif not isHoveringAndActive and wasHovered then
-- Just stopped hovering - fire unhover event
self._hovered = false
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
-- Process all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle
for _, button in ipairs(buttons) do
-- Check if this button was tracked as pressed
local wasPressed = self._pressed[button]
local isPhysicallyPressed = love.mouse.isDown(button)
if isHovering or isDragging or wasPressed then
if isPhysicallyPressed then
-- Button is pressed down
if not wasPressed then
-- Just pressed - fire press event (only if hovering)
if isHovering then
self:_handleMousePress(element, mx, my, button)
end
else
-- Button is still pressed - check for drag
self:_handleMouseDrag(element, mx, my, button, isHovering)
end
elseif wasPressed then
-- Button was just released
-- Only fire click and release events if mouse is still hovering AND element is active
-- (not occluded by another element)
if isHovering and isActiveElement then
self:_handleMouseRelease(element, mx, my, button)
else
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
end
-- After processing events, reset pressed states for buttons that are no longer held
-- This handles the case where mouse leaves while button is held, then released
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
end
--- Handle mouse button press
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(element, mx, my, button)
-- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
if element:_handleScrollbarPress(mx, my, button) then
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
self._pressed[button] = true
self._scrollbarPressHandled = true
return
end
end
-- Fire press event
local modifiers = EventHandler._utils.getModifiers()
local pressEvent = EventHandler._InputEvent.new({
type = "press",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, pressEvent)
self._pressed[button] = true
-- On left click, set keyboard focus to any focusable element (not just editable).
-- Clear the focus indicator since mouse navigation doesn't use it.
local isFocusable
if type(element.isFocusable) == "function" then
isFocusable = element:isFocusable()
else
isFocusable = (element.editable == true)
or (type(element.onEvent) == "function")
or element._selectState ~= nil
or element.selectOption ~= nil
end
if button == 1 and EventHandler._Context and isFocusable then
EventHandler._Context.setFocused(element)
-- Hide focus indicator - it's only for keyboard navigation
if EventHandler._FocusIndicator then
EventHandler._FocusIndicator.setFocused(nil)
end
end
-- Set mouse down position for text selection on left click
if button == 1 and element._textEditor then
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
element._textDragOccurred = false -- Reset drag flag on press
end
-- Record drag start position per button
self._dragStartX[button] = mx
self._dragStartY[button] = my
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
--- Handle mouse drag (while button is pressed and mouse moves)
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then
-- Handle scrollbar drag if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
element:_handleScrollbarDrag(mx, my)
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
return -- Don't process other drag events while dragging scrollbar
end
-- Mouse has moved - fire drag event only if still hovering
if isHovering then
local modifiers = EventHandler._utils.getModifiers()
local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button]
local dragEvent = EventHandler._InputEvent.new({
type = "drag",
button = button,
x = mx,
y = my,
dx = dx,
dy = dy,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, dragEvent)
end
-- Handle text selection drag for editable elements
if button == 1 and element.editable and element._focused and element._handleTextDrag then
element:_handleTextDrag(mx, my)
end
-- Update last known position for this button
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
end
--- Handle mouse button release
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
function EventHandler:_handleMouseRelease(element, mx, my, button)
local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers()
-- Handle scrollbar release if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
element:_handleScrollbarRelease(button)
self._scrollbarPressHandled = false -- Reset flag
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
return -- Don't process click events for scrollbar release
end
-- Determine click count (double-click detection)
local clickCount
local doubleClickThreshold = 0.3 -- 300ms for double-click
if
self._lastClickTime
and self._lastClickButton == button
and (currentTime - self._lastClickTime) < doubleClickThreshold
then
clickCount = self._clickCount + 1
else
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
-- Fire click event
local clickEvent = EventHandler._InputEvent.new({
type = eventType,
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, clickEvent)
self._pressed[button] = false
-- Clean up drag tracking
self._dragStartX[button] = nil
self._dragStartY[button] = nil
-- Clean up text selection drag tracking
if button == 1 then
element._mouseDownPosition = nil
end
-- Focus editable elements on left click
if button == 1 and element.editable then
-- Only focus if not already focused (to avoid moving cursor to end)
local wasFocused = element:isFocused()
if not wasFocused then
element:focus()
end
-- Handle text click for cursor positioning and word selection
-- Only process click if no text drag occurred (to preserve drag selection)
if element._handleTextClick and not element._textDragOccurred then
element:_handleTextClick(mx, my, clickCount)
end
-- Reset drag flag after release
element._textDragOccurred = false
end
-- Fire release event
local releaseEvent = EventHandler._InputEvent.new({
type = "release",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, releaseEvent)
if button == 1 and element._handleSelectRelease then
element:_handleSelectRelease()
end
end
--- Process touch events in the update cycle
---@param element Element The parent element
function EventHandler:processTouchEvents(element)
-- Start performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_touch")
end
-- Check if element can process events
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.onTouchEvent
or self.onTouchEvent
or element.editable
)
and not element.disabled
and self.touchEnabled
if not canProcessEvents then
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
return
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Get current active touches from LÖVE
local activeTouches = {}
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
activeTouches[tostring(id)] = true
end
-- Count active tracked touches for multi-touch filtering
local trackedTouchCount = 0
for _ in pairs(self._touches) do
trackedTouchCount = trackedTouchCount + 1
end
-- Process active touches
for _, id in ipairs(touches) do
local touchId = tostring(id)
local tx, ty = love.touch.getPosition(id)
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
-- Check if touch is within element bounds
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
if isInside then
if not self._touches[touchId] then
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
-- and we already have an active touch
if self.multiTouchEnabled or trackedTouchCount == 0 then
-- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
trackedTouchCount = trackedTouchCount + 1
end
else
-- Touch moved
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
end
elseif self._touches[touchId] then
-- Touch moved outside or ended
if activeTouches[touchId] then
-- Still active but outside - fire moved event
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
else
-- Touch ended
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
end
end
end
-- Check for ended touches (touches that were tracked but are no longer active)
for touchId, _ in pairs(self._touches) do
if not activeTouches[touchId] then
-- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId]
if lastPos then
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
else
-- Cleanup orphaned touch
self:_cleanupTouch(touchId)
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
end
--- Handle touch began event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
-- Create touch state
self._touches[touchId] = {
x = x,
y = y,
pressure = pressure,
timestamp = love.timer.getTime(),
phase = "began",
}
-- Record start position
self._touchStartPositions[touchId] = { x = x, y = y }
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Initialize touch history
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
-- Create and fire touch press event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
touchEvent.type = "touchpress"
touchEvent.dx = 0
touchEvent.dy = 0
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
--- Handle touch moved event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local lastPos = self._lastTouchPositions[touchId]
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
-- Touch position changed
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Update touch state
touchState.x = x
touchState.y = y
touchState.pressure = pressure
touchState.phase = "moved"
-- Update last position
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Add to touch history (keep last 5 positions)
local history = self._touchHistory[touchId] or {}
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
if #history > 5 then
table.remove(history, 1)
end
self._touchHistory[touchId] = history
-- Create and fire touch move event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
touchEvent.type = "touchmove"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
end
--- Handle touch ended event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Create and fire touch release event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
touchEvent.type = "touchrelease"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
-- Cleanup touch state
self:_cleanupTouch(touchId)
end
--- Cleanup touch state
---@param touchId string Touch ID
function EventHandler:_cleanupTouch(touchId)
self._touches[touchId] = nil
self._touchStartPositions[touchId] = nil
self._lastTouchPositions[touchId] = nil
self._touchHistory[touchId] = nil
end
--- Get active touches on this element
---@return table<string, table> Active touches
function EventHandler:getActiveTouches()
return self._touches
end
--- Reset scrollbar press flag (called each frame)
function EventHandler:resetScrollbarPressFlag()
self._scrollbarPressHandled = false
end
--- Check if any mouse button is pressed
---@return boolean True if any button is pressed
function EventHandler:isAnyButtonPressed()
for _, pressed in pairs(self._pressed) do
if pressed then
return true
end
end
return false
end
--- Check if a specific button is pressed
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if button is pressed
function EventHandler:isButtonPressed(button)
return self._pressed[button] == true
end
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
---@param element Element The element that triggered the event
---@param event InputEvent The event data
function EventHandler:_invokeCallback(element, event)
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onEvent or self.onEvent
if not callback then
return
end
if self.onEventDeferred then
-- Get FlexLove module to defer the callback
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onTouchEvent callback, optionally deferring it
---@param element Element The element that triggered the event
---@param event InputEvent The touch event data
function EventHandler:_invokeTouchCallback(element, event)
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onTouchEvent or self.onTouchEvent
if not callback then
return
end
if self.onTouchEventDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onGesture callback, optionally deferring it
---@param element Element The element that triggered the event
---@param gesture table The gesture data from GestureRecognizer
function EventHandler:_invokeGestureCallback(element, gesture)
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onGesture or self.onGesture
if not callback then
return
end
if self.onGestureDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, gesture)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
gestureType = gesture.type,
})
end
else
callback(element, gesture)
end
end
return EventHandler
-232
View File
@@ -1,232 +0,0 @@
local packageName = ... or "FocusIndicator"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local FocusIndicator = {}
--- Configuration
---@type KeyboardNavigationFocusIndicatorConfig
FocusIndicator.config = {
enabled = true,
--- Custom draw function to override default rendering
---@type function|nil
--- Called with: element, bounds, style - return true to skip default drawing
draw = nil,
-- Appearance
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
lineWidth = 2,
inset = -3, -- Negative value extends beyond element
borderRadius = 4,
-- Animation
animationDuration = 0.15, -- Seconds for focus animation
pulseEnabled = false, -- Enable pulsing animation
pulseDuration = 1.0, -- Seconds per pulse cycle
pulseScaleMin = 0.95, -- Minimum scale during pulse
pulseScaleMax = 1.05, -- Maximum scale during pulse
}
--- State
FocusIndicator._focusedElement = nil
FocusIndicator._animationProgress = 0
FocusIndicator._pulsePhase = 0
FocusIndicator._hidden = true
FocusIndicator._deps = nil
--- Initialize FocusIndicator module
---@param deps table Dependencies table containing Context and Color modules
---@field deps.Context table Context module for getting focused element
---@field deps.Color table Color module for color manipulation
function FocusIndicator.init(deps)
FocusIndicator._deps = deps
FocusIndicator._Context = deps.Context
FocusIndicator._Color = deps.Color
end
--- Update animation state for entrance and pulse effects
---@param dt number Delta time in seconds since last frame
function FocusIndicator:update(dt)
if not FocusIndicator.config.enabled then
return
end
-- Update focus entrance animation
if FocusIndicator._animationProgress < 1 then
FocusIndicator._animationProgress =
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
end
-- Update pulse animation
if FocusIndicator.config.pulseEnabled then
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
end
end
--- Set the focused element to render indicator around
---@param element Element? The element to show focus indicator around, or nil to hide
function FocusIndicator.setFocused(element)
FocusIndicator._focusedElement = element
FocusIndicator._hidden = element == nil
-- Reset animation when focus changes
if element then
FocusIndicator._animationProgress = 0
end
end
--- Get the current scale factor for animations
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
---@return number Scale factor (typically 0.8-1.05 range)
function FocusIndicator:getScale()
local scale = 1
-- Apply entrance animation (scale up from 0.8)
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
scale = scale * entranceScale
-- Apply pulse animation
if FocusIndicator.config.pulseEnabled then
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
-- Smooth sine wave pulse
local pulseScale = FocusIndicator.config.pulseScaleMin
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
scale = scale * pulseScale
end
return scale
end
--- Get the current opacity for the indicator
--- Applies entrance animation fade-in to the configured alpha
---@return number Alpha value (0-1 range)
function FocusIndicator:getOpacity()
-- Fade in on focus
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
end
--- Draw the focus indicator around the focused element
--- Renders a rounded rectangle border, or calls custom draw function if configured
--- Should be called from within love.draw() after all elements are drawn
function FocusIndicator:draw()
if not FocusIndicator.config.enabled then
return
end
if FocusIndicator._hidden then
return
end
-- In immediate mode the stored element reference is stale (recreated every frame).
-- Always resolve through Context so we get the live object with up-to-date positions.
local element
if FocusIndicator._Context then
element = FocusIndicator._Context.getFocused()
else
element = FocusIndicator._focusedElement
end
if not element then
return
end
-- Get element dimensions (use border-box size which includes padding)
local x = element.x or 0
local y = element.y or 0
local w = element._borderBoxWidth
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
local h = element._borderBoxHeight
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
if w == 0 or h == 0 then
return
end
-- Calculate indicator dimensions with inset and scale
local inset = FocusIndicator.config.inset
local scale = self:getScale()
local indicatorX = x + inset
local indicatorY = y + inset
local indicatorW = w - 2 * inset
local indicatorH = h - 2 * inset
-- Center the scale around the element
local offsetX = (indicatorW * (1 - scale)) / 2
local offsetY = (indicatorH * (1 - scale)) / 2
indicatorX = indicatorX + offsetX
indicatorY = indicatorY + offsetY
indicatorW = indicatorW * scale
indicatorH = indicatorH * scale
-- Get color with animated opacity
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
local a = self:getOpacity()
-- Build style table for custom draw callback
local bounds = {
x = indicatorX,
y = indicatorY,
width = indicatorW,
height = indicatorH,
}
local style = {
color = { r = r, g = g, b = b, a = a },
lineWidth = FocusIndicator.config.lineWidth,
borderRadius = FocusIndicator.config.borderRadius,
scale = scale,
opacity = a,
}
-- Check for custom draw callback
if FocusIndicator.config.draw then
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
if skipDefault then
return
end
end
-- Save current love.graphics state
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local prevLineWidth = love.graphics.getLineWidth()
-- Set blend mode for transparency
love.graphics.setBlendMode("alpha")
-- Draw rounded rectangle border
love.graphics.setColor(r, g, b, a)
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
-- Draw the rounded rectangle border
local borderRadius = FocusIndicator.config.borderRadius
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
-- Restore love.graphics state
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
love.graphics.setColor(prevR, prevG, prevB, prevA)
love.graphics.setLineWidth(prevLineWidth)
end
--- Set the indicator color
---@param r number Red component (0-1 range)
---@param g number Green component (0-1 range)
---@param b number Blue component (0-1 range)
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
function FocusIndicator.setColor(r, g, b, a)
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
end
--- Set the stroke width for the indicator border
---@param width number Line width in pixels
function FocusIndicator.setLineWidth(width)
FocusIndicator.config.lineWidth = width
end
return FocusIndicator
-274
View File
@@ -1,274 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Font cache with LRU eviction, font resolution, and cache management.
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
-- a cross-import into utils (utils re-exports the cache via aliases).
-- Font cache with LRU eviction
local FONT_CACHE = {}
local FONT_CACHE_MAX_SIZE = 50
local FONT_CACHE_STATS = {
hits = 0,
misses = 0,
evictions = 0,
size = 0,
}
local ErrorHandler = nil
local resolveImagePath = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
resolveImagePath = deps.resolveImagePath
end
end
-- LRU tracking: each entry has {font, lastUsed, accessCount}
local function updateCacheAccess(cacheKey)
local entry = FONT_CACHE[cacheKey]
if entry then
entry.lastUsed = love.timer.getTime()
entry.accessCount = entry.accessCount + 1
end
end
local function evictLRU()
local oldestKey = nil
local oldestTime = math.huge
for key, entry in pairs(FONT_CACHE) do
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
if type(entry) == "table" and entry.lastUsed then
if entry.lastUsed < oldestTime then
oldestTime = entry.lastUsed
oldestKey = key
end
end
end
if oldestKey then
FONT_CACHE[oldestKey] = nil
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
end
end
--- Create or get a font from cache
---@param size number
---@param fontPath string?
---@return love.Font
function FONT_CACHE.get(size, fontPath)
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
-- Small sizes (< 20): round to nearest 2
-- Medium sizes (20-40): round to nearest 4
-- Large sizes (> 40): round to nearest 8
if size < 20 then
size = math.floor((size + 1) / 2) * 2
elseif size < 40 then
size = math.floor((size + 2) / 4) * 4
else
size = math.floor((size + 4) / 8) * 8
end
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if FONT_CACHE[cacheKey] then
-- Cache hit
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
updateCacheAccess(cacheKey)
return FONT_CACHE[cacheKey].font
end
-- Cache miss
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
if ErrorHandler then
ErrorHandler:warn("utils", "RES_004", {
resourceType = "font",
path = fontPath,
})
end
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
-- Per-glyph fallback so a non-Latin UI string is not drawn as tofu.
-- pcall'd require: FlexLove is vendored and must still load standalone.
local okUi, UiFont = pcall(require, "src.render.UiFont")
if okUi and UiFont then UiFont.attach(font, size) end
-- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
return font
end
--- Get font for text size (cached)
---@param textSize number?
---@param fontPath string?
---@return love.Font
function FONT_CACHE.getFont(textSize, fontPath)
if textSize then
return FONT_CACHE.get(textSize, fontPath)
else
return love.graphics.getFont()
end
end
-- Font resolution utilities
--- Resolve font path from fontFamily and theme
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return string? Resolved font path or nil
local function resolveFontPath(fontFamily, themeComponent, themeManager)
if fontFamily then
-- Check if fontFamily is a theme font name
local themeToUse = themeManager and themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
return themeToUse.fonts[fontFamily]
else
-- Treat as direct path to font file
return fontFamily
end
elseif themeComponent and themeManager then
-- If using themeComponent but no fontFamily specified, check for default font in theme
return themeManager:getDefaultFontFamily()
end
return nil
end
--- Get font for element (resolves from theme or fontFamily)
---@param textSize number? Text size in pixels
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return love.Font
local function getFont(textSize, fontFamily, themeComponent, themeManager)
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
return FONT_CACHE.getFont(textSize, fontPath)
end
-- Font cache management
--- Get font cache statistics
---@return table stats {hits, misses, evictions, size, hitRate}
local function getFontCacheStats()
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
return {
hits = FONT_CACHE_STATS.hits,
misses = FONT_CACHE_STATS.misses,
evictions = FONT_CACHE_STATS.evictions,
size = FONT_CACHE_STATS.size,
hitRate = hitRate,
}
end
--- Set maximum font cache size
---@param maxSize number Maximum number of fonts to cache
local function setFontCacheSize(maxSize)
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
-- Evict entries if cache is now over limit
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
evictLRU()
end
end
--- Clear font cache
local function clearFontCache()
-- Clear cache entries but preserve methods (get, getFont)
for key, entry in pairs(FONT_CACHE) do
if type(entry) == "table" and entry.lastUsed then
FONT_CACHE[key] = nil
end
end
FONT_CACHE_STATS.size = 0
FONT_CACHE_STATS.evictions = 0
end
--- Preload font at multiple sizes
---@param fontPath string? Path to font file (nil for default font)
---@param sizes table Array of font sizes to preload
local function preloadFont(fontPath, sizes)
for _, size in ipairs(sizes) do
-- Round size to reduce cache entries
size = math.floor(size + 0.5)
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if not FONT_CACHE[cacheKey] then
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
end
end
end
--- Reset font cache statistics
local function resetFontCacheStats()
FONT_CACHE_STATS.hits = 0
FONT_CACHE_STATS.misses = 0
FONT_CACHE_STATS.evictions = 0
end
return {
FONT_CACHE = FONT_CACHE,
init = init,
resolveFontPath = resolveFontPath,
getFont = getFont,
getFontCacheStats = getFontCacheStats,
setFontCacheSize = setFontCacheSize,
clearFontCache = clearFontCache,
preloadFont = preloadFont,
resetFontCacheStats = resetFontCacheStats,
}
-583
View File
@@ -1,583 +0,0 @@
---@class GestureRecognizer
---@field _touches table<string, table> -- Current touch states
---@field _gestureStates table -- Active gesture states
---@field _config table -- Gesture configuration (thresholds, etc.)
---@field _InputEvent table
---@field _utils table
local GestureRecognizer = {}
GestureRecognizer.__index = GestureRecognizer
-- Gesture types enum
local GestureType = {
TAP = "tap",
DOUBLE_TAP = "double_tap",
LONG_PRESS = "long_press",
SWIPE = "swipe",
PAN = "pan",
PINCH = "pinch",
ROTATE = "rotate",
}
-- Gesture states
local GestureState = {
POSSIBLE = "possible",
BEGAN = "began",
CHANGED = "changed",
ENDED = "ended",
CANCELLED = "cancelled",
FAILED = "failed",
}
-- Default configuration
local defaultConfig = {
-- Tap gesture
tapMaxDuration = 0.3, -- seconds
tapMaxMovement = 10, -- pixels
-- Double-tap gesture
doubleTapInterval = 0.3, -- seconds between taps
-- Long-press gesture
longPressMinDuration = 0.5, -- seconds
longPressMaxMovement = 10, -- pixels
-- Swipe gesture
swipeMinDistance = 50, -- pixels
swipeMaxDuration = 0.2, -- seconds
swipeMinVelocity = 200, -- pixels per second
-- Pan gesture
panMinMovement = 5, -- pixels to start pan
-- Pinch gesture
pinchMinScaleChange = 0.1, -- 10% scale change
-- Rotate gesture
rotateMinAngleChange = 5, -- degrees
}
--- Create a new GestureRecognizer instance
---@param config table? Optional configuration options
---@param deps table Dependencies {InputEvent, utils}
---@return GestureRecognizer
function GestureRecognizer.new(config, deps)
config = config or {}
local self = setmetatable({}, GestureRecognizer)
self._InputEvent = deps.InputEvent
self._utils = deps.utils
-- Merge configuration with defaults
self._config = {}
for key, value in pairs(defaultConfig) do
self._config[key] = config[key] or value
end
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = {},
rotate = {},
}
return self
end
--- Update gesture recognizer with touch event
---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event)
if not event.touchId then
return nil
end
local touchId = event.touchId
local gestures = {}
-- Update touch state
if event.type == "touchpress" then
self._touches[touchId] = {
startX = event.x,
startY = event.y,
x = event.x,
y = event.y,
startTime = event.timestamp,
lastTime = event.timestamp,
phase = "began",
}
-- Initialize gesture detection
self:_detectTapBegan(touchId, event)
self:_detectLongPressBegan(touchId, event)
elseif event.type == "touchmove" then
local touch = self._touches[touchId]
if touch then
touch.x = event.x
touch.y = event.y
touch.lastTime = event.timestamp
touch.phase = "moved"
-- Update gesture detection
local panGesture = self:_detectPan(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
local swipeGesture = self:_detectSwipe(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
-- Multi-touch gestures
if self:_getTouchCount() >= 2 then
local pinchGesture = self:_detectPinch(event)
if pinchGesture then
table.insert(gestures, pinchGesture)
end
local rotateGesture = self:_detectRotate(event)
if rotateGesture then
table.insert(gestures, rotateGesture)
end
end
end
elseif event.type == "touchrelease" then
local touch = self._touches[touchId]
if touch then
touch.phase = "ended"
-- Finalize gesture detection
local tapGesture = self:_detectTapEnded(touchId, event)
if tapGesture then
table.insert(gestures, tapGesture)
end
local swipeGesture = self:_detectSwipeEnded(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
local panGesture = self:_detectPanEnded(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
-- Cleanup touch
self._touches[touchId] = nil
end
elseif event.type == "touchcancel" then
-- Cancel all active gestures for this touch
self._touches[touchId] = nil
self:_cancelAllGestures()
end
return #gestures > 0 and gestures or nil
end
--- Get number of active touches
---@return number
function GestureRecognizer:_getTouchCount()
local count = 0
for _ in pairs(self._touches) do
count = count + 1
end
return count
end
--- Detect tap gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapBegan(touchId, event)
-- Tap detection happens on touch end
-- Just record the touch for now
end
--- Detect tap gesture ended
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid tap
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
local currentTime = event.timestamp
local doubleTapState = self._gestureStates.doubleTap
-- Check for double-tap
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
doubleTapState.tapCount = doubleTapState.tapCount + 1
if doubleTapState.tapCount >= 2 then
-- Fire double-tap gesture
return {
type = GestureType.DOUBLE_TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
else
doubleTapState.tapCount = 1
end
doubleTapState.lastTapTime = currentTime
-- Fire tap gesture
return {
type = GestureType.TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
end
--- Detect long-press gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectLongPressBegan(touchId, event)
-- Long-press detection happens continuously during touch
self._gestureStates.longPress[touchId] = {
startX = event.x,
startY = event.y,
startTime = event.timestamp,
triggered = false,
}
end
--- Detect pan gesture
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPan(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
local panState = self._gestureStates.pan[touchId]
if not panState then
-- Check if pan should begin
if distance >= self._config.panMinMovement then
self._gestureStates.pan[touchId] = {
active = true,
lastX = touch.startX,
lastY = touch.startY,
}
panState = self._gestureStates.pan[touchId]
return {
type = GestureType.PAN,
state = GestureState.BEGAN,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
else
-- Pan is active, fire changed event
local panDx = event.x - panState.lastX
local panDy = event.y - panState.lastY
panState.lastX = event.x
panState.lastY = event.y
return {
type = GestureType.PAN,
state = GestureState.CHANGED,
x = event.x,
y = event.y,
dx = panDx,
dy = panDy,
totalDx = dx,
totalDy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect pan ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPanEnded(touchId, event)
local panState = self._gestureStates.pan[touchId]
if panState and panState.active then
self._gestureStates.pan[touchId] = nil
local touch = self._touches[touchId]
local dx = event.x - touch.startX
local dy = event.y - touch.startY
return {
type = GestureType.PAN,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect swipe gesture
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectSwipe(touchId, event)
-- Swipe detection happens on touch end
end
--- Detect swipe ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectSwipeEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid swipe
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
local velocity = distance / duration
if velocity >= self._config.swipeMinVelocity then
-- Determine swipe direction
local angle = math.atan2(dy, dx)
local direction = "right"
if angle >= -math.pi / 4 and angle < math.pi / 4 then
direction = "right"
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
direction = "down"
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
direction = "up"
else
direction = "left"
end
return {
type = GestureType.SWIPE,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
direction = direction,
velocity = velocity,
timestamp = event.timestamp,
}
end
end
return nil
end
--- Detect pinch gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPinch(event)
-- Get two touches for pinch
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current distance
local currentDx = t2.x - t1.x
local currentDy = t2.y - t1.y
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
-- Calculate initial distance
local initialDx = t2.startX - t1.startX
local initialDy = t2.startY - t1.startY
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
if initialDistance == 0 then
return nil
end
-- Calculate scale
local scale = currentDistance / initialDistance
local pinchState = self._gestureStates.pinch
if not pinchState.active then
-- Check if pinch should begin
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
pinchState.active = true
pinchState.initialScale = scale
pinchState.lastScale = scale
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.PINCH,
state = GestureState.BEGAN,
scale = scale,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Pinch is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local scaleChange = scale - pinchState.lastScale
pinchState.lastScale = scale
return {
type = GestureType.PINCH,
state = GestureState.CHANGED,
scale = scale,
scaleChange = scaleChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect rotate gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectRotate(event)
-- Get two touches for rotation
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current angle
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
-- Calculate initial angle
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
-- Calculate rotation (in degrees)
local rotation = (currentAngle - initialAngle) * 180 / math.pi
local rotateState = self._gestureStates.rotate
if not rotateState.active then
-- Check if rotation should begin
if math.abs(rotation) >= self._config.rotateMinAngleChange then
rotateState.active = true
rotateState.initialRotation = rotation
rotateState.lastRotation = rotation
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.ROTATE,
state = GestureState.BEGAN,
rotation = rotation,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Rotation is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local rotationChange = rotation - rotateState.lastRotation
rotateState.lastRotation = rotation
return {
type = GestureType.ROTATE,
state = GestureState.CHANGED,
rotation = rotation,
rotationChange = rotationChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Cancel all active gestures
function GestureRecognizer:_cancelAllGestures()
for gestureType, state in pairs(self._gestureStates) do
if type(state) == "table" and state.active then
state.active = false
end
end
end
--- Reset gesture recognizer state
function GestureRecognizer:reset()
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = { active = false },
rotate = { active = false },
}
end
-- Export gesture types and states
GestureRecognizer.GestureType = GestureType
GestureRecognizer.GestureState = GestureState
return GestureRecognizer

Some files were not shown because too many files have changed in this diff Show More