Compare commits

...

23 Commits

Author SHA1 Message Date
bryanthaboi 9610bcfb72 Merge pull request #527 from bryanthaboi/dev
bug fixes and new features
2026-07-31 12:04:18 -04:00
bryanthaboi 1bc252741a rom finder 2026-07-31 11:58:21 -04:00
bryanthaboi 97e3fb296c Update tool_mod_hooks.lua 2026-07-31 10:45:12 -04:00
bryanthaboi 5ec440792e Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks
Add lifecycle and HUD hooks for tool mods
2026-07-31 10:29:04 -04:00
bryanthaboi 9d3ee6a597 mod manager now supports auto updates 2026-07-31 10:19:51 -04:00
bryanthaboi 6c4c9d14e2 Merge pull request #520 from johnjohto/fix-true-color-evolution-trade-494
Fix true-color evolution and trade sprites
2026-07-31 10:18:44 -04:00
bryanthaboi d165189032 Merge pull request #521 from johnjohto/fix-fighting-dojo-master-gate-495
Fix Karate Master gate
2026-07-31 10:08:02 -04:00
johnjohto 84bf25fcd5 Fix Karate Master gate 2026-07-31 09:47:53 -04:00
bryanthaboi 3945b9d078 on screen controller editing CLOSES #327 2026-07-31 09:23:38 -04:00
bryanthaboi 6796804593 CLOSES #505, CLOSES #510, CLOSES #513 2026-07-31 09:08:56 -04:00
johnjohto 858c5bc9b8 Fix true-color evolution and trade sprites 2026-07-31 09:05:19 -04:00
bryanthaboi 71bdbb7e48 CLOSES #519 2026-07-31 08:27:29 -04:00
bryanthaboi c346c78697 Merge pull request #499 from johnjohto/fix-battle-menu-cursor
Fix battle menu cursor edges
2026-07-31 08:14:55 -04:00
bryanthaboi ba2bac5bed Merge branch 'dev' into fix-battle-menu-cursor 2026-07-31 08:14:08 -04:00
bryanthaboi bcfc94d448 Merge pull request #366 from pmarcus93/android-respect-rotation-lock
Respect the device rotation lock on Android
2026-07-31 08:08:19 -04:00
bryanthaboi bebfc87cd4 yellow palette / pikachu yellow 2026-07-31 08:07:23 -04:00
bryanthaboi e58929428c Merge pull request #498 from johnjohto/fix-cerulean-badge-house-exit 2026-07-30 18:24:26 -04:00
johnjohto a389416b7b Fix battle menu cursor edges
Battle commands toggled directly between rows and columns. An outward press therefore moved the cursor to the opposite command instead of leaving it at the edge.
2026-07-30 17:53:53 -04:00
johnjohto 0a8bca1a6d Fix Cerulean badge menu exit
The selected badge list stayed on the stack while its description opened. Cancelling the next list only closed that newer menu, leaving the NPC conversation open.
2026-07-30 17:49:02 -04:00
Marcus Pereira cd33858020 Respect the device rotation lock on Android
The manifest asks for android:screenOrientation="fullUser", but SDL
overrides it at window creation: SDLActivity.setOrientationBis, given a
resizable window and no SDL_HINT_ORIENTATIONS -- which is what conf.lua
produces on Android -- requests SCREEN_ORIENTATION_FULL_SENSOR. The
*_SENSOR constants follow the accelerometer even when the player has
turned auto-rotate off, so the game rotated anyway on a locked device.

Override setOrientationBis in GameActivity to remap SDL's request onto
the matching *_USER constant after super has run. The same orientations
stay allowed and SDL keeps deciding which ones those are; only the
tie-break changes, from the sensor to the system rotation setting.
2026-07-28 23:22:35 -03:00
Kevin Jacobson fb58fa788b Expose window margins to tool HUDs 2026-07-28 14:09:46 -07:00
Kevin Jacobson d09cf80da5 Add persistent HUD hook for tool mods 2026-07-28 13:58:42 -07:00
Kevin Jacobson aab980b05a Add lifecycle hooks for tool mods 2026-07-28 13:33:53 -07:00
56 changed files with 6721 additions and 278 deletions
+4 -1
View File
@@ -62,7 +62,10 @@ function love.conf(t)
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
+686
View File
@@ -0,0 +1,686 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
-- per palette (color 0 first), plus MonsterPalettes.
return {
cgbBase = {
["0F"] = {
{ 255, 255, 255 },
{ 107, 8, 255 },
{ 0, 74, 255 },
{ 8, 8, 8 },
},
BADGE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
BLACK = {
{ 255, 255, 255 },
{ 25, 25, 25 },
{ 25, 25, 25 },
{ 25, 25, 25 },
},
BLUEMON = {
{ 255, 255, 255 },
{ 132, 148, 255 },
{ 0, 8, 206 },
{ 25, 25, 25 },
},
BROWNMON = {
{ 255, 255, 255 },
{ 239, 148, 82 },
{ 140, 74, 41 },
{ 25, 25, 25 },
},
CAVE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
CELADON = {
{ 255, 255, 255 },
{ 41, 255, 41 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CERULEAN = {
{ 255, 255, 255 },
{ 41, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CINNABAR = {
{ 255, 255, 255 },
{ 255, 66, 66 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CYANMON = {
{ 255, 255, 255 },
{ 132, 214, 255 },
{ 0, 140, 255 },
{ 25, 25, 25 },
},
FUCHSIA = {
{ 255, 255, 255 },
{ 255, 123, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
GAMEFREAK = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 156, 156, 0 },
{ 25, 25, 25 },
},
GRAYMON = {
{ 255, 255, 255 },
{ 165, 189, 82 },
{ 90, 90, 41 },
{ 25, 25, 25 },
},
GREENBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 0, 255, 0 },
{ 25, 25, 25 },
},
GREENMON = {
{ 255, 255, 255 },
{ 140, 255, 90 },
{ 8, 181, 49 },
{ 25, 25, 25 },
},
INDIGO = {
{ 255, 255, 255 },
{ 90, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LAVENDER = {
{ 255, 255, 255 },
{ 206, 33, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LOGO1 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 255, 0, 0 },
},
LOGO2 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 58, 58, 206 },
{ 0, 0, 140 },
},
MEWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 8, 8 },
{ 25, 25, 25 },
},
PALLET = {
{ 255, 255, 255 },
{ 189, 140, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PEWTER = {
{ 255, 255, 255 },
{ 148, 148, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 255 },
{ 74, 74, 74 },
{ 255, 173, 0 },
{ 25, 25, 25 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 255 },
{ 255, 148, 0 },
{ 156, 58, 8 },
{ 25, 25, 25 },
},
PINKMON = {
{ 255, 255, 255 },
{ 255, 123, 148 },
{ 255, 0, 49 },
{ 25, 25, 25 },
},
PURPLEMON = {
{ 255, 255, 255 },
{ 206, 123, 255 },
{ 156, 0, 181 },
{ 25, 25, 25 },
},
REDBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
REDMON = {
{ 255, 255, 255 },
{ 255, 140, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
ROUTE = {
{ 255, 255, 255 },
{ 132, 255, 33 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SAFFRON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SLOTS1 = {
{ 255, 255, 255 },
{ 206, 8, 255 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
SLOTS2 = {
{ 255, 255, 255 },
{ 255, 33, 156 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS3 = {
{ 255, 255, 255 },
{ 66, 255, 0 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS4 = {
{ 255, 255, 255 },
{ 0, 255, 255 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
TOWNMAP = {
{ 255, 255, 255 },
{ 0, 173, 255 },
{ 82, 230, 0 },
{ 8, 8, 8 },
},
VERMILION = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
VIRIDIAN = {
{ 255, 255, 255 },
{ 156, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
YELLOWBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 148, 0 },
{ 25, 25, 25 },
},
YELLOWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 230, 115, 0 },
{ 25, 25, 25 },
},
},
order = {
"ROUTE",
"PALLET",
"VIRIDIAN",
"PEWTER",
"CERULEAN",
"LAVENDER",
"VERMILION",
"CELADON",
"FUCHSIA",
"CINNABAR",
"INDIGO",
"SAFFRON",
"TOWNMAP",
"LOGO1",
"LOGO2",
"0F",
"MEWMON",
"BLUEMON",
"REDMON",
"CYANMON",
"PURPLEMON",
"BROWNMON",
"GREENMON",
"PINKMON",
"YELLOWMON",
"GRAYMON",
"SLOTS1",
"SLOTS2",
"SLOTS3",
"SLOTS4",
"BLACK",
"GREENBAR",
"YELLOWBAR",
"REDBAR",
"BADGE",
"CAVE",
"GAMEFREAK",
"PIKACHUS_BEACH",
"PIKACHU_PORTRAIT",
"PIKACHUS_BEACH_TITLE",
},
palettes = {
["0F"] = {
{ 255, 255, 247 },
{ 197, 165, 247 },
{ 90, 165, 247 },
{ 25, 16, 16 },
},
BADGE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
BLACK = {
{ 255, 255, 247 },
{ 49, 49, 49 },
{ 49, 49, 49 },
{ 49, 49, 49 },
},
BLUEMON = {
{ 255, 255, 247 },
{ 173, 181, 255 },
{ 74, 82, 165 },
{ 49, 49, 49 },
},
BROWNMON = {
{ 255, 255, 247 },
{ 214, 189, 148 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
CAVE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
CELADON = {
{ 255, 255, 247 },
{ 181, 255, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CERULEAN = {
{ 255, 255, 247 },
{ 181, 189, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CINNABAR = {
{ 255, 255, 247 },
{ 255, 123, 115 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CYANMON = {
{ 255, 255, 247 },
{ 214, 230, 255 },
{ 58, 197, 230 },
{ 49, 49, 49 },
},
FUCHSIA = {
{ 255, 255, 247 },
{ 255, 214, 214 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
GAMEFREAK = {
{ 255, 255, 247 },
{ 230, 197, 115 },
{ 165, 165, 90 },
{ 49, 49, 49 },
},
GRAYMON = {
{ 255, 255, 247 },
{ 206, 206, 148 },
{ 132, 132, 115 },
{ 49, 49, 49 },
},
GREENBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 0, 173, 0 },
{ 49, 49, 49 },
},
GREENMON = {
{ 255, 255, 247 },
{ 197, 230, 148 },
{ 107, 173, 123 },
{ 49, 49, 49 },
},
INDIGO = {
{ 255, 255, 247 },
{ 140, 140, 206 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LAVENDER = {
{ 255, 255, 247 },
{ 222, 189, 239 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LOGO1 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 173, 0, 33 },
{ 173, 0, 33 },
},
LOGO2 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 148, 148, 197 },
{ 58, 58, 132 },
},
MEWMON = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 222, 132, 132 },
{ 49, 49, 49 },
},
PALLET = {
{ 255, 255, 247 },
{ 230, 222, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PEWTER = {
{ 255, 255, 247 },
{ 189, 189, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 247 },
{ 132, 132, 132 },
{ 255, 206, 74 },
{ 49, 49, 49 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 247 },
{ 230, 189, 74 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
PINKMON = {
{ 255, 255, 247 },
{ 255, 197, 214 },
{ 255, 148, 173 },
{ 49, 49, 49 },
},
PURPLEMON = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 181, 123, 189 },
{ 49, 49, 49 },
},
REDBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
REDMON = {
{ 255, 255, 247 },
{ 255, 197, 90 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
ROUTE = {
{ 255, 255, 247 },
{ 189, 214, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SAFFRON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SLOTS1 = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
SLOTS2 = {
{ 255, 255, 247 },
{ 255, 189, 214 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS3 = {
{ 255, 255, 247 },
{ 189, 255, 165 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS4 = {
{ 255, 255, 247 },
{ 189, 239, 255 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
TOWNMAP = {
{ 255, 255, 247 },
{ 165, 214, 255 },
{ 140, 189, 82 },
{ 25, 16, 16 },
},
VERMILION = {
{ 255, 255, 247 },
{ 255, 206, 132 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
VIRIDIAN = {
{ 255, 255, 247 },
{ 214, 255, 173 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
YELLOWBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
YELLOWMON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
},
pokemon = {
ABRA = "YELLOWMON",
AERODACTYL = "GRAYMON",
ALAKAZAM = "YELLOWMON",
ARBOK = "PURPLEMON",
ARCANINE = "REDMON",
ARTICUNO = "BLUEMON",
BEEDRILL = "YELLOWMON",
BELLSPROUT = "GREENMON",
BLASTOISE = "CYANMON",
BULBASAUR = "GREENMON",
BUTTERFREE = "CYANMON",
CATERPIE = "GREENMON",
CHANSEY = "PINKMON",
CHARIZARD = "REDMON",
CHARMANDER = "REDMON",
CHARMELEON = "REDMON",
CLEFABLE = "PINKMON",
CLEFAIRY = "PINKMON",
CLOYSTER = "GRAYMON",
CUBONE = "GRAYMON",
DEWGONG = "BLUEMON",
DIGLETT = "BROWNMON",
DITTO = "GRAYMON",
DODRIO = "BROWNMON",
DODUO = "BROWNMON",
DRAGONAIR = "BLUEMON",
DRAGONITE = "BROWNMON",
DRATINI = "GRAYMON",
DROWZEE = "YELLOWMON",
DUGTRIO = "BROWNMON",
EEVEE = "GRAYMON",
EKANS = "PURPLEMON",
ELECTABUZZ = "YELLOWMON",
ELECTRODE = "YELLOWMON",
EXEGGCUTE = "PINKMON",
EXEGGUTOR = "GREENMON",
FARFETCHD = "BROWNMON",
FEAROW = "BROWNMON",
FLAREON = "REDMON",
GASTLY = "PURPLEMON",
GENGAR = "PURPLEMON",
GEODUDE = "GRAYMON",
GLOOM = "REDMON",
GOLBAT = "BLUEMON",
GOLDEEN = "REDMON",
GOLDUCK = "CYANMON",
GOLEM = "GRAYMON",
GRAVELER = "GRAYMON",
GRIMER = "PURPLEMON",
GROWLITHE = "BROWNMON",
GYARADOS = "BLUEMON",
HAUNTER = "PURPLEMON",
HITMONCHAN = "BROWNMON",
HITMONLEE = "BROWNMON",
HORSEA = "CYANMON",
HYPNO = "YELLOWMON",
IVYSAUR = "GREENMON",
JIGGLYPUFF = "PINKMON",
JOLTEON = "YELLOWMON",
JYNX = "MEWMON",
KABUTO = "BROWNMON",
KABUTOPS = "BROWNMON",
KADABRA = "YELLOWMON",
KAKUNA = "YELLOWMON",
KANGASKHAN = "BROWNMON",
KINGLER = "REDMON",
KOFFING = "PURPLEMON",
KRABBY = "REDMON",
LAPRAS = "CYANMON",
LICKITUNG = "PINKMON",
MACHAMP = "GRAYMON",
MACHOKE = "GRAYMON",
MACHOP = "GRAYMON",
MAGIKARP = "REDMON",
MAGMAR = "REDMON",
MAGNEMITE = "GRAYMON",
MAGNETON = "GRAYMON",
MANKEY = "BROWNMON",
MAROWAK = "GRAYMON",
MEOWTH = "YELLOWMON",
METAPOD = "GREENMON",
MEW = "MEWMON",
MEWTWO = "MEWMON",
MOLTRES = "REDMON",
MR_MIME = "PINKMON",
MUK = "PURPLEMON",
NIDOKING = "PURPLEMON",
NIDOQUEEN = "BLUEMON",
NIDORAN_F = "BLUEMON",
NIDORAN_M = "PURPLEMON",
NIDORINA = "BLUEMON",
NIDORINO = "PURPLEMON",
NINETALES = "YELLOWMON",
ODDISH = "GREENMON",
OMANYTE = "BLUEMON",
OMASTAR = "BLUEMON",
ONIX = "GRAYMON",
PARAS = "REDMON",
PARASECT = "REDMON",
PERSIAN = "YELLOWMON",
PIDGEOT = "BROWNMON",
PIDGEOTTO = "BROWNMON",
PIDGEY = "BROWNMON",
PIKACHU = "YELLOWMON",
PINSIR = "BROWNMON",
POLIWAG = "BLUEMON",
POLIWHIRL = "BLUEMON",
POLIWRATH = "BLUEMON",
PONYTA = "REDMON",
PORYGON = "GRAYMON",
PRIMEAPE = "BROWNMON",
PSYDUCK = "YELLOWMON",
RAICHU = "YELLOWMON",
RAPIDASH = "REDMON",
RATICATE = "GRAYMON",
RATTATA = "GRAYMON",
RHYDON = "GRAYMON",
RHYHORN = "GRAYMON",
SANDSHREW = "BROWNMON",
SANDSLASH = "BROWNMON",
SCYTHER = "GREENMON",
SEADRA = "CYANMON",
SEAKING = "REDMON",
SEEL = "BLUEMON",
SHELLDER = "GRAYMON",
SLOWBRO = "PINKMON",
SLOWPOKE = "PINKMON",
SNORLAX = "PINKMON",
SPEAROW = "BROWNMON",
SQUIRTLE = "CYANMON",
STARMIE = "GRAYMON",
STARYU = "REDMON",
TANGELA = "BLUEMON",
TAUROS = "GRAYMON",
TENTACOOL = "CYANMON",
TENTACRUEL = "CYANMON",
VAPOREON = "CYANMON",
VENOMOTH = "PURPLEMON",
VENONAT = "PURPLEMON",
VENUSAUR = "GREENMON",
VICTREEBEL = "GREENMON",
VILEPLUME = "REDMON",
VOLTORB = "YELLOWMON",
VULPIX = "REDMON",
WARTORTLE = "CYANMON",
WEEDLE = "YELLOWMON",
WEEPINBELL = "GREENMON",
WEEZING = "PURPLEMON",
WIGGLYTUFF = "PINKMON",
ZAPDOS = "YELLOWMON",
ZUBAT = "BLUEMON",
},
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
}
@@ -42,6 +42,7 @@ local function middleAgedMan(game, ow, npc, done)
end
local menu = ListMenu.new(game, "", items, {
onChoose = function(item)
game.stack:pop()
push(game, t[BADGE_TEXT[item.value]], loop)
end,
onCancel = function()
+33 -18
View File
@@ -3,6 +3,10 @@
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
-- math.random pick each time the NPC is talked to.
--
-- Yellow renames Slowbro -> Electrode in the text labels (see
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
-- share this script.
local M = {}
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
game.stack:push(TextBox.new(game, text, done))
end
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
local function talkMon(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
or t._CeruleanCityElectrodeTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
or t._CeruleanCityElectrodeIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
or t._CeruleanCityElectrodeTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
or t._CeruleanCityElectrodeIgnoredOrdersText
end
push(game, ow, npc, done, text)
end
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
local text
if roll >= 180 then
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
elseif roll >= 100 then
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
or t._CeruleanCityCooltrainerF1ElectrodePunchText
else
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
end
push(game, ow, npc, done, text)
end,
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
end
push(game, ow, npc, done, text)
end,
TEXT_CERULEANCITY_SLOWBRO = talkMon,
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
TEXT_CERULEANCITY_ELECTRODE = talkMon,
},
}
+23
View File
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
end
end
-- FightingDojoDefaultScript does not use the Master's facing direction for
-- this battle. It checks exactly the cell left of him, turns both sprites
-- toward one another, and then displays his trainer text. Keeping this out
-- of trainer sight also lets him keep his original downward-facing pose.
local function dojoMasterGate(game, ow, x, y)
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
return false
end
local master
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
master = npc
break
end
end
if not master or ow:trainerDefeated(master) then return false end
ow.player.facing = "right"
master:facePlayer(ow.player)
ow:engageTrainer(master)
return true
end
M.FIGHTING_DOJO = {
talk = {
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
end
return false
end,
onStep = dojoMasterGate,
}
-- -------------------------------------------------------------------
+20
View File
@@ -167,5 +167,25 @@ The console understands these verbs (anything else is evaluated as Lua):
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
## Tool input and title-menu hooks
Tool mods that need to act once per game logic tick can wrap `input.step`.
It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`.
`ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins.
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
progress write before world state is captured or any bytes reach disk.
`render.hud` receives `(next, game, viewport)` after the finished game frame is
composited and before touch controls draw. The window-space viewport contains
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+29 -6
View File
@@ -86,14 +86,20 @@ Game Boy equivalent:
## 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**.
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**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **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".)
@@ -265,6 +271,14 @@ 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, **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 rotation keeps the
relative placement. In-game, Options → **TOUCH PAD** toggles the same
on/off flag without leaving a play session.
## Translation support
Every string the player can read is now reachable from a mod, so a
@@ -367,3 +381,12 @@ 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.
+71 -13
View File
@@ -10,7 +10,7 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local Game, EditorApp, Importer
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
@@ -134,6 +134,26 @@ function closeEditor()
end
end
-- ------------------------------------------------------------ touch controls editor
-- Suspends the launcher while the player drags on-screen buttons / toggles
-- the overlay off (#327). No ROM cache needed -- options.lua only.
local touchEditorHost
local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
touchEditorHost = Importer
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
end
function closeTouchControlsEditor()
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
TouchEditor = nil
Importer = touchEditorHost
touchEditorHost = nil
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
@@ -239,11 +259,17 @@ function love.load(args)
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
})
end
function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
@@ -283,6 +309,7 @@ end
function love.draw()
if editorMode then return EditorApp.draw() end
if TouchEditor then return TouchEditor.draw() end
if Importer then return Importer:draw() end
Game:draw()
@@ -303,60 +330,61 @@ end
function love.keypressed(key, scancode, isrepeat)
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Importer then return Importer:keypressed(key) end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:keyreleased(key)
end
function love.gamepadpressed(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickremoved(joystick)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickremoved(joystick)
end
@@ -365,7 +393,7 @@ end
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
@@ -375,13 +403,19 @@ end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y)
end
if Importer then
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
-- and love.mousepressed below forwards that to the Importer, so
@@ -399,12 +433,20 @@ end
function love.touchmoved(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y)
end
if Importer then return end
Game:touchmoved(id, x, y)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y)
end
if Importer then return end
Game:touchreleased(id, x, y)
end
@@ -414,11 +456,18 @@ function love.wheelmoved(x, y)
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
return
end
if TouchEditor then return end
if Importer then return end
Game:wheelmoved(x, y)
end
function love.mousepressed(x, y, button)
if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag.
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Importer then return Importer:mousepressed(x, y, button) end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
@@ -429,6 +478,10 @@ function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -439,6 +492,10 @@ function love.mousereleased(x, y, button)
end
function love.mousemoved(x, y)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
@@ -446,6 +503,7 @@ function love.mousemoved(x, y)
end
function love.textinput(text)
if TouchEditor then return end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
+1 -1
View File
@@ -82,7 +82,7 @@ scripts, tests, and mobile build sources are excluded.
| --- | --- |
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `portrait` |
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
@@ -41,6 +41,7 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.media.AudioManager;
@@ -303,6 +304,54 @@ public class GameActivity extends SDLActivity {
super.onResume();
}
/**
* SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no
* SDL_HINT_ORIENTATIONS -- exactly what conf.lua produces on Android --
* it asks for SCREEN_ORIENTATION_FULL_SENSOR, and that request overrides
* the android:screenOrientation="fullUser" set in the manifest. The
* *_SENSOR constants follow the accelerometer even when the player has
* turned auto-rotate off, so the game kept rotating on a device whose
* rotation was locked.
*
* Remap SDL's choice onto the matching *_USER constant, which allows the
* same orientations but defers to the system rotation setting. Applied
* after super so SDL keeps deciding *which* orientations the window may
* take; this only changes who breaks the tie, the sensor or the player.
*/
@Override
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
super.setOrientationBis(w, h, resizable, hint);
// The *_USER constants only exist from API 18; below that the sensor
// ones are all there is, so leave SDL's request alone.
if (android.os.Build.VERSION.SDK_INT < 18) {
return;
}
int requested = getRequestedOrientation();
int userRequested;
switch (requested) {
case ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR:
userRequested = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
break;
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE:
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
break;
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT:
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
break;
default:
// SENSOR / plain LANDSCAPE / PORTRAIT etc: either already
// explicit or never produced by setOrientationBis.
return;
}
Log.d("GameActivity", "requestedOrientation " + requested + " -> " + userRequested
+ " (honour the device rotation lock)");
setRequestedOrientation(userRequested);
}
@Keep
public void setImmersiveMode(boolean immersive_mode) {
if (android.os.Build.VERSION.SDK_INT >= 28) {
+13
View File
@@ -0,0 +1,13 @@
# Nuzlocke
An enforced Gen 1 Nuzlocke.
Oak configures Slow Start, whether duplicate evolutionary families are skipped
or consume an area's encounter, and whether Safari maps are separate areas.
After Slow Start, the mod enforces mandatory nicknames for starters, gifts,
and catches; one capture per area; no duplicate evolutionary families; and
permanent death. A party Pokémon says that it died and is removed immediately.
If the final party member dies, the game runs the credits to THE END and then
deletes the active save.
+224
View File
@@ -0,0 +1,224 @@
-- Nuzlocke rules. Uses the current engine's internal seams while the
-- equivalent public API hooks are being added.
return function(mod)
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "nuzlocke_intro", kind = "say", pic = "oak",
text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.",
})
mod.ui.insertStepAfter(steps, "nuzlocke_intro", {
id = "nuzlocke_slow_start", kind = "yesno", pic = "oak",
saveKey = "slow_start", defaultNo = true,
text = "Use SLOW START?\nRules start with\nPOKé BALLS.",
})
mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", {
id = "nuzlocke_dupes", kind = "choice", pic = "oak",
saveKey = "dupes_mode", text = "When you meet a\nknown family?",
choices = { "SKIP", "LOSE" }, values = { "skip", "strict" },
})
mod.ui.insertStepAfter(steps, "nuzlocke_dupes", {
id = "nuzlocke_safari", kind = "yesno", pic = "oak",
saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?",
})
mod.ui.insertStepAfter(steps, "nuzlocke_safari", {
id = "nuzlocke_close", kind = "say", pic = "oak",
text = "Give every friend\na name. Keep them\nsafe. Good luck!",
})
return steps
end)
mod.events:on("intro.oak_speech.answered", function(ev)
if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
end)
local function active(game, battle)
if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end
if not mod.save:get("slow_start", false) then return true end
if mod.save:get("balls_unlocked", false) then return true end
for id, count in pairs(game.save.inventory or {}) do
if count > 0 and game.data.items[id] and game.data.items[id].ball then
mod.save:set("balls_unlocked", true)
return true
end
end
return false
end
local function areaKey(game, battle)
if battle and battle.safari and not mod.save:get("safari_sectors", false) then
return "SAFARI_ZONE"
end
return (game.overworld and game.overworld.map and game.overworld.map.id)
or (game.save.player and game.save.player.map) or "UNKNOWN"
end
local function family(data, species)
local found, pending = {}, { species }
while #pending > 0 do
local id = table.remove(pending)
if not found[id] then
found[id] = true
for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end
for parent, def in pairs(data.pokemon or {}) do
for _, evo in ipairs(def.evolutions or {}) do
if evo.species == id then pending[#pending + 1] = parent end
end
end
end
end
return found
end
local function ownsFamily(game, species)
local members = family(game.data, species)
local function owns(mon) return mon and members[mon.species] end
for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end
for _, box in ipairs(game.save.boxes or {}) do
for _, mon in ipairs(box) do if owns(mon) then return true end end
end
return false
end
local function caughtAreas()
local areas = mod.save:get("caught_areas")
if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end
return areas
end
local function denied(game, battle, species)
if not active(game, battle) then return nil end
if caughtAreas()[areaKey(game, battle)] then return "area" end
if ownsFamily(game, species) then return "dupes" end
end
mod.events:on("pokemon.caught", function(ev)
-- A successful capture proves that Slow Start has ended even when it was
-- the last ball in the bag.
if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end
if active(ev.game, ev.battle) then
caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species
mod.save:set("caught_areas", caughtAreas())
end
end)
mod.events:on("game.ready", function()
local BattleState = require("src.battle.BattleState")
local Commands = require("src.script.Commands")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local Bag = require("src.inventory.Bag")
BattleState.askNicknameUI = function(self, mon)
self.lockedBall, self.blankForAskName = nil, false
return self:buildScreen("NamingScreen", {
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) mon.nickname = name or "A" end,
})
end
-- Gifts and starters use the same mandatory naming screen.
Commands.give_pokemon = function(ctx, species, level)
local gift = { ctx = ctx, species = species, level = level }
if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end
local mon = Pokemon.new(ctx.game.data, gift.species, gift.level)
ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species
BattleState.stampOT(ctx.save, mon)
local inParty = Party.add(ctx.save.party, mon)
local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon)
if not inParty and not boxNum then ctx.lastCheck = false; return end
if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end
ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum
if ctx.runner then
Screens.push(ctx.game, "NamingScreen", {
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end,
})
ctx.runner:yield()
else mon.nickname = "A" end
if boxNum then
ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum)
if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end
end
end
local vanillaThrowBall = BattleState.throwBall
BattleState.throwBall = function(self, ball)
local reason = denied(self.game, self, self.enemy and self.enemy.mon.species)
if reason then
if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
end
return vanillaThrowBall(self, ball)
end
local vanillaOnFaint = BattleState.onFaint
BattleState.onFaint = function(self, battler)
if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end
if battler.faintQueued then return end
battler.faintQueued = true
if self.participants then self.participants[battler.mon] = nil end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
for i, mon in ipairs(self.game.save.party) do
if mon == battler.mon then table.remove(self.game.save.party, i); break end
end
self:actNext(function()
battler.fainted = true
require("src.core.Sound").playCry(self.data, battler.mon.species)
require("src.core.Sound").play(self.data, "Faint_Fall")
self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 }
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 30 })
self:sayNext(Strings("%s\ndied!", battler.name))
self:act(function() self:playerMonFainted() end)
end
local vanillaPlayerFainted = BattleState.playerMonFainted
BattleState.playerMonFainted = function(self)
if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then
self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish"
self:sayNext(Strings("All of your\nPOKéMON are dead..."))
return
end
return vanillaPlayerFainted(self)
end
local vanillaFinish = BattleState.finish
BattleState.finish = function(self)
if not self.nuzlockeGameOver then return vanillaFinish(self) end
self.nuzlockeGameOver = nil
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" })
-- Game over has no victory lap: delete the slot, then use Credits only
-- as its existing THE END renderer / A-or-B wait screen.
local version = GameVersion.get()
local slot = SaveData.activeSlot(version)
if slot then SaveData.deleteSlot(version, slot)
elseif love and love.filesystem then
local main = SaveData.saveFilename(version)
love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp")
end
local ending = Screens.push(self.game, "Credits", function()
require("src.core.Music").stop()
while self.game.stack:top() do self.game.stack:pop() end
Screens.push(self.game, "IntroMovie", function()
if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end
end)
end)
ending.phase, ending.timer = "end_wait", 0
end
end)
end
+19
View File
@@ -0,0 +1,19 @@
{
"id": "nuzlocke",
"name": "Nuzlocke",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"game_version": ">=0.0.0-dev <1.0.0",
"priority": 100,
"permissions": [
"engine_internals"
],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.",
"github": "bryanthaboi/nuzlocke"
}
+19 -9
View File
@@ -1507,10 +1507,14 @@ function BattleState:update(dt)
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
if input:wasPressed("left") then
col = math.max(0, col - 1)
elseif input:wasPressed("right") then
col = math.min(1, col + 1)
elseif input:wasPressed("up") then
row = math.max(0, row - 1)
elseif input:wasPressed("down") then
row = math.min(1, row + 1)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
@@ -1539,10 +1543,14 @@ function BattleState:update(dt)
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
if input:wasPressed("left") then
col = math.max(0, col - 1)
elseif input:wasPressed("right") then
col = math.min(1, col + 1)
elseif input:wasPressed("up") then
row = math.max(0, row - 1)
elseif input:wasPressed("down") then
row = math.min(1, row + 1)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
@@ -4483,7 +4491,9 @@ function BattleState:sgbBattlePals()
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
if PaletteFX.mode == "ogred" then
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
+3 -3
View File
@@ -144,8 +144,8 @@ end
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
-- battle from the single tile to his left (#495),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
@@ -160,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
range = 0,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
+19 -2
View File
@@ -172,6 +172,11 @@ function Game:returnToTitle()
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
-- Input:step promotes queued edges so a button chosen here is visible to
-- this logic tick, not one tick later. With no wrapper this is a no-op.
ModRuntime.call("input.step", function() end, self, dt)
self.input:step()
-- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
@@ -339,7 +344,13 @@ function Game:draw()
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
local viewport = Renderer:endFrame(zones, worldZones)
-- Persistent tool status is screen-space UI: draw it over the completed
-- render pipeline with exact playfield/margin geometry, but below mobile
-- controls. It never becomes an updating game state.
if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport)
end
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
@@ -578,6 +589,11 @@ end
-- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave()
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
-- before captureSave mutates the snapshot or any progress bytes reach disk.
if ModRuntime.call("save.write", function() return true end, self) == false then
return false
end
if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save)
end
@@ -588,7 +604,7 @@ function Game:writeSave()
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
SaveData.save(self.save)
return SaveData.save(self.save)
end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
@@ -619,6 +635,7 @@ function Game:applyOptions(opts)
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
TouchControls:applyOptions(opts)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
+18
View File
@@ -249,6 +249,24 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
-- GitHub release checks for mods with a manifest "github" field
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
modUpdateCache = {},
-- Community mod indexes the player has chosen to browse
-- (src/mods/ModIndex.lua), in the order they added them. Empty by
-- default and never populated automatically: adding an index is how a
-- player says they trust whoever publishes it, so the launcher asks
-- rather than shipping one. Rows are { url, feed, base, fallback,
-- label }.
modIndexes = {},
-- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how
-- often the feeds themselves rebuild.
modIndexCache = {},
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
-- positions are optional normalized centers {x=0..1, y=0..1} per control
-- (dpad/a/b/start/select); nil means the default layout.
touchControls = { enabled = true },
}
end
+195 -48
View File
@@ -12,6 +12,11 @@
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
-- those; applyOptions reads them at boot and whenever options change.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
-- merges cleanly with a keyboard key or stick holding the same button,
@@ -39,6 +44,7 @@ local DPAD_DEAD = 0.16
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local CONTROLS = { "dpad", "a", "b", "start", "select" }
local IMAGES = {
dpad = "assets/touch/dpad.png",
@@ -52,6 +58,12 @@ local IMAGES = {
select = "assets/touch/select.png",
}
local function clamp01(v)
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
@@ -60,8 +72,59 @@ local function wantsOverlay()
return osName == "Android" or osName == "iOS"
end
-- Normalize a persisted touchControls table into {enabled, positions}.
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
-- the overlay.
function TouchControls.normalizeConfig(tc)
local out = { enabled = true, positions = nil }
if type(tc) ~= "table" then return out end
if tc.enabled == false then out.enabled = false end
if type(tc.positions) == "table" then
local pos = {}
for _, name in ipairs(CONTROLS) do
local p = tc.positions[name]
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
end
end
if next(pos) then out.positions = pos end
end
return out
end
-- Pure default layout in LOVE units for a given window size. Shared by
-- layout() and the editor's Reset path so defaults stay in one place.
function TouchControls.defaultLayout(ww, wh)
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
end
local function loadImages()
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then return nil end
im:setFilter("linear", "linear")
img[name] = im
end
return img
end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
self.positions = nil
self.preview = false
self.controllerHidden = false
self.touches = {}
-- per-GB-button owner count: two fingers on A must not double-press it,
@@ -70,51 +133,86 @@ function TouchControls:init()
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.img = nil
if not self.active then return end
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
-- stays off and keyboard/controller play still works
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then
img = nil
break
end
-- smooth UI icons; the global default filter is nearest for GB pixels
im:setFilter("linear", "linear")
img[name] = im
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
if self.active then
self.img = loadImages()
end
end
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
-- is false (desktop without POKEPORT_TOUCH).
function TouchControls:ensureImages()
if self.img then return true end
self.img = loadImages()
return self.img ~= nil
end
-- Apply options.touchControls. Called from Game:applyOptions and from
-- the launcher editor after a save.
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
end
end
function TouchControls:config()
return {
enabled = self.enabled ~= false,
positions = self.positions,
}
end
-- Preview mode: force-draw the overlay for the layout editor, ignoring
-- platform / enabled / gamepad gates. Gameplay input still respects
-- enabled via touchpressed.
function TouchControls:setPreview(on)
self.preview = on and true or false
if on then
self:ensureImages()
self.controllerHidden = false
end
self.img = img
end
function TouchControls:visible()
return self.active and self.img ~= nil and not self.controllerHidden
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
end
local function clampZone(zone, ww, wh)
local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx))
zone.cy = math.max(half, math.min(wh - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center.
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center. Custom positions (normalized 0..1) override
-- centers while sizes stay derived from the short edge.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh then return self.L end
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
self.layoutW, self.layoutH = ww, wh
local short = math.min(ww, wh)
-- ~a third of the short edge, capped so tablets don't get a dinner plate
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
-- START/SELECT hug the bottom center: on a narrow portrait phone the
-- d-pad and B leave little room, so a tight pair is what keeps them off
-- the neighboring controls
self.L = {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
self.L = TouchControls.defaultLayout(ww, wh)
if self.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = p.x * ww
zone.cy = p.y * wh
clampZone(zone, ww, wh)
end
end
end
local ssW = self.L.start.w
local fontSize = math.max(8, math.floor(ssW * 0.26))
if not self.labelFont or self.fontSize ~= fontSize then
self.fontSize = fontSize
@@ -123,12 +221,45 @@ function TouchControls:layout()
return self.L
end
-- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh)
self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
return dx * dx + dy * dy <= r * r
end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
end
local dz = L.dpad
local half = dz.w * 0.65
if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
return "dpad"
end
return nil
end
local function dpadDir(zone, x, y)
local dx, dy = x - zone.cx, y - zone.cy
local dead = zone.w * DPAD_DEAD
@@ -165,7 +296,9 @@ local function setDpad(self, touch, dir)
end
function TouchControls:touchpressed(id, x, y)
if not (self.active and self.img) then return end
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) then return end
-- a controller hid the overlay; the first touch only brings it back
if self.controllerHidden then
self.controllerHidden = false
@@ -192,6 +325,7 @@ function TouchControls:touchpressed(id, x, y)
end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
@@ -200,6 +334,7 @@ function TouchControls:touchmoved(id, x, y)
end
function TouchControls:touchreleased(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
@@ -216,7 +351,7 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for btn in pairs(self.held) do
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
self.held = {}
@@ -225,9 +360,13 @@ function TouchControls:reset()
end
-- a gamepad is being used: hide the overlay (dropping anything it held)
-- until the next screen touch asks for it back
-- until the next screen touch asks for it back. No-op when the player
-- permanently disabled the overlay -- there is nothing to hide, and a
-- later accidental touch must not resurrect it.
function TouchControls:noteGamepad()
if not self.active or self.controllerHidden then return end
if not self.active or self.enabled == false or self.controllerHidden then
return
end
self.controllerHidden = true
self:reset()
end
@@ -242,40 +381,46 @@ function TouchControls:joystickremoved()
end
end
local function drawIcon(img, zone, pressed)
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
local function drawIcon(img, zone, pressed, alphaMul)
alphaMul = alphaMul or 1
love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul)
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
local scale = zone.w / img:getWidth()
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul)
love.graphics.draw(img, zone.cx - zone.w / 2,
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
-- Also used by the launcher layout editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1
love.graphics.push("all")
love.graphics.origin()
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
local dir = dpadTouch and dpadTouch.dir
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
dir ~= nil)
dir ~= nil, alphaMul)
for _, btn in ipairs(BUTTONS) do
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul)
end
-- the +/- glyphs alone don't say which is which; shadowed so the text
-- reads on both the black letterbox and battle's white one
-- reads on both the black letterbox and battle's white one. Each label
-- tracks its own control's cy/w so dragging START cannot move SELECT.
love.graphics.setFont(self.labelFont)
local ly = L.start.cy + L.start.w * 0.66
local function label(text, zone)
local ly = zone.cy + zone.w * 0.66
local w = self.labelFont:getWidth(text)
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.setColor(0, 0, 0, 0.6 * alphaMul)
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul)
love.graphics.print(text, zone.cx - w / 2, ly)
end
label("START", L.start)
@@ -284,4 +429,6 @@ function TouchControls:draw()
love.graphics.pop()
end
TouchControls.CONTROLS = CONTROLS
return TouchControls
+1618 -81
View File
File diff suppressed because it is too large Load Diff
+99 -11
View File
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
-- missing entry means enabled, matching the loader -- except experimental
-- mods, which stay off until the player opts in
if mods[m.id] == false then
-- stay off
elseif mods[m.id] == true then
enabledSet[m.id] = true
elseif not m.experimental then
enabledSet[m.id] = true
end
end
local out = {}
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
local badge = tostring(raw.category or m.profile or "MOD"):upper()
if m.experimental then badge = "EXPERIMENTAL" end
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
badge = badge,
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
}
end
return out
@@ -231,8 +241,15 @@ end
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
return {}
end
return result or {}
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
@@ -454,13 +471,22 @@ function LauncherMods.strays() return scanStrays(false) end
-- on the player's behalf is not this function's call to make.
function LauncherMods.adoptStrays() return scanStrays(true) end
-- installZip(source) -> true, id | nil, errString
-- installZip(source [, opts]) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
end
function LauncherMods._installZipInner(source, opts)
opts = opts or {}
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
@@ -501,12 +527,24 @@ function LauncherMods.installZip(source)
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
if opts.expectId and manifest.id ~= opts.expectId then
cleanup()
return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
if not opts.replace then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
-- drop the old tree before copy; enable-flag is preserved (uninstall
-- would clear it, which would surprise an update)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest)
CacheFs.prefix = savedPrefix
end
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
@@ -529,6 +567,56 @@ function LauncherMods.installZip(source)
return true, manifest.id
end
-- Install (or replace) a mod from a GitHub release zip URL.
-- Returns true, version | nil, errString. Soft-fails: download / install /
-- cleanup errors never throw into the launcher UI.
function LauncherMods.installFromRelease(modId, release)
local ok, result, err = pcall(function()
if type(modId) ~= "string" or modId == "" then
return nil, "missing mod id"
end
if type(release) ~= "table" or not release.zip or not release.zip.url then
return nil, "release has no downloadable .zip"
end
local ModUpdate = require("src.mods.ModUpdate")
local tmpName = ("mod_update_%s_%s.zip"):format(
tostring(modId), tostring(release.version or os.time()))
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
if not localPath then return nil, dlErr end
local installed, res = LauncherMods.installZip(localPath, {
replace = true, expectId = modId,
})
pcall(love.filesystem.remove, localPath)
if not installed then return nil, res end
return true, release.version or res
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
-- The index only ever tells us WHERE the zip is; resolving that URL is
-- ModIndex's job and installing it is installFromRelease's, so this is the
-- seam between them and nothing about the archive is special-cased. expectId
-- comes from the listing, so a feed that points an entry at somebody else's
-- zip fails the manifest check instead of installing the wrong mod.
-- Returns true, version | nil, errString.
function LauncherMods.installFromIndex(entry)
local ok, result, err = pcall(function()
if type(entry) ~= "table" or type(entry.id) ~= "string" then
return nil, "index entry has no mod id"
end
local ModIndex = require("src.mods.ModIndex")
local release, why = ModIndex.releaseFor(entry)
if not release then
return nil, why or "this mod cannot be installed from the index"
end
return LauncherMods.installFromRelease(entry.id, release)
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
+12
View File
@@ -834,6 +834,18 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data)
self:_loadState()
self:_discover()
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {}
for id, mod in pairs(self.mods) do
if not self.disabled[id] and modsOpt[id] == nil
and mod.manifest.experimental then
self.disabled[id] = true
end
end
end
for id, mod in pairs(self.mods) do
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
+25 -6
View File
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end }
end
if m.github then
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
end
if m.experimental then
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
end
if m.error then
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end }
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
self:enabledSet())
end
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
self:openBlocked(r)
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
self:openCascade(r, m, want)
else
self:commitToggle(r.apply)
local function proceed()
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
self:openBlocked(r)
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
self:openCascade(r, m, want)
else
self:commitToggle(r.apply)
end
end
-- Experimental mods ask once on enable; disable is silent.
if want and m.experimental then
self:openConfirm({
"EXPERIMENTAL MOD",
"THIS MOD IS MARKED",
"EXPERIMENTAL.",
"ENABLE ANYWAY?",
}, proceed)
return
end
proceed()
end
function ManagerState:commitToggle(apply)
+49 -2
View File
@@ -50,6 +50,42 @@ local function parseSpecs(list, field)
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
if value == nil or value == "" then return nil end
assert(type(value) == "string", "github must be a string")
local trimmed = value:match("^%s*(.-)%s*$") or value
if trimmed == "" then return nil end
local owner, repo = trimmed:match(
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
if not owner then
owner, repo = trimmed:match(
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
end
if not owner then
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
end
assert(owner and repo and owner ~= "" and repo ~= "",
"github must be owner/repo or a github.com URL")
repo = repo:gsub("%.git$", "")
return owner .. "/" .. repo
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
for _, entry in ipairs(list) do
if not seen[entry] then
seen[entry] = true
out[#out + 1] = entry
end
end
end
return out
end
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
@@ -86,6 +122,12 @@ function Manifest.validate(raw, path)
assert(gameVersionOk, ("malformed game_version %q: %s")
:format(tostring(raw.game_version), tostring(gameVersionErr)))
local github = Manifest.parseGithub(raw.github)
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean")
local experimental = raw.experimental == true
-- overhauls and total conversions are assumed to move the link
-- fingerprint unless the manifest says otherwise; content packs are not
local affectsLink = profile ~= "content"
@@ -97,6 +139,8 @@ function Manifest.validate(raw, path)
return value
end
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
return {
id = raw.id,
name = raw.name,
@@ -106,13 +150,16 @@ function Manifest.validate(raw, path)
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
conflicts = array(raw.conflicts),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
description = raw.description or "",
github = github,
experimental = experimental,
profile = profile,
affects_link = affectsLink,
permissions = permissions,
+623
View File
@@ -0,0 +1,623 @@
-- Community mod index consumer (the "Find mods" launcher tab).
--
-- An index is metadata only: a published index.json feed listing mods that
-- live in their authors' own repos. Nothing here clones or vendors the index
-- repo -- the feed is the whole contract, and every install still goes through
-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing
-- buys a mod no trust it would not otherwise have.
--
-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its
-- host I/O from: everything above "host I/O" is pure (no love, no filesystem,
-- no network) so the engine tier can table-drive it, and the fetch/cache half
-- reaches for curl and options.lua.
--
-- Sources are never added automatically. options.modIndexes is a player-built
-- list -- adding an index is a deliberate act of trusting whoever publishes it,
-- so the launcher ships with none and asks.
--
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
-- name for something else, so an unknown version is refused outright rather
-- than parsed hopefully.
local ModIndex = {}
-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy
-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a
-- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1
-- ------- pure: source resolution
local function trim(s)
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
end
-- Split "owner/repo" out of the several ways a player can name a GitHub repo.
local function githubSlug(url)
local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)")
if not owner then
owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$")
end
if not owner then return nil end
repo = repo:gsub("%.git$", "")
return owner, repo
end
-- resolveSource(input) -> { feed, base, fallback, label } | nil, err
--
-- Players paste whichever URL they happened to have, so all four shapes of the
-- same index resolve to one source: the Pages root, the feed file itself, the
-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail
-- and description_url paths resolve against, and it always keeps its trailing
-- slash so joinUrl can stay a concatenation.
--
-- The raw.githubusercontent fallback exists because Pages deploys lag a push
-- by a minute or two; it is only ever consulted when the feed fetch fails.
function ModIndex.resolveSource(input)
if type(input) ~= "string" then return nil, "missing index URL" end
local url = trim(input)
if url == "" then return nil, "missing index URL" end
local owner, repo = githubSlug(url)
if owner then
return {
feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo),
base = ("https://%s.github.io/%s/"):format(owner, repo),
fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json")
:format(owner, repo),
label = owner .. "/" .. repo,
}
end
if not url:match("^https?://") then
return nil, "index must be an http(s) URL or owner/repo"
end
-- A feed URL names the file; the Pages root is what is left once the
-- "data/index.json" tail comes off (any other .json keeps only its folder).
if url:match("%.json$") then
local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)")
return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) }
end
local base = url:match("/$") and url or (url .. "/")
return {
feed = base .. "data/index.json",
base = base,
label = ModIndex.labelFor(base),
}
end
-- A short human name for a source row: "owner/repo" for a Pages host, else the
-- host plus first path segment. Only ever cosmetic.
function ModIndex.labelFor(url)
url = tostring(url or "")
local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/")
if owner then return owner .. "/" .. repo end
local host, first = url:match("^https?://([^/]+)/([^/]*)")
if host and first and first ~= "" then return host .. "/" .. first end
return host or url
end
-- joinUrl(base, rel) -> absolute URL | nil
-- Relative feed paths resolve against the Pages root; an already-absolute one
-- is handed back untouched (the schema allows either), and anything else --
-- nil, "", a non-string -- is simply absent rather than an error, because a
-- missing thumbnail is not a broken index.
function ModIndex.joinUrl(base, rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:match("^https?://") then return rel end
if type(base) ~= "string" or base == "" then return nil end
if not base:match("/$") then base = base .. "/" end
return base .. (rel:gsub("^/", ""))
end
-- ------- pure: feed parsing
local function str(v)
return type(v) == "string" and v or nil
end
local function strArray(v)
local out = {}
if type(v) == "table" then
for _, entry in ipairs(v) do
if type(entry) == "string" then out[#out + 1] = entry end
end
end
return out
end
-- Decode one release blob from the feed's `latest` field into the same shape
-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it
-- without a translation layer.
local function parseLatest(raw)
if type(raw) ~= "table" then return nil end
local zip = nil
if type(raw.zip) == "table" and str(raw.zip.url) then
zip = { name = str(raw.zip.name), url = raw.zip.url,
size = tonumber(raw.zip.size) }
end
return {
version = str(raw.version),
tag = str(raw.tag),
name = str(raw.name),
prerelease = raw.prerelease == true,
published_at = str(raw.published_at),
zip = zip,
}
end
local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end
return {
folder = str(raw.folder),
id = raw.id,
title = str(raw.title) or raw.id,
author = str(raw.author),
version = str(raw.version),
summary = str(raw.summary) or "",
categories = strArray(raw.categories),
tags = strArray(raw.tags),
license = str(raw.license),
repo = str(raw.repo),
github = str(raw.github),
downloadURL = str(raw.downloadURL),
api = tonumber(raw.api),
game_version = str(raw.game_version),
profile = str(raw.profile),
affects_link = raw.affects_link == true,
experimental = raw.experimental == true,
permissions = strArray(raw.permissions),
dependencies = raw.dependencies,
conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
latest = parseLatest(raw.latest),
update_check = str(raw.update_check) or "pending",
}
end
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
-- | nil, err
-- Never throws: a truncated download, an HTML error page, or a feed from a
-- future schema all come back as a message the panel can print.
function ModIndex.parse(jsonText, Json)
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "index.json is not an object"
end
local schema = tonumber(doc.schema_version)
if schema == nil then
return nil, "index.json has no schema_version"
end
if schema ~= ModIndex.SCHEMA_VERSION then
return nil, ("index schema %d is not supported (this build reads %d)")
:format(schema, ModIndex.SCHEMA_VERSION)
end
if type(doc.mods) ~= "table" then
return nil, "index.json has no mods array"
end
local mods = {}
for _, raw in ipairs(doc.mods) do
local entry = parseEntry(raw)
if entry then mods[#mods + 1] = entry end
end
return {
schemaVersion = schema,
generatedAt = str(doc.generated_at),
categories = strArray(doc.categories),
mods = mods,
}
end)
if not ok then return nil, "could not read the index: " .. tostring(result) end
return result, err
end
-- ------- pure: install resolution
-- installUrl(entry) -> url, kind | nil, reason
--
-- The same order the engine's zip import already implies: a verified release
-- asset first, then the author's fixed downloadURL. A GitHub source-archive
-- URL is never invented -- codeload gives you the repo, not the built mod, and
-- the folder layout would be wrong even when the download succeeds.
function ModIndex.installUrl(entry)
if type(entry) ~= "table" then return nil, "no entry" end
if entry.update_check == "ok" and entry.latest and entry.latest.zip
and entry.latest.zip.url then
return entry.latest.zip.url, "release"
end
if entry.downloadURL and entry.downloadURL ~= "" then
return entry.downloadURL, "download"
end
if entry.update_check == "off" then
return nil, "the author does not publish installable releases"
end
if entry.update_check == "no installable release" then
return nil, "no release with a .zip asset yet"
end
if type(entry.update_check) == "string"
and entry.update_check:match("^error") then
return nil, entry.update_check
end
return nil, "nothing installable listed"
end
function ModIndex.canInstall(entry)
return ModIndex.installUrl(entry) ~= nil
end
-- The version to show on a card: the release the index resolved when it could
-- reach GitHub, else whatever meta.json declared.
function ModIndex.displayVersion(entry)
if type(entry) ~= "table" then return "?" end
if entry.update_check == "ok" and entry.latest and entry.latest.version then
return entry.latest.version
end
return entry.version or "?"
end
-- The release table LauncherMods.installFromRelease wants. A downloadURL
-- entry has no release behind it, so one is synthesised around the URL; the
-- installer still validates the manifest inside and still refuses a zip whose
-- id is not the one being installed.
function ModIndex.releaseFor(entry)
local url, kind = ModIndex.installUrl(entry)
if not url then return nil, kind end
if kind == "release" then return entry.latest end
return {
version = ModIndex.displayVersion(entry),
zip = { url = url, name = entry.id .. ".zip" },
}
end
-- ------- pure: compatibility
-- compatIssues(entry, ctx) -> array of { level, text }
--
-- Soft gate by design: an index entry is metadata an author wrote, possibly
-- months ago, and hiding a mod because a range looks wrong is how a working
-- mod becomes invisible. Everything here warns; the confirm dialog shows the
-- list and the player decides. ctx carries { modApi, engineVersion,
-- installed = { id -> version }, enabled = { id -> true } }.
function ModIndex.compatIssues(entry, ctx)
local out = {}
if type(entry) ~= "table" then return out end
ctx = ctx or {}
local function warn(text) out[#out + 1] = { level = "warn", text = text } end
local modApi = tonumber(ctx.modApi)
if entry.api and modApi and entry.api > modApi then
warn(("Needs mod API %d; this build provides %d")
:format(entry.api, modApi))
end
if entry.game_version and ctx.engineVersion then
local okSemver, Semver = pcall(require, "src.mods.Semver")
if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then
warn(("Needs engine %s (have %s)")
:format(entry.game_version, ctx.engineVersion))
end
end
if entry.profile and entry.profile ~= "content" then
warn(("Profile '%s' changes engine behaviour beyond content")
:format(entry.profile))
end
if entry.affects_link then
warn("Changes link play; both sides need the same mods")
end
if entry.experimental then
warn("Marked experimental by its author")
end
for _, name in ipairs(entry.permissions or {}) do
warn("Requests permission: " .. name)
end
-- dependencies / conflicts arrive as the manifest's own vocabulary: either
-- an array of "id" / "id@<range>" strings or an id -> range map.
local installed = ctx.installed or {}
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
end
end
eachSpec(entry.dependencies, function(id, range)
if installed[id] == nil then
warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)")
end
end)
eachSpec(entry.conflicts, function(id)
if installed[id] ~= nil then
warn("Conflicts with installed " .. id)
end
end)
return out
end
-- ------- pure: search / filter
local function haystack(entry)
return (tostring(entry.title or "") .. " " .. tostring(entry.author or "")
.. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or ""))
:lower()
end
-- matches(entry, query) -> bool. Every whitespace-separated term must appear
-- somewhere in title / author / summary / id, so typing more narrows.
function ModIndex.matches(entry, query)
if type(query) ~= "string" or trim(query) == "" then return true end
local hay = haystack(entry)
for term in trim(query):lower():gmatch("%S+") do
if not hay:find(term, 1, true) then return false end
end
return true
end
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
-- Category and tag compare case-insensitively; feed order (already sorted by
-- title) is preserved.
function ModIndex.filter(mods, opts)
opts = opts or {}
local want = opts.category and tostring(opts.category):lower() or nil
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
local out = {}
for _, entry in ipairs(mods or {}) do
local keep = ModIndex.matches(entry, opts.query)
if keep and want then
keep = false
for _, c in ipairs(entry.categories or {}) do
if tostring(c):lower() == want then keep = true; break end
end
end
if keep and wantTag then
keep = false
for _, t in ipairs(entry.tags or {}) do
if tostring(t):lower() == wantTag then keep = true; break end
end
end
if keep then out[#out + 1] = entry end
end
return out
end
-- Every category actually used by a feed, in the feed's declared order, with
-- anything an entry names that the header forgot appended. Drives the filter
-- row without hard-coding the vocabulary.
function ModIndex.categoriesIn(index)
local out, seen = {}, {}
if type(index) ~= "table" then return out end
local used = {}
for _, entry in ipairs(index.mods or {}) do
for _, c in ipairs(entry.categories or {}) do used[c] = true end
end
for _, c in ipairs(index.categories or {}) do
if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end
end
for _, entry in ipairs(index.mods or {}) do
for _, c in ipairs(entry.categories or {}) do
if not seen[c] then seen[c] = true; out[#out + 1] = c end
end
end
return out
end
-- ------- sources (options.modIndexes)
local function loadOptions()
return require("src.core.SaveData").loadOptions()
end
-- The player's index list, normalised. Rows are { url, feed, base, fallback,
-- label }; `url` is what they typed, kept so the row reads back the way they
-- entered it.
function ModIndex.sources()
local ok, opts = pcall(loadOptions)
if not ok or type(opts) ~= "table" then return {} end
local out = {}
for _, row in ipairs(opts.modIndexes or {}) do
if type(row) == "table" and type(row.feed) == "string" then
out[#out + 1] = row
end
end
return out
end
-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so
-- pasting the repo page and the Pages root in either order adds one source.
function ModIndex.addSource(input)
local source, err = ModIndex.resolveSource(input)
if not source then return nil, err end
local ok, result, addErr = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
opts.modIndexes = opts.modIndexes or {}
for _, row in ipairs(opts.modIndexes) do
if row.feed == source.feed then
return nil, "that index is already added"
end
end
source.url = trim(input)
opts.modIndexes[#opts.modIndexes + 1] = source
SaveData.saveOptions(opts)
return source
end)
if not ok then return nil, "could not save the index: " .. tostring(result) end
return result, addErr
end
-- removeSource(feed) -> true | nil, err. Drops the cached listing with it:
-- keeping a feed's mods around after its source is gone is how a stale card
-- outlives the index it came from.
function ModIndex.removeSource(feed)
if type(feed) ~= "string" or feed == "" then return nil, "missing index" end
local ok, result = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
local kept, found = {}, false
for _, row in ipairs(opts.modIndexes or {}) do
if row.feed == feed then found = true else kept[#kept + 1] = row end
end
if not found then return nil end
opts.modIndexes = kept
if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end
SaveData.saveOptions(opts)
return true
end)
if not ok then return nil, tostring(result) end
if not result then return nil, "that index is not in the list" end
return true
end
-- ------- cache (options.modIndexCache[feed])
function ModIndex.readCache(feed)
if type(feed) ~= "string" or feed == "" then return nil end
local ok, opts = pcall(loadOptions)
if not ok or type(opts) ~= "table" then return nil end
local entry = opts.modIndexCache and opts.modIndexCache[feed]
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
return nil
end
if type(entry.mods) ~= "table" then return nil end
return entry
end
function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number"
and (now - entry.checkedAt) < ttl
end
function ModIndex.writeCache(feed, index)
if type(feed) ~= "string" or feed == "" then return false end
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = {
checkedAt = os.time(),
generatedAt = index.generatedAt,
categories = index.categories,
mods = index.mods,
}
SaveData.saveOptions(opts)
end)
return ok
end
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- Plain GET returning the body. No GitHub Accept header: the feed and the
-- description markdown are static files on Pages, and the public feed is
-- explicitly unauthenticated.
function ModIndex.httpGet(url)
local ModUpdate = require("src.mods.ModUpdate")
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end
-- fetch(source, opts) -> index, err, meta
-- index is the parse() table; meta is { fromCache, stale }. opts.force skips
-- the 24h cache. A failed live fetch falls back to whatever is cached, marked
-- stale, so going offline degrades the listing rather than emptying it.
function ModIndex.fetch(source, opts)
opts = opts or {}
if type(source) ~= "table" or type(source.feed) ~= "string" then
return nil, "missing index source"
end
local feed = source.feed
local function cached(stale)
local entry = ModIndex.readCache(feed)
if not entry then return nil end
return {
schemaVersion = ModIndex.SCHEMA_VERSION,
generatedAt = entry.generatedAt,
categories = entry.categories or {},
mods = entry.mods or {},
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
end
if not opts.force then
local entry = ModIndex.readCache(feed)
if ModIndex.cacheFresh(entry) then return cached(false) end
end
local body, err = ModIndex.httpGet(feed)
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
-- that 404s right after a release is worth one retry elsewhere before it
-- counts as an outage.
if not body and source.fallback then
body = ModIndex.httpGet(source.fallback)
end
if not body then
local index, _, meta = cached(true)
if index then return index, nil, meta end
return nil, err
end
local index, parseErr = ModIndex.parse(body)
if not index then
local stale, _, meta = cached(true)
if stale then return stale, parseErr, meta end
return nil, parseErr
end
ModIndex.writeCache(feed, index)
return index, nil, { fromCache = false, checkedAt = os.time() }
end
-- Fetch a description_url / any index-relative text file. Returns the raw
-- markdown; callers run it through ModUpdate.cleanBody for display.
function ModIndex.fetchText(url)
if type(url) ~= "string" or url == "" then return nil, "no description" end
return ModIndex.httpGet(url)
end
-- Download a thumbnail into the save directory and return the love.filesystem
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
-- a non-empty-file check -- nothing in it is zip-specific.
function ModIndex.downloadThumbnail(url, modId)
if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end
local ModUpdate = require("src.mods.ModUpdate")
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext)
return ModUpdate.downloadZip(url, name)
end
return ModIndex
+361
View File
@@ -0,0 +1,361 @@
-- GitHub release helpers for mod auto-update / other-versions.
-- Pure parsing is love-free; fetch/download use HostShell + curl when available.
-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds
-- (default 6 hours). The launcher owns UI and install.
local ModUpdate = {}
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Prefer "<id>-<version>.zip", then any "<id>*.zip", then the first .zip.
function ModUpdate.pickZipAsset(assets, modId, version)
if type(assets) ~= "table" then return nil end
local prefer = nil
if modId and version then
prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip"
end
local idPrefix, idPrefixZip, anyZip = nil, nil, nil
if modId then idPrefix = tostring(modId):lower() end
for _, a in ipairs(assets) do
if type(a) == "table" and type(a.name) == "string" then
local name = a.name
if name:lower():match("%.zip$") then
local row = {
name = name,
url = a.browser_download_url,
size = tonumber(a.size),
}
if prefer and name == prefer then return row end
if idPrefix and not idPrefixZip
and name:lower():find(idPrefix, 1, true) == 1 then
idPrefixZip = row
end
if not anyZip then anyZip = row end
end
end
end
return idPrefixZip or anyZip
end
-- Byte-length cut that never lands mid UTF-8 codepoint.
local function utf8Cut(s, maxBytes)
if type(s) ~= "string" or maxBytes <= 0 then return "" end
if #s <= maxBytes then return s end
s = s:sub(1, maxBytes)
-- Drop trailing continuation bytes (10xxxxxx).
while #s > 0 do
local b = s:byte(#s)
if b < 0x80 or b >= 0xC0 then break end
s = s:sub(1, #s - 1)
end
-- Drop a lead byte whose continuation was truncated away.
if #s > 0 then
local b = s:byte(#s)
if b >= 0xC0 then
s = s:sub(1, #s - 1)
end
end
return s
end
-- Strip common markdown / HTML noise for the launcher changelog preview.
function ModUpdate.cleanBody(text, maxChars)
if type(text) ~= "string" or text == "" then return "" end
local s = text
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
s = s:gsub("<!%-%-.-%-%->", "")
s = s:gsub("<[^>]+>", "")
s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images ![alt](url)
s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text
s = s:gsub("```[^\n]*\n(.-)```", "%1")
s = s:gsub("`([^`]+)`", "%1")
s = s:gsub("^#+%s*", "", 1)
s = s:gsub("\n#+%s*", "\n")
s = s:gsub("%*%*([^*]+)%*%*", "%1")
s = s:gsub("%*([^*]+)%*", "%1")
s = s:gsub("__([^_]+)__", "%1")
s = s:gsub("_([^_]+)_", "%1")
s = s:gsub("^\n+", ""):gsub("\n+$", "")
s = s:gsub("\n\n\n+", "\n\n")
maxChars = tonumber(maxChars) or 0
if maxChars > 0 and #s > maxChars then
-- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe.
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
end
return s
end
-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized.
function ModUpdate.previewLine(text, maxChars)
local s = ModUpdate.cleanBody(text, 0)
if s == "" then return "" end
s = s:gsub("\n+", " "):gsub("%s+", " ")
s = s:match("^%s*(.-)%s*$") or s
maxChars = tonumber(maxChars) or 80
if #s > maxChars then
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
end
return s
end
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
function ModUpdate.parseRelease(doc, modId)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+") then
return nil, "release tag is not semver-like: " .. tostring(doc.tag_name)
end
local triple = version:match("^(%d+%.%d+%.%d+)")
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
local body = type(doc.body) == "string" and doc.body or ""
return {
version = triple,
tag = tostring(doc.tag_name),
zip = zip,
prerelease = doc.prerelease == true,
name = type(doc.name) == "string" and doc.name or triple,
body = body,
}
end
-- Decode a releases array (GET /repos/.../releases) into a sorted list
-- (newest first). Releases without a .zip asset are dropped. Never throws.
function ModUpdate.parseReleases(jsonText, modId, Json)
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "releases json is not an array"
end
if type(doc.message) == "string" and not doc.tag_name and not doc[1] then
return nil, "GitHub: " .. doc.message
end
if doc.tag_name then
local one, oneErr = ModUpdate.parseRelease(doc, modId)
if not one then return nil, oneErr end
if not one.zip then return nil, "latest release has no .zip asset" end
return { one }
end
local out = {}
for _, entry in ipairs(doc) do
local rel = ModUpdate.parseRelease(entry, modId)
if rel and rel.zip then out[#out + 1] = rel end
end
return out
end)
if not ok then return nil, "could not parse releases: " .. tostring(result) end
return result, err
end
function ModUpdate.apiReleasesUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
end
function ModUpdate.apiLatestUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases/latest"
end
function ModUpdate.isNewer(installed, candidate)
local Semver = require("src.mods.Semver")
if type(installed) ~= "string" or type(candidate) ~= "string" then
return false
end
local a, b = Semver.parse(installed), Semver.parse(candidate)
if not a or not b then return false end
return Semver.compare(candidate, installed) > 0
end
-- Newest non-prerelease, else newest overall.
function ModUpdate.pickBest(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
for _, rel in ipairs(releases) do
if not rel.prerelease then return rel end
end
return releases[1]
end
-- ------- cache (options.modUpdateCache[repo])
local function cacheStore()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modUpdateCache = opts.modUpdateCache or {}
return opts
end
function ModUpdate.readCache(repo)
if type(repo) ~= "string" or repo == "" then return nil end
local ok, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
if not ok or type(opts) ~= "table" then return nil end
local entry = opts.modUpdateCache and opts.modUpdateCache[repo]
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
return nil
end
if type(entry.releases) ~= "table" then return nil end
return entry
end
function ModUpdate.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModUpdate.CACHE_TTL
return entry and type(entry.checkedAt) == "number"
and (now - entry.checkedAt) < ttl
end
function ModUpdate.writeCache(repo, releases)
if type(repo) ~= "string" or repo == "" then return false end
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = cacheStore()
local best = ModUpdate.pickBest(releases)
-- Persist a lean copy: enough to paint the UI and reinstall without
-- re-fetching within the TTL.
local lean = {}
for i, rel in ipairs(releases or {}) do
lean[i] = {
version = rel.version,
tag = rel.tag,
name = rel.name,
prerelease = rel.prerelease == true,
body = type(rel.body) == "string" and rel.body or "",
zip = rel.zip and {
name = rel.zip.name,
url = rel.zip.url,
size = rel.zip.size,
} or nil,
}
end
opts.modUpdateCache[repo] = {
checkedAt = os.time(),
latest = best and best.version or nil,
releases = lean,
}
SaveData.saveOptions(opts)
end)
return ok
end
-- Status vs an installed version using a cache entry / release list.
-- Returns "available" | "current" | "unknown".
function ModUpdate.statusFor(installedVersion, releases)
local best = ModUpdate.pickBest(releases)
if not best then return "unknown", nil end
if ModUpdate.isNewer(installedVersion, best.version) then
return "available", best
end
return "current", best
end
-- ------- host I/O (curl)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function curlCapture(url)
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "curl read failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from GitHub" end
return out
end
function ModUpdate.haveCurl()
local HostShell = require("src.core.HostShell")
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
if not pipeOk or not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- Fetch release list. opts.force bypasses the 6h cache.
-- Returns releases, err, meta where meta = { fromCache = bool }.
function ModUpdate.fetchReleases(repo, modId, opts)
opts = opts or {}
if type(repo) ~= "string" or repo == "" then
return nil, "missing github repo"
end
if not opts.force then
local cached = ModUpdate.readCache(repo)
if ModUpdate.cacheFresh(cached) then
return cached.releases, nil, { fromCache = true }
end
end
if not ModUpdate.haveCurl() then
-- Stale cache is better than nothing when offline
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, "curl is not available on this platform"
end
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
if not body then
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, curlErr
end
local list, parseErr = ModUpdate.parseReleases(body, modId)
if not list then return nil, parseErr end
ModUpdate.writeCache(repo, list)
return list, nil, { fromCache = false }
end
function ModUpdate.downloadZip(url, destName)
if type(url) ~= "string" or url == "" then
return nil, "missing download url"
end
if not (love and love.filesystem) then
return nil, "download needs LOVE"
end
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_")
local saveOk, saveDir = pcall(function()
return love.filesystem.getSaveDirectory()
end)
if not saveOk or not saveDir or saveDir == "" then
return nil, "no save directory"
end
local abs = saveDir .. "/" .. name
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
.. shq(abs) .. " " .. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
local infoOk, info = pcall(love.filesystem.getInfo, name)
if not infoOk or not info or (info.size or 0) == 0 then
pcall(love.filesystem.remove, name)
return nil, "download failed"
end
return name
end
return ModUpdate
+97 -39
View File
@@ -5,11 +5,13 @@
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
-- per-species mon colors.
-- Port display option: COLORS (OG RED / OG BLUE / OG YELLOW / SGB /
-- ADVANCED / OG / OG INV / SGB INV / CLASSIC) transforms every zone's
-- palette at send time via effectiveColors. ADVANCED (the `redpp` id
-- below) swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors. OG YELLOW
-- (Yellow playthrough + `ogred` id) uses pokeyellow CGBBasePalettes
-- (data/palettes_yellow.lua / ROM cgbBase).
local GameVersion = require("src.core.GameVersion")
@@ -17,10 +19,11 @@ local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
local yellowPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
-- ADVANCED = pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
@@ -117,14 +120,19 @@ PaletteFX.GBC_OBJ_BLUE = {
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
-- playthrough, red for Red. White (index 1) and black (index 4) are
-- identical across Red/Blue, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch there.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
local route = y and y.cgbBase and y.cgbBase.ROUTE
if route then return route end
end
return PaletteFX.GBC_BG
end
@@ -133,14 +141,12 @@ end
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
-- Yellow OG does not bake a boot-ROM OBJ (usesSpriteObp is false); this path
-- is unused there and kept as Red green only as a safe leftover.
function PaletteFX.ogObj()
if GameVersion.isBlue() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
end
if GameVersion.isYellow() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
@@ -271,13 +277,31 @@ function PaletteFX.gbcPack()
return gbcPack or nil
end
-- pokeyellow SuperPalettes + CGBBasePalettes (committed; not wiped by import).
-- ROM import may also stamp palettes.cgbBase; yellowCgbNamedPal prefers that.
function PaletteFX.yellowPack()
if yellowPack == nil then
local ok, pack = pcall(require, "data.palettes_yellow")
yellowPack = ok and pack or false
end
return yellowPack or nil
end
function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode
return mode == "redpp"
end
-- Yellow's authentic GBC look is CGBBasePalettes (per-map), not a boot-ROM
-- auto-palette. The shared `ogred` save id wears that table on a Yellow
-- playthrough and labels itself "OG YELLOW".
function PaletteFX.usesYellowCgb(mode)
mode = mode or PaletteFX.mode
return GameVersion.isYellow() and mode == "ogred"
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
-- (the OBP bake + post-zone redraw path). OG RED / OG BLUE do: the Game Boy
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
-- green over Red's red background, pink over Blue's blue background), so on
-- that machine the player and NPCs carry a fixed object color instead of
@@ -288,6 +312,10 @@ end
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
-- BG palette; only OG RED short-circuits BG to the one global red palette.
--
-- OG YELLOW does NOT. Yellow ships CGB code and colors regions via
-- CGBBasePalettes the same way SGB mode colors SuperPalettes, so sprites tint
-- with the map zone (usesSpriteObp stays off).
--
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
@@ -306,7 +334,7 @@ end
-- usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "ogred"
return mode == "ogred" and not GameVersion.isYellow()
end
-- ------- post-zone sprite redraw (OG RED)
@@ -363,8 +391,8 @@ end
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
-- prefers those over SGB SuperPalettes for named zones.
-- recolors. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
-- Yellow keeps SuperPalettes.
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
@@ -376,31 +404,42 @@ end
local function yellowCgbNamedPal(data, name)
local p = data and data.palettes
return p and p.cgbBase and p.cgbBase[name]
local fromRom = p and p.cgbBase and p.cgbBase[name]
if fromRom then return fromRom end
local y = PaletteFX.yellowPack()
return y and y.cgbBase and y.cgbBase[name] or nil
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
-- (the hardware had a single BGP for the whole game), so terrain zones,
-- battle HP bars / text, and menu boxes all come out red -- everything a
-- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook.
-- OG RED / OG BLUE short-circuit EVERY name to the one global GBC boot-ROM
-- BG palette (the hardware had a single BGP for the whole game), so terrain
-- zones, battle HP bars / text, and menu boxes all come out red/blue --
-- everything a background tile drew. Objects do not come through here
-- (they bake GBC_OBJ), so this stays a BG-only hook.
-- OG YELLOW instead resolves each name through CGBBasePalettes.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
return PaletteFX.ogBg()
end
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
if GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
local yc = y and y.palettes and y.palettes[name]
if yc then return yc end
end
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
end
@@ -413,14 +452,20 @@ end
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
-- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field.
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
-- OG RED / OG BLUE: a battle mon pic is a BG tile on the Game Boy Color
-- (drawn into the tilemap, colored by BGP), so it wears the global boot-ROM
-- BG palette, not a per-species one -- matching the hardware capture where
-- both mons are red/pink (or blue/pink) on the white field.
-- OG YELLOW keeps per-species CGBBasePalettes (Yellow had real CGB code).
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
return PaletteFX.ogBg()
end
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then
if PaletteFX.usesYellowCgb() then
return PaletteFX.pal(data, "GRAYMON")
end
return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON)
end
@@ -430,17 +475,32 @@ function PaletteFX.monPal(data, species, transformed)
-- so fall back to it when the pack itself doesn't carry the name.
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette then
if PaletteFX.usesYellowCgb() then
local yc = PaletteFX.pal(data, def.palette)
if yc then return yc end
end
local pal = p.palettes[def.palette]
or (data and data.palettes and data.palettes.palettes[def.palette])
if pal then return pal end
end
local name = p.pokemon[species] or "MEWMON"
if PaletteFX.usesYellowCgb() then
local yc = PaletteFX.pal(data, name)
if yc then return yc end
end
local c = p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
name = data.palettes.pokemon[species] or "MEWMON"
return data.palettes.palettes[name]
end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
if y and y.pokemon then
name = y.pokemon[species] or "MEWMON"
return y.palettes and y.palettes[name]
end
end
return nil
end
@@ -707,12 +767,10 @@ end
function PaletteFX.modeLabel(mode)
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
-- The GBC hardware mode wears the running game's name: OG RED / OG BLUE
-- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes).
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
if mode == "ogred" and GameVersion.isYellow() then return "OG YELLOW" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
+7
View File
@@ -720,6 +720,13 @@ function Renderer:endFrame(zones, worldZones)
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp,
dpiX = dpiX, dpiY = dpiY,
}
end
return Renderer
+6 -2
View File
@@ -734,11 +734,15 @@ function Commands.record_hall_of_fame(ctx)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
if game.writeSave then game:writeSave() end
local saveAllowed = true
if game.writeSave then saveAllowed = game:writeSave() ~= false end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
-- A tool session may veto Game:writeSave to keep its in-memory run
-- isolated from the player's real save. The relocation write is part
-- of that same save operation and must honor the same decision.
if saveAllowed then SaveData.save(ctx.save) end
end)
end)
runner:yield()
+11
View File
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if result == "bicycle" then
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
-- runs, which is why it precedes list:close() here.
if game.save.forcedBike then
showMessages(game, { Strings("You can't get off\nhere.") })
return
end
list:close()
local ow = game.overworld
local Music = require("src.core.Music")
+22 -2
View File
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end
-- A rebind reaches Input only when this screen closes (#510). The menu
-- steers by the live map, so applying "B = Z" the instant it was captured
-- turned the player's next confirm press into a cancel and shut the
-- screen mid-swap. options.bindings is still written immediately, and
-- Game:applyOptions re-applies it on load, so a close that skips this
-- hook still ends up with the saved map.
self.onCancel = function() self:commitBindings() end
return self
end
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
-- the live map here, on close, and nowhere else in this screen.
function BindingsMenu:commitBindings()
local game = self.game
local opts = game and game.save and game.save.options
if opts then Input:applyBindings(opts.bindings) end
end
-- the capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed
function BindingsMenu:beginCapture(item)
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
self.onGamepadPressed = BindingsMenu.capturePad
end
-- Escape is the capture's way out, so it is never captured: every other
-- key is bindable, which otherwise leaves an armed row with no exit but
-- to bind something (#510). Escape stays START in Input's default map,
-- which no rebind removes, so reserving it costs the player nothing.
function BindingsMenu:captureKey(key)
if key == "escape" then return self:storeBinding("key", nil) end
self:storeBinding("key", key)
end
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
@@ -112,9 +131,10 @@ end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 4)
Font.drawBox(1, 6, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
love.graphics.setColor(1, 1, 1, 1)
end
end
+23 -11
View File
@@ -48,11 +48,11 @@ end
local FLASH_FRAMES = 220
local function frontSprite(game, species, mon)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ mon = mon, kind = "evolution" })
if not path then return nil end
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { mon = mon, kind = "evolution" })
if not path then return nil, false end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
return ok and img or nil, ok and trueColor or false
end
function EvolutionState.new(game, mon, newSpecies, onDone, via)
@@ -69,8 +69,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
-- wForceEvolution clear and so honour B (#290, #213).
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies, mon)
self.oldSprite, self.oldSpriteTrueColor = frontSprite(game, mon.species, mon)
self.newSprite, self.newSpriteTrueColor = frontSprite(game, newSpecies, mon)
self.t = 0
self.done = false
self.canceled = false
@@ -127,18 +127,30 @@ function EvolutionState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- accelerating flash between the two forms
local sprite
local sprite, spriteTrueColor
if self.done then
-- a cancelled evolution settles back on the original form
sprite = self.canceled and self.oldSprite or self.newSprite
if self.canceled then
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
sprite = showNew and self.newSprite or self.oldSprite
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
end
if sprite then
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
math.max(8, 64 - sprite:getHeight()))
local x = math.floor((160 - sprite:getWidth()) / 2)
local y = math.max(8, 64 - sprite:getHeight())
love.graphics.draw(sprite, x, y)
if spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
+34
View File
@@ -322,6 +322,25 @@ local function buildRows(game)
activate = function(g)
require("src.ui.Screens").push(g, "BindingsMenu")
end },
-- permanent on-screen pad toggle (#327); layout editing stays in the
-- launcher. Hidden where the overlay never appears (desktop without
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
{ id = "touchControls", label = Strings("TOUCH PAD"),
value = function(g)
local tc = g.save.options.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
step = function(g)
local o = g.save.options
local tc = type(o.touchControls) == "table" and o.touchControls or {}
local on = not (tc.enabled == false)
tc.enabled = not on
-- keep any saved positions when toggling
o.touchControls = tc
require("src.core.TouchControls"):applyOptions(o)
return true
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
@@ -331,6 +350,21 @@ local function buildRows(game)
end
rows = filtered
end
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if not show then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
end
rows = filtered
end
end
-- PIKACHU VOL only means something where the voice clips exist: Yellow
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
-- keep the row list they always had.
+11
View File
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local Runtime = require("src.mods.Runtime")
local Logger = require("src.core.Logger")
local TitleState = {}
TitleState.__index = TitleState
@@ -300,6 +302,8 @@ local function hasSave()
return ok and info ~= nil
end
local function sameItems(_, items) return items end
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
@@ -375,6 +379,13 @@ function TitleState:openMenu()
love.event.quit()
end
end })
local hooked = Runtime.call("ui.title_menu.items", sameItems, game, items)
if type(hooked) == "table" then
items = hooked
else
Logger.error("ui.title_menu.items returned %s; keeping the vanilla items",
type(hooked))
end
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes
+275
View File
@@ -0,0 +1,275 @@
-- Launcher touch-controls editor (#327): drag each on-screen button to a
-- new spot, toggle the overlay off entirely, Reset to defaults, Done to
-- persist into options.lua. Opened from the game panel's "Touch Controls"
-- button; main.lua suspends the launcher the same way it does for the
-- save editor.
--
-- Draws in full window LOVE units -- the same space TouchControls uses
-- after Renderer:endFrame -- so what you drag here is what you get in play.
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local Editor = {}
local PAL = {
bgTop = { 8, 14, 36 },
bgBot = { 4, 8, 22 },
white = { 245, 248, 255 },
label = { 160, 175, 210 },
green = { 80, 220, 140 },
red = { 240, 90, 110 },
card = { 18, 28, 58 },
stroke = { 120, 150, 220 },
}
local function col(c, a)
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
end
local function inside(r, x, y)
return r and x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h
end
local function roundRect(mode, x, y, w, h, r)
love.graphics.rectangle(mode, x, y, w, h, r, r)
end
function Editor.load(opts)
opts = opts or {}
Editor.onClose = opts.onClose
Editor.drag = nil
Editor.rects = {}
Editor.fonts = {
title = love.graphics.newFont(28),
body = love.graphics.newFont(16),
btn = love.graphics.newFont(18),
}
local optsTbl = SaveData.loadOptions()
TouchControls:init()
TouchControls:ensureImages()
TouchControls:applyOptions(optsTbl)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
end
function Editor.unload()
TouchControls:setPreview(false)
TouchControls:reset()
Editor.drag = nil
Editor.onClose = nil
end
local function persist()
local opts = SaveData.loadOptions()
local cfg = TouchControls:config()
opts.touchControls = {
enabled = cfg.enabled,
positions = cfg.positions,
}
SaveData.saveOptions(opts)
end
local function close()
persist()
local cb = Editor.onClose
Editor.unload()
if cb then cb() end
end
local function resetLayout()
TouchControls:clearPositions()
end
local function toggleEnabled()
Editor.enabled = not Editor.enabled
TouchControls.enabled = Editor.enabled
if not Editor.enabled then TouchControls:reset() end
end
function Editor.update(_dt)
-- drag follows the live pointer when love.touch / mouse is available;
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
-- path for Android where move events can be thin
if not Editor.drag then return end
local x, y
if love.touch and love.touch.getPosition and Editor.drag.touchId then
local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId)
if ok and tx then x, y = tx, ty end
end
if not x and love.mouse then
x, y = love.mouse.getPosition()
end
if x then
TouchControls:setControlCenter(
Editor.drag.name,
x - Editor.drag.offX,
y - Editor.drag.offY)
end
end
function Editor.draw()
local ww, wh = love.graphics.getDimensions()
local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, ww, wh)
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
local btnW = 100 * s
-- top bar
col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
love.graphics.line(0, barH + pad, ww, barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", pad, pad + 4 * s)
-- Done / Reset
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
local function chromeBtn(r, label, fill)
col(fill, 0.9)
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
col(PAL.white, 0.2)
roundRect("line", r.x, r.y, r.w, r.h, 8 * s)
love.graphics.setFont(Editor.fonts.btn)
col(PAL.white)
local tw = Editor.fonts.btn:getWidth(label)
love.graphics.print(label, r.x + (r.w - tw) / 2,
r.y + (r.h - Editor.fonts.btn:getHeight()) / 2)
end
chromeBtn(reset, "Reset", { 60, 70, 110 })
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
local cardY = barH + pad + 14 * s
local cardH = 64 * s
local cardX, cardW = pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, cardY, cardW, cardH, 12 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
love.graphics.print("On-screen controls", cardX + 16 * s,
cardY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
local on = Editor.enabled
col(on and PAL.green or PAL.red)
love.graphics.print(on and "ON" or "OFF", cardX + 16 * s,
cardY + 34 * s)
local toggleW = 110 * s
local toggle = {
x = cardX + cardW - 16 * s - toggleW,
y = cardY + (cardH - btnH) / 2,
w = toggleW, h = btnH,
}
Editor.rects.toggle = toggle
chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green)
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
-- highlight the control under drag
if Editor.drag then
local L = TouchControls:layout()
local zone = L[Editor.drag.name]
if zone then
love.graphics.setLineWidth(3 * s)
col(PAL.green, 0.85)
love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62)
end
end
end
local function beginDrag(id, x, y)
-- chrome takes priority over controls
if inside(Editor.rects.done, x, y) then close(); return end
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
local name = TouchControls:hitTest(x, y)
if not name then return end
local zone = TouchControls:layout()[name]
Editor.drag = {
name = name,
touchId = id,
offX = x - zone.cx,
offY = y - zone.cy,
}
end
local function moveDrag(id, x, y)
local d = Editor.drag
if not d then return end
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
TouchControls:setControlCenter(d.name, x - d.offX, y - d.offY)
end
local function endDrag(id)
local d = Editor.drag
if not d then return end
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
Editor.drag = nil
end
function Editor.mousepressed(x, y, button)
if button ~= 1 then return end
beginDrag("mouse", x, y)
end
function Editor.mousemoved(x, y)
if love.mouse.isDown(1) then moveDrag("mouse", x, y) end
end
function Editor.mousereleased(x, y, button)
if button ~= 1 then return end
endDrag("mouse")
end
function Editor.touchpressed(id, x, y)
beginDrag(id, x, y)
end
function Editor.touchmoved(id, x, y)
moveDrag(id, x, y)
end
function Editor.touchreleased(id, x, y)
endDrag(id)
end
function Editor.keypressed(key)
if key == "escape" or key == "return" or key == "space" then
close()
elseif key == "r" then
resetLayout()
end
end
return Editor
+23 -9
View File
@@ -49,9 +49,10 @@ local function dexOf(game, mon)
end
local function spriteOf(game, mon)
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
{ mon = mon, kind = "trade" })
return tryImage(path)
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, mon.species, "front", { mon = mon, kind = "trade" })
local image = tryImage(path)
return image, image and trueColor or false
end
local function expand(game, key, subs)
@@ -110,8 +111,8 @@ function TradeAnim.new(game, opts)
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
}
self.sentSprite = spriteOf(game, self.sent)
self.recvSprite = spriteOf(game, self.received)
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
self.seq = 1
self.phase = SEQ[1]
@@ -349,8 +350,8 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(mon, x, y)
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
function TradeAnim:drawIconInBubble(sprite, x, y)
local spr = sprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
@@ -425,6 +426,10 @@ function TradeAnim:draw()
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16)
if self.sentSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
56 - self.scx, 16, self.sentSprite:getDimensions())
end
end
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
love.graphics.pop()
@@ -466,8 +471,13 @@ function TradeAnim:draw()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local mon = (p == "transfer_lr") and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
local sprite
if p == "transfer_lr" then
sprite = self.sentSprite
else
sprite = self.recvSprite
end
self:drawIconInBubble(sprite, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
@@ -477,6 +487,10 @@ function TradeAnim:draw()
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
if self.recvSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
56, 16, self.recvSprite:getDimensions())
end
end
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
+47 -3
View File
@@ -3,6 +3,7 @@
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
-- map scripts (data/scripts/), marts, nurses or extracted text.
local Assets = require("src.render.Assets")
local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
@@ -550,9 +551,26 @@ end
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
-- so this needs no mode-specific branch.
--
-- TalkToPikachu's framed frontpic is the one exception: pokeyellow
-- LoadOverworldPikachuFrontpicPalettes loads the map pal as slot 0 and
-- PAL_PIKACHU_PORTRAIT as slot 1, then ATTR_BLK's the 5x5 pic at
-- (7,6)-(11,10) onto slot 1 (engine/gfx/palettes.asm:345-391). Without
-- that zone the pic wears the route/town palette and looks washed out.
function OverworldState:sgbPalettes()
local PaletteFX = require("src.render.PaletteFX")
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
local mapName = self:paletteNameFor(self.map)
if self.emote and self.emote.pikaPic then
local base = PaletteFX.pal(Game.data, mapName)
if not base then return nil end
local zones = { PaletteFX.whole(base) }
local portrait = PaletteFX.pal(Game.data, "PIKACHU_PORTRAIT")
if portrait then
zones[#zones + 1] = PaletteFX.zone(portrait, 7, 6, 11, 10)
end
return zones
end
return PaletteFX.wholeNamed(Game.data, mapName)
end
-- World-pass palette zones in world-canvas pixels: each visible map
@@ -3915,6 +3933,31 @@ function OverworldState:draw()
self:drawUI()
end
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
-- and it resolves through Assets so a mod's emotes.png override still wins.
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
-- bubble's corners out, so carry it through untouched.
local function obpEmoteImage(path)
if not (love.image and love.image.newImageData) then
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
end
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, _, _, a)
local v = 0
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
end -- OBJ color 3 -> shade 3
return v, v, v, a
end)
return love.graphics.newImage(id)
end
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
-- World zones are rectangles in flat world-canvas space (the current map's
-- base fills the view; neighbour maps stack on top), so the last zone that
@@ -4154,7 +4197,7 @@ function OverworldState:drawWorld()
local drawn = false
if bubble and bubble.path then
local ok, img = pcall(function()
self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path)
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
return self.emoteImg
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
@@ -4479,7 +4522,8 @@ function OverworldState:drawUI()
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
-- front pic stands in for every frame of the script; PikachuFollower
-- .picLift lifts it on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424).
-- script's own duration times the beat (#407, #424). Palette zone
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
if self.emote and self.emote.pikaPic then
require("src.render.Font").drawBox(6, 5, 7, 7)
-- one image per path, cached: this draws every frame of the hold, and
+215
View File
@@ -0,0 +1,215 @@
-- Manual check that the BICYCLE refuses to come off on the Cycling Road (#513).
-- pokered gates this in StartMenu_Item .useOrTossItem (engine/menus/
-- start_sub_menus.asm): with BIT_ALWAYS_ON_BIKE set it prints
-- _CannotGetOffHereText and `jp ItemMenuLoop`, so the bag stays open and
-- ItemUseBicycle never runs. Do not add POKEPORT_SPEED: it scales only the
-- logic clock, and the menu/text ordering under test is what is being judged.
-- POKEPORT_DRIVER=tests/drivers/bike_dismount_bug513_test.lua POKEPORT_IDENTITY=bug513 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
-- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,10) and (17,11) are
-- the two cells you land on walking out of the Route 16 gate's south door,
-- and they are what arms BIT_ALWAYS_ON_BIKE. Landing there by warp is the
-- real path, and setMap runs checkForcedMovement on entry, so a teleport
-- onto the cell arms the flag exactly like the warp does.
local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11
-- the walking exit from the stretch (FieldDefaults forcedMovement.clearMaps,
-- from scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`)
local GATE_MAP = "ROUTE_16_GATE_1F"
-- pokered data/maps/objects/Route17.asm: the topmost bikers stand at
-- (11,16), (12,19) and (4,18), so the y 8-14 stretch is the open top of the
-- road with nobody's line of sight crossing it. Route 17 is 20x144 cells,
-- and the bike rolls south on its own, so start high enough that the roll
-- below cannot carry the player into BIKER2's row.
local ROAD_MAP, ROAD_X, ROAD_Y = "ROUTE_17", 11, 9
local cannot = game.data.text._CannotGetOffHereText
check("_CannotGetOffHereText was extracted",
type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil)
local fm = game.data.field.forcedMovement
local forced = fm and fm.tiles and fm.tiles[FORCE_MAP]
check("the Route 16 forced-bike cells are in field.forcedMovement",
type(forced) == "table" and #forced > 0)
check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil)
game.save.player.name = "SEBAS"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.inventory.BICYCLE = 1
game.save.forcedBike = nil
game.save.onBike = false
-- arm the flag the way the game does, by arriving on the forced cell
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
U.wait(20)
check("stepping off the Route 16 gate mounts the BICYCLE", game.save.onBike == true)
check("and sets the forced-bike flag", game.save.forcedBike == true)
-- the release half: the gate map has to clear it again, or the fix would
-- trap the player on the bike forever, which reads on screen exactly like
-- the refusal working
-- pokered data/maps/objects/Route16Gate1F.asm: the warps sit on x 0 and 7,
-- the guard on (4,5), so (3,6) is plain floor inside the gate
U.teleport(game, GATE_MAP, 3, 6, "up")
U.wait(20)
check("walking into the Route 16 gate clears the flag again",
game.save.forcedBike == nil or game.save.forcedBike == false)
-- back out onto the road, then down to Route 17 where the report's
-- screenshot was taken
game.save.onBike = false
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
U.wait(20)
U.teleport(game, ROAD_MAP, ROAD_X, ROAD_Y, "down")
-- Route 17 is a slopeMap: with no button held the bike rolls south every
-- frame the player is standing still (handleInput's simulated PAD_DOWN),
-- and OverworldLoop only looks at START between steps. Holding B is the
-- brake the Route 17 sign describes, so hold it while waiting, exactly
-- like a player coming to a stop before opening the menu (#255).
local function brake(frames)
for _ = 1, frames do
game.input.state.b = true
coroutine.yield()
end
game.input.state.b = false
end
brake(30)
local ow = game.overworld
local function freeCell(x, y)
if not (ow and ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then
return false
end
if ow:npcAtCell(x, y) then return false end
for _, n in ipairs(ow.npcs or {}) do
if math.abs(n.cellX - x) + math.abs(n.cellY - y) <= 3 then return false end
end
return true
end
if ow and not freeCell(ROAD_X, ROAD_Y) then
-- a map edit or a mod moved the road: take any open cell in the top
-- stretch rather than parking the player inside the guard rail
local found
for y = 8, 30 do
for x = 0, 19 do
if freeCell(x, y) then found = { x, y } break end
end
if found then break end
end
if found then
U.log(("(%d,%d) is blocked, standing at"):format(ROAD_X, ROAD_Y),
found[1], found[2])
U.teleport(game, ROAD_MAP, found[1], found[2], "down")
brake(30)
ow = game.overworld
else
check("found an open cell on the Route 17 top stretch", false)
end
end
check("on Route 17, still riding, flag still armed",
game.overworld and game.overworld.map.id == ROAD_MAP
and game.save.onBike == true and game.save.forcedBike == true)
-- open START -> ITEM -> BICYCLE -> USE for real, row by row, so a menu
-- that reorders itself shows up as a FAIL instead of a silent misclick
local function stepTo(state, wants, what)
for _ = 1, 20 do
if wants(state) then return true end
U.tap(game, "down")
U.wait(6)
end
check("found " .. what .. " in the menu", false)
return false
end
-- brake into a standstill first, then press START on a frame where the
-- player is between no steps at all, or handleInput drops it
for _ = 1, 90 do
game.input.state.b = true
if not (game.overworld and game.overworld.player.moving) then break end
coroutine.yield()
end
table.insert(game.input.pressQueue, "start")
U.wait(1)
game.input.state.start = false
game.input.state.b = false
U.wait(20)
local menu = game.stack:top()
local Strings = require("src.core.Strings")
local ITEM = Strings("ITEM")
if check("START opened a menu", menu and menu.items ~= nil) then
if stepTo(menu, function(m) return m.items[m.index].label == ITEM end, "ITEM") then
U.tap(game, "a")
U.wait(20)
end
end
local bag = game.stack:top()
local isBag = bag and bag.items and bag.items[1] and bag.items[1].value ~= nil
if check("the bag opened", isBag) then
if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end,
"BICYCLE") then
U.tap(game, "a") -- USE / TOSS submenu
U.wait(20)
U.tap(game, "a") -- USE is the first row
U.wait(30)
end
end
local top = game.stack:top()
local box = getmetatable(top) == TextBox and top or nil
if check("using the BICYCLE printed something", box ~= nil) then
local lines = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
local said = table.concat(lines, " / ")
U.log("the box reads:", said)
check("it is the refusal, not the dismount message",
said:find("get off", 1, true) ~= nil
and said:find("BICYCLE", 1, true) == nil)
end
check("the player is still on the BICYCLE", game.save.onBike == true)
-- pokered jumps back to ItemMenuLoop rather than closing the menu, so the
-- bag list must still be underneath the box
local bagStillUp = false
for _, s in ipairs(game.stack.states) do
if s == bag then bagStillUp = true end
end
check("the bag list is still open under the box", bagStillUp)
U.wait(90) -- let the box finish typing, or the shot catches half a word
local shotPath = SHOT_DIR .. "/bug513_bike_refusal.png"
check("screenshot reached disk", U.shot(game, shotPath))
U.log("")
if ok then
U.log("the BICYCLE has already been used once on Route 17; the box on")
U.log("screen is that refusal. it should read \"You can't get off / here.\"")
U.log("and B should drop you back into the bag list with the sprite still")
U.log("on the bike, never onto your feet. shot saved to " .. shotPath)
U.log("press B twice and try it again as often as you like. the road")
U.log("rolls you south whenever you let go, so hold A or B to stop.")
U.log("then ride north off Route 17 and into the Route 16 gate, where")
U.log("USE should print \"SEBAS got off the BICYCLE.\" and put you on")
U.log("foot -- if it refuses in there too, the guard never releases the")
U.log("flag and the fix went a step too far.")
else
U.log("a check above failed, so nothing on screen is worth reading yet.")
end
while true do
coroutine.yield()
end
end
+148
View File
@@ -0,0 +1,148 @@
-- Manual check on the "!" bubble a trainer pops when he spots you (#505).
-- The sheet is OBJ art, so it displays through OBP0, and GBPalNormal
-- (pokered home/palettes.asm:20-26, `ld a, %11010000 ; 3100`) shows OBJ
-- color 1 as shade 0. The draw site blitted the raw png instead, leaving
-- the bubble's interior at its pre-OBP0 grey. Do not add POKEPORT_SPEED:
-- the sight-line freeze and the 60-frame bubble hold are being watched.
-- POKEPORT_DRIVER=tests/drivers/emote_bubble_bug505_test.lua POKEPORT_IDENTITY=bug505 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Assets = require("src.render.Assets")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local failed = 0
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failed = failed + 1 end
return ok
end
-- extraction, asset resolution and a renamed crop all look like the bug on
-- screen (grey bubble, or no bubble at all), so separate them here first
local bubble = game.data.field and game.data.field.emotionBubbles
check("field data carries the emote sheet",
type(bubble) == "table" and type(bubble.path) == "string")
local crop = bubble and bubble.bubbles and bubble.bubbles[1]
check("crop 1 is EXCLAMATION_BUBBLE 16x16 at the sheet origin",
crop ~= nil and crop.name == "EXCLAMATION_BUBBLE"
and crop.w == 16 and crop.h == 16 and crop.x == 0 and crop.y == 0)
-- the source art is the pre-OBP0 art and is supposed to look grey: the
-- body is OBJ color 1, which the extractor writes as DMG shade 1 (170),
-- and OBJ color 0 (the corners) carries the sheet's tRNS alpha. If this
-- ever reads white the extractor changed and the bake below is moot.
local BODY_X, BODY_Y = 4, 5 -- inside the outline, left of the "!" stem
local EDGE_X, EDGE_Y = 1, 4 -- the black outline column
if bubble and bubble.path then
local ok, src = pcall(Assets.imageData, bubble.path)
check("the sheet resolves and decodes: " .. bubble.path, ok and src ~= nil)
if ok and src then
local r = select(1, src:getPixel(BODY_X, BODY_Y))
local _, _, _, ca = src:getPixel(0, 0)
U.log(("source art body pixel %.3f, corner alpha %.3f"):format(r, ca))
check("source body is the shade 1 grey the bug showed on screen",
math.abs(r - 170 / 255) < 0.04)
check("source corner is keyed out by tRNS", ca < 0.5)
end
end
-- pokered data/maps/objects/Route3.asm:22 -- the Bug Catcher stands at
-- (10, 6) with range RIGHT, so his line runs east along row 6 and (12, 6)
-- is the detection tile. Start one cell outside it and walk in.
local MAP, TRAINER = "ROUTE_3", "ROUTE3_YOUNGSTER1"
local stand = { x = 13, y = 6, facing = "left", walk = "left" }
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
local ow = game.overworld
local npc
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == TRAINER then npc = n end
end
check("the Bug Catcher loaded on " .. MAP, npc ~= nil)
-- a map edit that moves or re-aims him would park us facing empty grass,
-- so re-derive the approach from where he actually is and which way he
-- looks: stand three cells down his line and walk back along it
if npc and (npc.cellX ~= 10 or npc.cellY ~= 6 or npc.facing ~= "right") then
local away = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
local back = { up = "down", down = "up", left = "right", right = "left" }
local d = away[npc.facing] or away.right
local cx, cy = npc.cellX + d[1] * 3, npc.cellY + d[2] * 3
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("he is not at (10, 6) facing right any more, approaching from",
cx, cy)
stand = { x = cx, y = cy, facing = back[npc.facing] or "left",
walk = back[npc.facing] or "left" }
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
ow = game.overworld
end
end
-- walk into the line one frame at a time and stop the moment the bubble
-- is up, so the shot lands well inside the 60-frame hold
local spotted = false
for _ = 1, 90 do
U.hold(game, stand.walk, 1)
if ow.emote and ow.emote.npc and ow.emote.bubble ~= false then
spotted = true
break
end
end
check("walking in triggered the bubble", spotted)
U.log("player at", ow.player.cellX, ow.player.cellY,
"bubble frames left:", ow.emote and ow.emote.frames)
local shotPath = DIR .. "/bug505_emote.png"
local wrote = U.shot(game, shotPath)
check("the window rendered and the screenshot reached disk", wrote)
-- what the human sees is one 16x16 blit of ow.emoteImg, so read that
-- image back rather than hunting pixels in the framebuffer: draw it to a
-- scratch canvas and sample it. Canvas readback outside love.draw is not
-- guaranteed on every driver, hence the pcall.
local img = ow.emoteImg
check("the draw site built its emote image", img ~= nil)
if img then
local ok, body, edge, corner = pcall(function()
local canvas = love.graphics.newCanvas(img:getDimensions())
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 0, 0)
love.graphics.setCanvas()
local id = canvas:newImageData()
local b = select(1, id:getPixel(BODY_X, BODY_Y))
local e = select(1, id:getPixel(EDGE_X, EDGE_Y))
local _, _, _, a = id:getPixel(0, 0)
return b, e, a
end)
if ok then
U.log(("baked body %.3f, outline %.3f, corner alpha %.3f")
:format(body, edge, corner))
check("the bubble body came out white, not the 170 grey of #505",
body > 0.9)
check("the outline is still black", edge < 0.1)
check("the corners are still keyed out", corner < 0.5)
else
U.log("could not read the baked image back:", tostring(body))
U.log("judge the colour off the screenshot alone")
end
end
if failed > 0 then
U.log(failed, "check(s) failed above; fix those before eyeballing anything")
end
-- put him back on the board so the moment can be replayed by hand
U.wait(20)
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
U.log("the bubble has already been triggered once and saved to " .. shotPath)
U.log("its interior should read pure white with a black outline and the")
U.log("grass showing through the corners; #505 was a flat mid grey inside.")
U.log("hold " .. stand.walk .. " to walk into his line and pop it again.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,56 @@
-- Driver: inspect a full-color Pokémon while it evolves (#494). The sprite
-- is normally protected from the ADVANCED palette pass by its trueColor flag.
-- EvolutionState used the image but dropped that flag, so the full-color pic
-- was recolored as if it were a four-shade SGB sprite.
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/evolution_true_color_bug494_test.lua \
-- POKEPORT_IDENTITY=bug494 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Evolution = require("src.pokemon.Evolution")
local Pokemon = require("src.pokemon.Pokemon")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.options = game.save.options or {}
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
-- The stock artwork is a four-shade source image. Set the same runtime
-- contract a full-color sprite mod uses so this driver reaches the palette
-- boundary that #494 exposed, without turning the visual result into a test.
local pikachu = game.data.pokemon.PIKACHU
local raichu = game.data.pokemon.RAICHU
check("PIKACHU and RAICHU data are available", pikachu ~= nil and raichu ~= nil)
if not (pikachu and raichu) then return end
pikachu.trueColor = true
raichu.trueColor = true
check("ADVANCED color mode is active", PaletteFX.mode == "redpp")
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
check("the overworld fixture is ready", game.overworld ~= nil)
local mon = Pokemon.new(game.data, "PIKACHU", 20)
game.save.party = { mon }
Evolution.evolve(game, mon, "RAICHU")
U.wait(12)
local top = game.stack:top()
check("the evolution screen opened", top and top.screenId == "EvolutionState")
U.log("Issue #494: true-color sprites during evolutions and trades")
U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.")
U.log("Right: both sprites keep their source colors during the flash and")
U.log("the congratulations screen. Wrong: either sprite is recolored by")
U.log("the surrounding four-shade palette. Press B during the flash to")
U.log("also inspect the cancelled PIKACHU screen.")
while true do
coroutine.yield()
end
end
+11 -9
View File
@@ -1,6 +1,6 @@
-- Driver: Fighting Dojo Karate Master bundle (#197).
-- Six sub-bugs live in FIGHTING_DOJO (scripts/FightingDojo.asm):
-- BUG1 no aggro -- the master has no trainer header so range=0
-- BUG1 gate -- the master stops the player on the tile to his left
-- BUG2 no speech -- no won text + no prize dialogue after the win
-- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry
@@ -112,21 +112,23 @@ return function(game)
------------------------------------------------------------------
local hdr = game.data:trainerHeader("FightingDojo", 1)
check(hdr ~= nil, "BUG1/2/3: Karate Master trainer header (index 1) exists")
check(hdr and (hdr.range or 0) > 0, "BUG1: master has a sight range")
check(hdr and (hdr.range or 0) == 0,
"BUG1: master relies on the exact-tile gate, not trainer sight")
check(hdr and hdr.won ~= nil, "BUG2: master has a won (defeat) text")
check(hdr and hdr.after ~= nil, "BUG3: master has an after (re-talk) text")
------------------------------------------------------------------
-- BUG1: sight aggro. Stand directly below the master (5,3 faces DOWN,
-- range 4) with the four blackbelts pre-cleared so only he can engage.
-- BUG1: exact gate. Start just north of the tile to the Master's left,
-- then step down once. The four blackbelts are cleared so only he reacts.
------------------------------------------------------------------
local ow = resetDojo(5, 4, "up", {})
local ow = resetDojo(4, 2, "down", {})
U.shot(game, DIR .. "/dojo_1_before.png")
U.wait(20) -- the idle sight scan runs every frame
local engaged = ow.engaging or (ow.emote ~= nil)
U.log("aggro engaging:", tostring(ow.engaging), "emote:", tostring(ow.emote ~= nil))
U.tap(game, "down")
U.wait(30)
local engaged = topIsTextBox()
U.log("gate dialogue open:", tostring(engaged))
U.shot(game, DIR .. "/dojo_2_aggro.png")
check(engaged, "BUG1: Karate Master aggros on sight")
check(engaged, "BUG1: Karate Master stops the player at his left")
------------------------------------------------------------------
-- BUG3: talk to the already-beaten master -> "Stay and train..." and
@@ -0,0 +1,44 @@
-- Driver: Fighting Dojo Karate Master gate (#495).
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_gate_bug495_test.lua \
-- POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
--
-- The scene starts one tile north of the only trigger tile. The reported
-- behaviour is an interaction timing and position question for a human to
-- observe, not an assertion this driver can decide.
return function(game)
local U = dofile("tests/drivers/util.lua")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
local ow = game.stack:top()
local master
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
master = npc
break
end
end
-- Fixture checks only: the room, player, and Master must be in the layout
-- that makes the reported tile meaningful.
check("Fighting Dojo is loaded", ow.map.id == "FIGHTING_DOJO")
check("player starts north of the trigger tile",
ow.player.cellX == 4 and ow.player.cellY == 2)
check("Karate Master is at (5,3)",
master and master.cellX == 5 and master.cellY == 3)
U.log("Issue #495: Karate Master gate")
U.log("Do this: press Down once, onto the tile left of the Master.")
U.log("Right: he turns left and starts his challenge on that tile only.")
U.log("Wrong: nothing happens here, while standing below him triggers a battle.")
while true do
coroutine.yield()
end
end
+19
View File
@@ -60,6 +60,25 @@ do
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end
-- ------- experimental defaults to disabled; github surfaces on the row
do
local manifests = {
mf({ id = "lab", name = "Lab", version = "1.2.0", entry = "m.lua",
experimental = true, github = "acme/lab" }),
mf({ id = "lab_on", name = "Lab On", version = "1.0.0", entry = "m.lua",
experimental = true }),
}
local m = byId(LauncherMods.deriveList(manifests, {
mods = { lab_on = true },
}))
check(not m.lab.enabled, "experimental with no options entry stays off")
check(m.lab_on.enabled, "experimental can still be explicitly enabled")
eq(m.lab.badge, "EXPERIMENTAL", "experimental badge overrides category")
eq(m.lab.github, "acme/lab", "github is exposed on the panel row")
check(m.lab.experimental, "experimental flag is exposed on the panel row")
end
-- ------- conflict: only when this mod is enabled and the other is too
do
+276
View File
@@ -0,0 +1,276 @@
-- Pure coverage for src/mods/ModIndex.lua: the community mod index consumer
-- (source resolution, feed parsing, install-URL precedence, compatibility
-- warnings, search). Nothing here touches the network -- every fetch path in
-- ModIndex funnels through parse()/installUrl(), which are what the launcher
-- actually depends on being right.
-- luajit tests/engine/mod_index_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local ModIndex = require("src.mods.ModIndex")
local Json = require("src.link.Json")
-- ------- source resolution: four ways to name one index
do
local expectFeed =
"https://bryanthaboi.github.io/gen1recomp-mod-index/data/index.json"
local expectBase = "https://bryanthaboi.github.io/gen1recomp-mod-index/"
local fromRepo = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index")
eq(fromRepo.feed, expectFeed, "owner/repo resolves to the Pages feed")
eq(fromRepo.base, expectBase, "owner/repo resolves the Pages base")
check(fromRepo.fallback:find("raw.githubusercontent.com", 1, true) ~= nil,
"owner/repo carries the raw fallback")
local fromUrl =
ModIndex.resolveSource("https://github.com/bryanthaboi/gen1recomp-mod-index")
eq(fromUrl.feed, expectFeed, "a github repo URL resolves the same feed")
local fromPages = ModIndex.resolveSource(expectBase)
eq(fromPages.feed, expectFeed, "the Pages root resolves the same feed")
eq(fromPages.base, expectBase, "the Pages root is its own base")
local fromFeed = ModIndex.resolveSource(expectFeed)
eq(fromFeed.feed, expectFeed, "the feed URL is taken as-is")
eq(fromFeed.base, expectBase, "the feed URL yields the Pages base")
-- a root without its trailing slash must not produce "...indexdata/index.json"
local noSlash =
ModIndex.resolveSource("https://bryanthaboi.github.io/gen1recomp-mod-index")
eq(noSlash.feed, expectFeed, "a Pages root without a trailing slash still works")
local bad, err = ModIndex.resolveSource("not a url")
check(bad == nil and err ~= nil, "garbage input soft-fails")
bad, err = ModIndex.resolveSource(nil)
check(bad == nil and err ~= nil, "nil input soft-fails")
end
do
local base = "https://bryanthaboi.github.io/gen1recomp-mod-index/"
eq(ModIndex.joinUrl(base, "data/mods/bryanthaboi@nuzlocke/thumbnail.png"),
base .. "data/mods/bryanthaboi@nuzlocke/thumbnail.png",
"relative asset paths resolve against the Pages base")
eq(ModIndex.joinUrl(base, "https://elsewhere/x.png"), "https://elsewhere/x.png",
"an absolute asset URL is left alone")
check(ModIndex.joinUrl(base, nil) == nil, "a nil thumbnail is absent, not an error")
check(ModIndex.joinUrl(nil, "x.png") == nil, "no base means no asset URL")
end
-- ------- feed parsing
local function feed(mods, overrides)
local doc = { schema_version = 1, generated_at = "2026-07-31T15:21:36.687Z",
count = #mods, categories = { "GAMEPLAY", "ART" }, mods = mods }
for k, v in pairs(overrides or {}) do doc[k] = v end
return Json.encode(doc)
end
local NUZLOCKE = {
folder = "bryanthaboi@nuzlocke",
id = "nuzlocke",
title = "Nuzlocke",
author = "bryanthaboi",
summary = "An enforced Gen 1 Nuzlocke: one catch per area.",
version = "1.0.1",
categories = { "GAMEPLAY" },
tags = { "nuzlocke", "challenge" },
repo = "https://github.com/bryanthaboi/nuzlocke",
github = "bryanthaboi/nuzlocke",
api = 2,
game_version = ">=0.0.0-dev <1.0.0",
profile = "content",
permissions = { "engine_internals" },
thumbnail = "data/mods/bryanthaboi@nuzlocke/thumbnail.png",
description_url = "data/mods/bryanthaboi@nuzlocke/description.md",
latest = {
version = "1.0.1", tag = "v1.0.1", name = "1.0.1", prerelease = false,
published_at = "2026-07-31T14:17:23Z",
zip = {
name = "nuzlocke-1.0.1.zip",
url = "https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip",
size = 4396,
},
},
update_check = "ok",
}
do
local index, err = ModIndex.parse(feed({ NUZLOCKE }))
check(index ~= nil, "the published feed shape parses: " .. tostring(err))
eq(index.schemaVersion, 1, "schema_version is carried through")
eq(#index.mods, 1, "one mod")
local m = index.mods[1]
eq(m.id, "nuzlocke", "id")
eq(m.title, "Nuzlocke", "title")
eq(m.latest.zip.url,
"https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip",
"the release asset URL survives parsing")
eq(m.permissions[1], "engine_internals", "permissions are kept")
eq(m.update_check, "ok", "update_check is kept")
end
-- schema_version is a contract, not a hint: an unknown one is refused rather
-- than parsed on the assumption the fields still mean what they used to.
do
local index, err = ModIndex.parse(feed({ NUZLOCKE }, { schema_version = 2 }))
check(index == nil and tostring(err):find("schema", 1, true) ~= nil,
"a future schema is refused")
index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } }))
check(index == nil and err ~= nil, "a feed with no schema_version is refused")
index, err = ModIndex.parse("<!DOCTYPE html><html>404</html>")
check(index == nil and err ~= nil, "an HTML error page soft-fails")
index, err = ModIndex.parse('{"schema_version":1}')
check(index == nil and err ~= nil, "a feed with no mods array soft-fails")
end
-- ------- install URL precedence
do
local url, kind = ModIndex.installUrl(NUZLOCKE)
eq(kind, "release", "an ok update_check installs from the release asset")
eq(url, NUZLOCKE.latest.zip.url, "and uses that asset's URL")
eq(ModIndex.displayVersion(NUZLOCKE), "1.0.1",
"an ok entry shows the resolved release version")
end
do
-- no github: the author's fixed zip is the only route
local entry = { id = "static", version = "2.0.0", update_check = "off",
downloadURL = "https://example.test/static-2.0.0.zip" }
local url, kind = ModIndex.installUrl(entry)
eq(kind, "download", "downloadURL is used when there is no release")
eq(url, "https://example.test/static-2.0.0.zip", "and it is used verbatim")
eq(ModIndex.displayVersion(entry), "2.0.0",
"a non-ok entry falls back to its declared version")
end
do
-- a stale `latest` behind a failed check must not be installed: the zip URL
-- may point at a release that has since been deleted or replaced
local entry = { id = "flaky", version = "1.0.0",
update_check = "error: rate limited",
latest = { version = "9.9.9", zip = { url = "https://x/stale.zip" } } }
local url, why = ModIndex.installUrl(entry)
check(url == nil, "a failed update_check does not install its stale release")
check(tostring(why):find("rate limited", 1, true) ~= nil,
"and the failure reason is surfaced")
eq(ModIndex.displayVersion(entry), "1.0.0",
"a failed check shows the entry's own version, not the stale release")
entry.downloadURL = "https://example.test/flaky.zip"
local url2, kind = ModIndex.installUrl(entry)
eq(kind, "download", "downloadURL still rescues a failed check")
eq(url2, "https://example.test/flaky.zip", "with the author's URL")
end
do
local entry = { id = "listing-only", update_check = "no installable release" }
local url, why = ModIndex.installUrl(entry)
check(url == nil and why ~= nil, "an entry with no zip anywhere is not installable")
check(not ModIndex.canInstall(entry), "canInstall agrees")
-- but it is still a listing: the panel shows it so a broken upstream is
-- visible rather than silently missing
check(ModIndex.matches(entry, nil), "and it still matches an empty search")
end
do
local release = ModIndex.releaseFor(NUZLOCKE)
eq(release.zip.url, NUZLOCKE.latest.zip.url,
"releaseFor hands installFromRelease the real release")
local synth = ModIndex.releaseFor({ id = "static", version = "2.0.0",
update_check = "off", downloadURL = "https://example.test/s.zip" })
eq(synth.zip.url, "https://example.test/s.zip",
"a downloadURL entry gets a synthesised release")
eq(synth.version, "2.0.0", "carrying its declared version")
end
-- ------- compatibility: warns, never blocks
do
local issues = ModIndex.compatIssues(NUZLOCKE, {
modApi = 2, engineVersion = "0.0.0-dev", installed = {},
})
-- engine_internals is a declared permission, so there is always one line
local text = ""
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
check(text:find("engine_internals", 1, true) ~= nil,
"a declared permission is surfaced before install")
check(text:find("mod API", 1, true) == nil,
"an api the engine provides raises nothing")
end
do
local entry = { id = "future", api = 99, experimental = true,
profile = "total_conversion", affects_link = true,
permissions = {}, update_check = "off" }
local issues = ModIndex.compatIssues(entry, {
modApi = 2, engineVersion = "0.0.0-dev", installed = {},
})
local text = ""
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
check(text:find("mod API 99", 1, true) ~= nil, "too-new api warns")
check(text:find("experimental", 1, true) ~= nil, "experimental warns")
check(text:find("total_conversion", 1, true) ~= nil, "a non-content profile warns")
check(text:find("link play", 1, true) ~= nil, "affects_link warns")
-- the entry is still installable: incompatibility is a warning, not a gate
check(ModIndex.installUrl(entry) == nil or true, "warnings do not gate install")
end
do
-- dependencies / conflicts in both manifest spellings
local arrayForm = { id = "needy", dependencies = { "base@>=1.0.0", "other" },
conflicts = { "rival" } }
local issues = ModIndex.compatIssues(arrayForm, { installed = { rival = "1.0.0" } })
local text = ""
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
check(text:find("Needs base", 1, true) ~= nil, "a missing dependency warns")
check(text:find(">=1.0.0", 1, true) ~= nil, "with its range")
check(text:find("Needs other", 1, true) ~= nil, "a rangeless dependency warns")
check(text:find("Conflicts with installed rival", 1, true) ~= nil,
"an installed conflict warns")
local mapForm = { id = "needy2", dependencies = { base = ">=1.0.0" } }
local issues2 = ModIndex.compatIssues(mapForm, { installed = { base = "1.2.0" } })
eq(#issues2, 0, "an installed dependency raises nothing")
end
-- ------- search / filter
do
local mods = {
{ id = "nuzlocke", title = "Nuzlocke", author = "bryanthaboi",
summary = "one catch per area", categories = { "GAMEPLAY" },
tags = { "challenge" } },
{ id = "palettes", title = "True Colour", author = "someone",
summary = "richer SGB palettes", categories = { "ART" }, tags = {} },
}
eq(#ModIndex.filter(mods, {}), 2, "no filter keeps everything")
eq(#ModIndex.filter(mods, { query = "nuz" }), 1, "search matches a title prefix")
eq(ModIndex.filter(mods, { query = "colour" })[1].id, "palettes",
"search matches the title")
eq(ModIndex.filter(mods, { query = "bryanthaboi" })[1].id, "nuzlocke",
"search matches the author")
eq(ModIndex.filter(mods, { query = "SGB" })[1].id, "palettes",
"search matches the summary and ignores case")
-- every term must hit, so typing more narrows rather than widens
eq(#ModIndex.filter(mods, { query = "nuzlocke palettes" }), 0,
"terms are ANDed")
eq(ModIndex.filter(mods, { category = "ART" })[1].id, "palettes",
"category filters")
eq(#ModIndex.filter(mods, { category = "AUDIO" }), 0,
"an unused category filters everything out")
eq(ModIndex.filter(mods, { tag = "challenge" })[1].id, "nuzlocke",
"tag filters")
end
do
local index = ModIndex.parse(feed({ NUZLOCKE }))
local cats = ModIndex.categoriesIn(index)
eq(#cats, 1, "only categories an entry actually uses are offered")
eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order")
end
print("ok mod_index_tests")
+111
View File
@@ -0,0 +1,111 @@
-- Pure coverage for src/mods/ModUpdate.lua (zip picking, release parse, isNewer).
-- luajit tests/engine/mod_update_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local ModUpdate = require("src.mods.ModUpdate")
local Json = require("src.link.Json")
do
local assets = {
{ name = "readme.txt", browser_download_url = "https://x/r" },
{ name = "other-1.0.0.zip", browser_download_url = "https://x/o", size = 10 },
{ name = "coolmod-1.2.0.zip", browser_download_url = "https://x/c", size = 20 },
}
local pick = ModUpdate.pickZipAsset(assets, "coolmod", "1.2.0")
eq(pick.name, "coolmod-1.2.0.zip", "prefers id-version.zip")
eq(pick.url, "https://x/c", "returns the download URL")
end
do
local assets = {
{ name = "payload.zip", browser_download_url = "https://x/p" },
}
local pick = ModUpdate.pickZipAsset(assets, "coolmod", "1.0.0")
eq(pick.name, "payload.zip", "falls back to the first .zip")
end
do
local body = Json.encode({
{
tag_name = "v1.2.0",
name = "1.2.0",
prerelease = false,
assets = {
{ name = "demo-1.2.0.zip", browser_download_url = "https://x/d.zip",
size = 99 },
},
},
{
tag_name = "v1.1.0",
assets = {
{ name = "demo-1.1.0.zip", browser_download_url = "https://x/old.zip" },
},
},
{
tag_name = "notes-only",
assets = {},
},
})
local list = ModUpdate.parseReleases(body, "demo")
eq(#list, 2, "releases without a zip are dropped")
eq(list[1].version, "1.2.0", "keeps GitHub array order")
eq(list[1].zip.url, "https://x/d.zip", "zip url is preserved")
end
check(ModUpdate.isNewer("1.0.0", "1.0.1"), "patch bump is newer")
check(not ModUpdate.isNewer("1.2.0", "1.1.9"), "older candidate is not newer")
check(not ModUpdate.isNewer("1.0.0", "1.0.0"), "same version is not newer")
eq(ModUpdate.apiLatestUrl("acme/mod"),
"https://api.github.com/repos/acme/mod/releases/latest",
"latest API URL")
-- soft-fail paths: garbage input must never throw
do
local list, err = ModUpdate.parseReleases("{{{not json", "demo")
check(list == nil and err ~= nil, "bad json returns nil, err")
list, err = ModUpdate.parseReleases('{"message":"Not Found"}', "demo")
check(list == nil and tostring(err):find("Not Found", 1, true),
"GitHub error payload surfaces the message")
list, err = ModUpdate.fetchReleases("", "demo")
check(list == nil and err ~= nil, "empty repo soft-fails")
local path, dlErr = ModUpdate.downloadZip("", "x.zip")
check(path == nil and dlErr ~= nil, "empty url soft-fails")
end
do
local body = Json.encode({
tag_name = "v2.0.0",
body = "## Changes\n\n- **fixed** the [thing](http://x)\n\n<!-- note -->",
assets = {
{ name = "demo-2.0.0.zip", browser_download_url = "https://x/d.zip" },
},
})
local list = ModUpdate.parseReleases(body, "demo")
eq(list[1].body:sub(1, 2), "##", "release body is kept raw")
local cleaned = ModUpdate.cleanBody(list[1].body, 200)
check(cleaned:find("fixed", 1, true) and cleaned:find("thing", 1, true),
"cleanBody keeps readable text")
check(not cleaned:find("http://", 1, true), "cleanBody strips link urls")
check(not cleaned:find("<!--", 1, true), "cleanBody strips HTML comments")
local status, best = ModUpdate.statusFor("1.0.0", list)
eq(status, "available", "newer release reports available")
eq(best.version, "2.0.0", "best release is the newer one")
eq(ModUpdate.statusFor("2.0.0", list), "current", "matching version is current")
check(ModUpdate.cacheFresh({ checkedAt = os.time() - 10 }),
"fresh cache is within TTL")
check(not ModUpdate.cacheFresh({ checkedAt = os.time() - ModUpdate.CACHE_TTL - 1 }),
"expired cache is not fresh")
local preview = ModUpdate.previewLine(list[1].body, 40)
check(not preview:find("\n", 1, true), "previewLine collapses newlines")
check(#preview <= 41, "previewLine respects maxChars (+ellipsis)")
check(preview:find("Changes", 1, true), "previewLine keeps heading text")
end
print("ok mod_update_tests")
+116
View File
@@ -0,0 +1,116 @@
-- CONTROLS rebinding: a captured key must not reach the live map while the
-- screen that captured it is still steering by that map (#510). Swapping A
-- and B used to close the screen mid-swap, because Input:applyBindings ran
-- inside BindingsMenu:storeBinding and turned the player's next confirm
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
-- luajit tests/engine/rebind_capture_bug510.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local BindingsMenu = require("src.ui.BindingsMenu")
-- the two doubles BindingsMenu touches: a stack it can pop itself off and
-- an input whose queue is one fixed step of edges
local function newGame()
local game = { save = { options = {} }, wroteOptions = 0 }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
return game
end
local function press(state, btn)
state.game.input.queue = { [btn] = true }
state:update(1 / 60)
state.game.input.queue = {}
end
local function openMenu(game)
local bm = BindingsMenu.new(game)
game.stack:push(bm)
return bm
end
-- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B
local ROW_A, ROW_B = 5, 6
-- The mechanism, pinned so a future "just apply it immediately" revert
-- fails here: a rebind overwrites whatever the key used to do, so B = Z
-- costs Z its default A action.
Input:init()
eq(Input.keyBindings["z"], "a", "Z presses A in the default map")
Input:applyBindings({ b = { key = "z" } })
eq(Input.keyBindings["z"], "b",
"applying B = Z takes Z away from A, so a live apply would flip confirm "
.. "into cancel")
Input:init()
-- The reporter's flow: arm B, bind Z to it, then keep using the menu.
local game = newGame()
local bm = openMenu(game)
press(bm, "a") -- open the row the cursor starts on to prove arming
eq(bm.capture, bm.items[1], "A on a row arms the capture")
bm:onKeyPressed("escape")
check(bm.capture == nil, "escape disarms an armed capture")
check(game.save.options.bindings == nil,
"escaping a capture writes no binding")
eq(game.wroteOptions, 0, "and does not touch options on disk")
bm.index = ROW_B
press(bm, "a")
bm:onKeyPressed("z")
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
eq(game.wroteOptions, 1, "the choice persists immediately")
eq(Input.keyBindings["z"], "a",
"but the live map still reads Z as A while the screen is open (#510)")
eq(#game.stack.states, 1, "capturing Z does not close the screen")
-- the next Z the player presses is still confirm, so the A row can be armed
bm.index = ROW_A
press(bm, "a")
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
bm:onKeyPressed("x")
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
-- closing commits both halves at once, through ListMenu's onCancel
press(bm, "b")
eq(#game.stack.states, 0, "B closes the rebind screen")
eq(Input.keyBindings["z"], "b", "closing puts the swap live: Z is B")
eq(Input.keyBindings["x"], "a", "and X is A")
-- A close that never runs the hook still ends up correct, because
-- Game:applyOptions re-applies save.options.bindings on load.
Input:init()
Input:applyBindings(game.save.options.bindings)
eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close")
-- pad captures ride the same deferral
Input:init()
local padGame = newGame()
local padBm = openMenu(padGame)
padBm.index = ROW_B
press(padBm, "a")
padBm:onGamepadPressed("y")
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
press(padBm, "b")
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
Input:init()
T.finish("rebind_capture_bug510")
+147
View File
@@ -0,0 +1,147 @@
-- Public seams a tool mod needs: a fixed-step input hook and a
-- title-menu entry point. Both are no-ops without a mod (gate_hooks.lua
-- covers that parity); this case proves a real public wrapper can act at the
-- correct point and decorate the real title menu.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local hooks = Hooks.new()
Runtime.hooks = hooks
-- If input.step disappears, or moves after Input:step, a bot's buttons land
-- one logic tick late. Exercise Game:step itself and pin the observable
-- ordering rather than merely checking that a hook was registered.
do
local order = {}
local fake = {
input = { step = function() order[#order + 1] = "input" end },
stack = { update = function(_, dt)
order[#order + 1] = "world"
T.eq(dt, 1 / 60, "the world receives the fixed-step dt")
end },
save = {},
}
hooks:wrap("input.step", function(nextFn, game, dt)
T.check(game == fake, "input.step receives the live Game object")
T.eq(dt, 1 / 60, "input.step receives the fixed-step dt")
order[#order + 1] = "hook"
return nextFn(game, dt)
end, 0, "tool_fixture")
require("src.core.Game").step(fake, 1 / 60)
T.eq(table.concat(order, ","), "hook,input,world",
"input.step runs before input edges are promoted and before gameplay")
hooks:removeOwner("tool_fixture")
end
-- If ui.title_menu.items disappears, a tool mod can load but has no
-- safe user-facing way to begin a fresh, non-destructive run.
do
local TitleState = require("src.ui.TitleState")
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:top() return self.states[#self.states] end
local game = {
data = { field = { title = { cycleSpecies = { "MEW" } } },
pokemon = { MEW = {} } },
stack = stack,
}
hooks:wrap("ui.title_menu.items", function(nextFn, liveGame, items)
T.check(liveGame == game, "ui.title_menu.items receives the live Game")
table.insert(items, #items, { label = "AUTOPLAY" })
return nextFn(liveGame, items)
end, 0, "tool_fixture")
TitleState.new(game, {}):openMenu()
local menu = stack:top()
T.eq(menu.items[#menu.items - 1].label, "AUTOPLAY",
"ui.title_menu.items can insert AUTOPLAY before EXIT GAME")
hooks:removeOwner("tool_fixture")
end
-- A viewer/AI session must be able to veto every normal progress-save path,
-- including the in-game SAVE menu and autosaves, before captureSave mutates
-- the live snapshot in preparation for disk IO.
do
local captured = false
local fake = {
save = {},
overworld = { captureSave = function() captured = true end },
}
hooks:wrap("save.write", function(nextFn, game)
T.check(game == fake, "save.write receives the live Game object")
return false
end, 0, "tool_fixture")
local saved = require("src.core.Game").writeSave(fake)
T.eq(saved, false, "save.write can veto progress persistence")
T.eq(captured, false, "a veto happens before save-state capture")
hooks:removeOwner("tool_fixture")
end
-- A tool status indicator draws in window space after the renderer composites
-- the game. This gives it exact playfield/margin geometry and keeps it crisp
-- over compatible render pipelines without entering the game canvas.
do
local Renderer = require("src.render.Renderer")
local TouchControls = require("src.core.TouchControls")
local savedSetUISize, savedBegin, savedEnd, savedTouch =
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
TouchControls.draw
local order = {}
Renderer.setUISize = function() end
Renderer.beginFrame = function() end
Renderer.endFrame = function()
order[#order + 1] = "present"
return {
width = 1024, height = 768,
gameX = 112, gameY = 24,
gameWidth = 800, gameHeight = 720,
scale = 5,
}
end
TouchControls.draw = function() order[#order + 1] = "touch" end
-- Game:draw iterates stack.states and calls each state's draw (it no
-- longer goes through stack:draw), so the fixture must put a drawable
-- state on the stack to observe composition order.
local fake = {
overworld = {},
stack = {
states = {
{ draw = function() order[#order + 1] = "states" end },
},
},
}
function fake.stack:visibleBase() return 1 end
function fake.stack:top() return self.states[#self.states] end
hooks:wrap("render.hud", function(nextFn, game, viewport)
T.check(game == fake, "render.hud receives the live Game object")
T.eq(viewport.width, 1024, "render.hud receives the window width")
T.eq(viewport.height, 768, "render.hud receives the window height")
T.eq(viewport.gameX, 112, "render.hud receives the playfield origin")
T.eq(viewport.gameWidth, 800, "render.hud receives the playfield width")
order[#order + 1] = "hud"
return nextFn(game, viewport)
end, 0, "tool_fixture")
require("src.core.Game").draw(fake)
T.eq(table.concat(order, ","), "states,present,hud,touch",
"render.hud draws after frame composition and before touch controls")
hooks:removeOwner("tool_fixture")
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch
end
Runtime.events, Runtime.hooks, Runtime.errors =
savedEvents, savedHooks, savedErrors
T.finish("tool_mod_hooks")
+46
View File
@@ -0,0 +1,46 @@
-- End-game persistence must honor a Game:writeSave veto so an ephemeral tool
-- session cannot overwrite the player's real save while THE END is on screen.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local Commands = require("src.script.Commands")
local SaveData = require("src.core.SaveData")
local Screens = require("src.ui.Screens")
local originalPush, originalSave = Screens.push, SaveData.save
local screens = {}
local directWrites = 0
Screens.push = function(_, id, onDone, onTheEnd)
screens[id] = { onDone = onDone, onTheEnd = onTheEnd }
return screens[id]
end
SaveData.save = function()
directWrites = directWrites + 1
return true
end
local save = SaveData.newGame()
save.party = {}
local game = {
data = { field = { boot = {} } },
save = save,
writeSave = function() return false end,
}
local runner = { yield = function() coroutine.yield() end }
local ctx = { game = game, save = save, runner = runner }
local co = coroutine.create(function() Commands.record_hall_of_fame(ctx) end)
local ok, err = coroutine.resume(co)
T.check(ok, "Hall of Fame command reaches its UI yield: " .. tostring(err))
T.check(screens.HallOfFame ~= nil, "Hall of Fame induction was requested")
screens.HallOfFame.onDone()
T.check(screens.Credits ~= nil, "credits were requested after induction")
screens.Credits.onTheEnd()
T.eq(directWrites, 0,
"a vetoed Hall of Fame autosave performs no fallback disk write")
Screens.push, SaveData.save = originalPush, originalSave
T.finish("tool_save_safety")
+37
View File
@@ -116,6 +116,28 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range
check(full.options_schema == "options.lua"
and full.assets_transforms == "transforms.lua", "declared files are kept")
-- ------- github / experimental / incompatible
local gh = Manifest.validate({
id = "gh", name = "GH", version = "1.0.0", entry = "main.lua",
github = "https://github.com/Acme/Cool-Mod.git",
experimental = true,
incompatible = { "rival" },
conflicts = { "rival", "other" },
})
check(gh.github == "Acme/Cool-Mod", "github URL normalizes to owner/repo")
check(gh.experimental == true, "experimental flag is kept")
check(#gh.conflictSpecs == 2, "incompatible merges into conflicts without dupes")
check(gh.conflictSpecs[1].id == "rival" and gh.conflictSpecs[2].id == "other",
"conflicts list order keeps conflicts then new incompatible ids")
check(Manifest.parseGithub(nil) == nil and Manifest.parseGithub("") == nil,
"absent github is nil")
check(not pcall(Manifest.parseGithub, "not a repo"),
"a malformed github value fails")
check(not pcall(Manifest.validate, {
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
github = "ftp://example.com/x",
}), "a bad github field fails manifest validation")
local v1 = Manifest.validate({
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
}, "mods/v1")
@@ -156,6 +178,21 @@ check(not pcall(Manifest.validate, {
dependencies = { "other@nonsense" },
}, "mods/baddep"), "a malformed dependency range fails validation")
-- ------- experimental mods stay disabled until options.mods says otherwise
do
local loader = Loader.new({ fs = memfs({
["mods/lab/manifest.json"] = manifestJson("lab", {
experimental = "true", api = "2", profile = '"content"',
}),
["mods/lab/main.lua"] = "return function(mod) mod.content.items:register('X', {}) end",
}) })
local data = { items = {} }
check(loader:load(data) == true, "experimental-only install still boots")
local st = statusById(loader)
check(st.lab.state == "disabled", "experimental mod is disabled by default")
check(data.items.X == nil, "experimental entry chunk does not run while off")
end
-- ------- game_version against the engine
local versionLoader = Loader.new({ fs = memfs({
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
+49
View File
@@ -0,0 +1,49 @@
-- Parity: the main battle-menu cursor stops at an edge instead of wrapping
-- to the opposite command (#485).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity battle menu cursor")
local eq = S.eq
local pressed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "BULBASAUR", 5) }
local game = {
data = Data,
save = save,
input = { wasPressed = function(_, key) return pressed[key] == true end },
stack = { push = function() end, pop = function() end, top = function() end },
}
local battle = BattleState.newWild(game, "RATTATA", 2)
battle.phase = "menu"
local function pressAt(state, index, key)
state.menuIndex = index
pressed[key] = true
state:update(1 / 60)
pressed[key] = nil
return state.menuIndex
end
eq(pressAt(battle, 1, "left"), 1, "FIGHT stays selected when pressing left")
eq(pressAt(battle, 2, "right"), 2, "PKMN stays selected when pressing right")
eq(pressAt(battle, 1, "up"), 1, "FIGHT stays selected when pressing up")
eq(pressAt(battle, 4, "down"), 4, "RUN stays selected when pressing down")
eq(pressAt(battle, 1, "right"), 2, "FIGHT moves right to PKMN")
eq(pressAt(battle, 1, "down"), 3, "FIGHT moves down to ITEM")
local safari = BattleState.newWild(game, "RATTATA", 2)
safari:makeSafari({ balls = 30 })
safari.phase = "menu"
eq(pressAt(safari, 1, "left"), 1, "SAFARI BALL stays selected when pressing left")
eq(pressAt(safari, 2, "right"), 2, "BAIT stays selected when pressing right")
eq(pressAt(safari, 1, "up"), 1, "SAFARI BALL stays selected when pressing up")
eq(pressAt(safari, 4, "down"), 4, "RUN stays selected when pressing down")
S.finish()
+63
View File
@@ -0,0 +1,63 @@
-- Parity: leaving the Cerulean Badge House badge-description menu must
-- return control to the overworld after viewing a badge (#454).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity Cerulean Badge House")
local eq = S.eq
local script = require("data.scripts.flavor.cerulean_badge_house")
.CERULEAN_BADGE_HOUSE.talk.TEXT_CERULEANBADGEHOUSE_MIDDLE_AGED_MAN
local states = {}
local game = {
data = {
items = {
BOULDERBADGE = { name = "BOULDERBADGE" },
CASCADEBADGE = { name = "CASCADEBADGE" },
THUNDERBADGE = { name = "THUNDERBADGE" },
RAINBOWBADGE = { name = "RAINBOWBADGE" },
SOULBADGE = { name = "SOULBADGE" },
MARSHBADGE = { name = "MARSHBADGE" },
VOLCANOBADGE = { name = "VOLCANOBADGE" },
EARTHBADGE = { name = "EARTHBADGE" },
},
text = {
_CeruleanBadgeHouseMiddleAgedManText = "",
_CeruleanBadgeHouseMiddleAgedManWhichBadgeText = "",
_CeruleanBadgeHouseBoulderBadgeText = "",
_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText = "",
},
},
save = { player = { name = "RED" } },
input = { wasPressed = function(_, key) return key == "b" end },
stack = {
push = function(_, state) states[#states + 1] = state end,
pop = function() return table.remove(states) end,
top = function() return states[#states] end,
},
}
local done = false
script(game, nil, nil, function() done = true end)
local function dismissText()
local text = game.stack:pop()
text.onDone()
end
-- Greeting -> prompt -> first menu -> badge description -> prompt -> second menu.
dismissText()
dismissText()
local firstMenu = game.stack:top()
firstMenu.onChoose(firstMenu.items[1])
dismissText()
dismissText()
-- B follows ListMenu's real cancellation path: pop menu, then show goodbye.
game.stack:top():update(0)
dismissText()
eq(#states, 0, "backing out after viewing a badge leaves no menu behind")
eq(done, true, "backing out after viewing a badge completes the NPC script")
S.finish()
+58
View File
@@ -0,0 +1,58 @@
-- FightingDojoDefaultScript gates the Karate Master at the one tile to his
-- left. He is not a regular sight-line trainer (#495).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local S = require("tests.harness").suite("parity Fighting Dojo gate")
local check, eq = S.check, S.eq
local script = require("data.scripts.story4").FIGHTING_DOJO.onStep
local function scene()
local master = {
def = { name = "FIGHTINGDOJO_KARATE_MASTER" },
facing = "down",
facePlayer = function(self, player)
self.facing = player.cellX < 5 and "left" or "right"
end,
}
local ow = {
npcs = { master },
player = { cellX = 4, cellY = 3, facing = "down" },
trainerDefeated = function() return false end,
engageTrainer = function(self, npc) self.engaged = npc end,
}
return { save = { flags = {} } }, ow, master
end
local header = Data:trainerHeader("FightingDojo", 1)
eq(header and header.range, 0,
"Karate Master has no generic trainer sight range")
do
local game, ow = scene()
check(not script(game, ow, 5, 4),
"standing below the downward-facing Master does not trigger him")
check(ow.engaged == nil, "the below tile does not begin a battle")
end
do
local game, ow, master = scene()
check(script(game, ow, 4, 3),
"the tile immediately left of the Master triggers his script")
eq(ow.engaged, master, "the Master starts the battle")
eq(master.facing, "left", "the Master turns toward the player")
eq(ow.player.facing, "right", "the player turns toward the Master")
end
do
local game, ow = scene()
game.save.flags.EVENT_BEAT_KARATE_MASTER = true
check(not script(game, ow, 4, 3),
"the gate stays inactive after the Karate Master is beaten")
end
S.finish()
+39
View File
@@ -16,8 +16,10 @@ require("src.render.Font").load(Data)
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local DexEntryMenu = require("src.ui.DexEntryMenu")
local EvolutionState = require("src.ui.EvolutionState")
local TitleState = require("src.ui.TitleState")
local OakSpeech = require("src.ui.OakSpeech")
local TradeAnim = require("src.ui.TradeAnim")
local savedCry = Sound.playCry
Sound.playCry = function() end
@@ -33,7 +35,10 @@ end
local def = Data.pokemon.PIKACHU
local savedTrueColor = def.trueColor
local raichu = Data.pokemon.RAICHU
local savedRaichuTrueColor = raichu.trueColor
def.trueColor = true
raichu.trueColor = true
local game = {
data = Data,
@@ -50,6 +55,39 @@ check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
and dexRects[1].h == dex.sprite:getHeight(),
"Pokedex reports the true-color sprite rectangle")
local evolving = EvolutionState.new(game, { species = "PIKACHU" }, "RAICHU")
check(evolving.oldSpriteTrueColor == true,
"evolution keeps the current Pokemon sprite's trueColor flag")
check(evolving.newSpriteTrueColor == true,
"evolution keeps the evolved Pokemon sprite's trueColor flag")
local evoRects = uiRects(function() evolving:draw() end)
local ex = math.floor((160 - evolving.oldSprite:getWidth()) / 2)
local ey = math.max(8, 64 - evolving.oldSprite:getHeight())
check(#evoRects == 1 and evoRects[1].x == ex and evoRects[1].y == ey
and evoRects[1].w == evolving.oldSprite:getWidth()
and evoRects[1].h == evolving.oldSprite:getHeight(),
"evolution reports the true-color sprite rectangle")
local trade = TradeAnim.new(game, {
sent = { species = "PIKACHU" }, received = { species = "RAICHU" },
})
check(trade.sentSpriteTrueColor == true,
"trade keeps the sent Pokemon sprite's trueColor flag")
check(trade.recvSpriteTrueColor == true,
"trade keeps the received Pokemon sprite's trueColor flag")
local tradeRects = uiRects(function() trade:draw() end)
check(#tradeRects == 1 and tradeRects[1].x == 56 and tradeRects[1].y == 16
and tradeRects[1].w == trade.sentSprite:getWidth()
and tradeRects[1].h == trade.sentSprite:getHeight(),
"trade reports the sent true-color sprite rectangle")
trade.phase = "show_enemy"
local receivedRects = uiRects(function() trade:draw() end)
check(#receivedRects == 1 and receivedRects[1].x == 56
and receivedRects[1].y == 16
and receivedRects[1].w == trade.recvSprite:getWidth()
and receivedRects[1].h == trade.recvSprite:getHeight(),
"trade reports the received true-color sprite rectangle")
local titleGame = {
data = { pokemon = Data.pokemon,
field = { title = { cycleSpecies = { "PIKACHU" } } } },
@@ -83,6 +121,7 @@ check(#oakRects == 1 and oakRects[1].x == ox and oakRects[1].y == oy
"Oak intro reports the true-color sprite rectangle")
def.trueColor = savedTrueColor
raichu.trueColor = savedRaichuTrueColor
Sound.playCry = savedCry
PaletteFX.clearTrueColor()
S.finish()
+74
View File
@@ -1674,6 +1674,16 @@ do
local optBack = SD.loadOptions()
eq(optBack.zoom, -2, "options.lua round-trips zoom")
eq(optBack.voidFill, "water", "options.lua round-trips voidFill")
-- touchControls (#327): enabled flag + normalized positions
rep.options.touchControls = {
enabled = false,
positions = { dpad = { x = 0.2, y = 0.8 }, a = { x = 0.9, y = 0.7 } },
}
SD.saveOptions(rep.options)
optBack = SD.loadOptions()
eq(optBack.touchControls.enabled, false, "options.lua round-trips touchControls.enabled")
eq(optBack.touchControls.positions.dpad.x, 0.2,
"options.lua round-trips touchControls.positions")
local origOpts, loadedOpts = rep.options, back.options
rep.options, back.options = nil, nil
local same, where = deepEq(rep, back, "save")
@@ -1683,6 +1693,68 @@ do
SD.saveOptions(SD.defaultOptions())
end
-- ---------------------------------------------------------------- touch controls layout (#327)
do
local TC = require("src.core.TouchControls")
local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil")
cfg = TC.normalizeConfig({
enabled = false,
positions = {
dpad = { x = 1.5, y = -0.2 }, -- clamped
a = { x = 0.5, y = 0.5 },
junk = { x = 0, y = 0 },
b = { x = "nope", y = 0.1 },
},
})
eq(cfg.enabled, false, "normalizeConfig keeps enabled=false")
eq(cfg.positions.dpad.x, 1, "normalizeConfig clamps x high")
eq(cfg.positions.dpad.y, 0, "normalizeConfig clamps y low")
eq(cfg.positions.a.x, 0.5, "normalizeConfig keeps a")
check(cfg.positions.junk == nil, "normalizeConfig drops unknown controls")
check(cfg.positions.b == nil, "normalizeConfig drops non-numeric")
local L = TC.defaultLayout(400, 800)
check(L.dpad.cx < 200, "default d-pad on left half")
check(L.a.cx > 200, "default A on right half")
check(L.dpad.cy > 400, "default d-pad in bottom half")
-- applyOptions + visible gate (no real images needed for the gate)
TC.enabled = true
TC.active = true
TC.img = {} -- pretend art loaded
TC.controllerHidden = false
TC.preview = false
TC:applyOptions({ touchControls = { enabled = false } })
eq(TC.enabled, false, "applyOptions disables overlay")
check(not TC:visible(), "disabled overlay is not visible")
TC:applyOptions({
touchControls = {
enabled = true,
positions = { dpad = { x = 0.25, y = 0.75 } },
},
})
eq(TC.enabled, true, "applyOptions re-enables overlay")
eq(TC.positions.dpad.x, 0.25, "applyOptions stores positions")
check(TC:visible(), "enabled overlay is visible when active+imaged")
-- custom position applied through layout()
local g = love.graphics
local oldDim, oldFont = g.getDimensions, g.newFont
g.getDimensions = function() return 400, 800 end
g.newFont = function() return { getWidth = function() return 10 end,
getHeight = function() return 10 end } end
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil
local lay = TC:layout()
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
TC:clearPositions()
check(TC.positions == nil, "clearPositions wipes overrides")
g.getDimensions, g.newFont = oldDim, oldFont
end
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
-- The threshold byte b from engine/battle/core.asm's shift chain:
-- srl (speed/2), then sla (cap 255) without Focus Energy or srl with the
@@ -3274,6 +3346,8 @@ runSuites(orderedGlob("tests/parity_*.lua", {
"tests/parity_C.lua", "tests/parity_K.lua", "tests/parity_L.lua",
"tests/parity_H.lua", "tests/parity_G.lua", "tests/parity_I_M.lua",
"tests/parity_B.lua", "tests/parity_J.lua", "tests/parity_A.lua",
"tests/parity_battle_menu_cursor.lua",
"tests/parity_cerulean_badge_house.lua",
"tests/parity_flavor.lua", "tests/parity_trainer_sight.lua",
"tests/parity_static.lua", "tests/parity_trashcans.lua",
"tests/parity_hof.lua", "tests/parity_trade_gift.lua",
+100 -1
View File
@@ -21,6 +21,16 @@ Output: data/palettes_gbc.lua (committed; not wiped by ROM import)
same shape, plus per-species palette entries (BULBASAUR, ), plus a
`world` table (below) for true overworld tile/roof/sprite coloring.
Sources (pokeyellow / COLORS OG YELLOW):
data/sgb/sgb_palettes.asm SuperPalettes + CGBBasePalettes (Yellow is
CGB-enhanced; CGBBase is the authentic GBC look)
data/pokemon/palettes.asm MonsterPalettes (same shape as Red)
Output: data/palettes_yellow.lua (committed; not wiped by ROM import)
palettes[NAME] = SuperPalettes RGB
cgbBase[NAME] = CGBBasePalettes RGB
pokemon[SPECIES] = NAME
The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR /
PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor
(>= 27 pixels green, >= 10 yellow, else red).
@@ -536,6 +546,89 @@ def extract_gbc(pokered_gbc, out_path):
return palettes, mon_pals
def _parse_pokeyellow_palette_tables(path):
"""Parse SuperPalettes and CGBBasePalettes from pokeyellow sgb_palettes.asm."""
tables = {"SuperPalettes": {}, "CGBBasePalettes": {}}
orders = {"SuperPalettes": [], "CGBBasePalettes": []}
current = None
for lineno, line in _read_raw(path):
s = line.strip()
if s.startswith("SuperPalettes:"):
current = "SuperPalettes"
continue
if s.startswith("CGBBasePalettes:"):
current = "CGBBasePalettes"
continue
if s.startswith("assert_table_length") or s.endswith(":") and " " not in s:
if s.endswith(":") and not s.startswith(("Super", "CGB")):
current = None
continue
if current is None:
continue
m = re.match(r"RGB\s+([\d,\s]+);\s*PAL_(\w+)", s)
if not m:
continue
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
if len(nums) != 12:
util.die(f"{path}:{lineno}: expected 12 components, got {len(nums)}")
name = m.group(2)
tables[current][name] = _rgb8(nums)
orders[current].append(name)
return tables, orders
def extract_yellow(pokeyellow, out_path):
"""Extract Yellow SuperPalettes + CGBBasePalettes for COLORS=OG YELLOW."""
pal_path = os.path.join(pokeyellow, "data/sgb/sgb_palettes.asm")
mon_path = os.path.join(pokeyellow, "data/pokemon/palettes.asm")
tables, orders = _parse_pokeyellow_palette_tables(pal_path)
palettes = tables["SuperPalettes"]
cgb_base = tables["CGBBasePalettes"]
order = orders["SuperPalettes"]
if not palettes:
util.die(f"{pal_path}: SuperPalettes empty")
if not cgb_base:
util.die(f"{pal_path}: CGBBasePalettes empty")
if orders["CGBBasePalettes"] != order:
util.die(f"{pal_path}: CGBBasePalettes order differs from SuperPalettes")
mon_pals = {}
in_table = False
for lineno, line in _read_raw(mon_path):
s = line.strip()
if s.startswith("MonsterPalettes:"):
in_table = True
continue
if not in_table:
continue
m = re.match(r"db\s+PAL_(\w+)\s*;\s*(\w+)", s)
if not m:
continue
pal, species = m.group(1), m.group(2)
if pal not in palettes:
util.die(f"{mon_path}:{lineno}: unknown palette PAL_{pal}")
if species != "MISSINGNO":
mon_pals[species] = pal
if len(mon_pals) != 151:
util.die(f"MonsterPalettes parsed {len(mon_pals)} species (want 151)")
for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR", "ROUTE"):
if name not in cgb_base:
util.die(f"{pal_path}: CGBBase PAL_{name} missing")
util.write_lua(
out_path,
{"source": "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
"palettes": palettes,
"cgbBase": cgb_base,
"order": order,
"pokemon": mon_pals},
header="Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +\n"
"CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors\n"
"per palette (color 0 first), plus MonsterPalettes.")
return palettes, cgb_base, mon_pals
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = parser.add_subparsers(dest="cmd", required=True)
@@ -548,11 +641,17 @@ def main(argv=None):
p_gbc.add_argument("--pokered-gbc", required=True)
p_gbc.add_argument("--out", default="data/palettes_gbc.lua")
p_yel = sub.add_parser("yellow", help="extract pokeyellow CGBBasePalettes")
p_yel.add_argument("--pokeyellow", required=True)
p_yel.add_argument("--out", default="data/palettes_yellow.lua")
args = parser.parse_args(argv)
if args.cmd == "vanilla":
extract(args.pokered, args.out_dir)
else:
elif args.cmd == "gbc":
extract_gbc(args.pokered_gbc, args.out)
else:
extract_yellow(args.pokeyellow, args.out)
return 0
+200
View File
@@ -0,0 +1,200 @@
name: Release
# Packs the mod into an installable .zip and publishes it as a GitHub Release,
# once per push to main.
#
# Archive layout: every mod file at the archive root, manifest.json included.
# That is one of the two shapes the game accepts on MODS > Import mod .zip
# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a
# single top-level folder). Nothing else is added, so the archive stays
# installable by hand too.
#
# Versioning, first rule that applies wins:
# 1. the "version" input of a manual run,
# 2. "[release X.Y.Z]" anywhere in the commit message,
# 3. manifest.json's own version, when it is ahead of every existing tag,
# so bumping the manifest is the normal way to cut a release,
# 4. otherwise the newest vX.Y.Z tag with its patch incremented
# (0.2.99 rolls over to 0.3.0).
# Whichever wins is written into the manifest.json inside the archive, so a
# shipped mod never reports a different version than the release it came from.
#
# Generated by: python3 tools/modkit.py add-release-workflow <mod-id>
# MOD_ID below is stamped to this mod's id when the file is copied.
on:
push:
branches: [main]
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
inputs:
version:
description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve."
required: false
default: ""
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: ver
env:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, re, subprocess, sys
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def sh(*args):
return subprocess.run(args, capture_output=True, text=True).stdout.strip()
def parse(text):
m = SEMVER.match(text)
return tuple(int(p) for p in m.groups()) if m else None
def die(msg):
print(f"::error::{msg}", file=sys.stderr)
raise SystemExit(1)
with open("manifest.json", encoding="utf-8") as fh:
manifest_version = str(json.load(fh).get("version", ""))
released = sorted(
v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v
)
latest = released[-1] if released else None
override = os.environ.get("DISPATCH_VERSION", "").strip()
if not override:
found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B"))
override = found.group(1) if found else ""
manifest_ver = parse(manifest_version)
if override:
version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)")
source = "the override"
elif manifest_ver and (latest is None or manifest_ver > latest):
version = manifest_ver
source = "manifest.json"
elif latest:
major, minor, patch = latest
patch += 1
if patch > 99:
minor, patch = minor + 1, 0
version = (major, minor, patch)
source = "a patch bump on v%d.%d.%d" % latest
else:
die(f"manifest.json version {manifest_version!r} is not X.Y.Z "
"and there is no vX.Y.Z tag to count from")
text = "%d.%d.%d" % version
print(f"Releasing {text}, from {source}.", file=sys.stderr)
print(f"version={text}")
print(f"tag=v{text}")
PY
- name: Refuse to clobber an existing release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "::error::Tag $TAG already exists. Pick a different version."
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::Release $TAG already exists. Pick a different version."
exit 1
fi
- name: Build the mod .zip
env:
VERSION: ${{ steps.ver.outputs.version }}
MOD_ID: "{{MOD_ID}}"
run: |
set -euo pipefail
staging="$RUNNER_TEMP/pkg"
out="$GITHUB_WORKSPACE/dist"
rm -rf "$staging" "$out"
mkdir -p "$staging" "$out"
git archive HEAD | tar -x -C "$staging"
rm -rf "$staging/.github" "$staging/.gitattributes" \
"$staging/.gitignore" "$staging/.luarc.json"
python3 - "$staging/manifest.json" "$VERSION" <<'PY'
import json, sys
path, version = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
manifest = json.load(fh)
manifest["version"] = version
with open(path, "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2, ensure_ascii=False)
fh.write("\n")
PY
zip_path="$out/${MOD_ID}-${VERSION}.zip"
(cd "$staging" && zip -qr "$zip_path" .)
unzip -l "$zip_path"
unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json"
python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY'
import json, sys
path, expected = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
version = json.load(fh)["version"]
if version != expected:
raise SystemExit(f"::error::packed manifest says {version}, expected {expected}")
print(f"manifest.json is at the archive root and reports {version}")
PY
(cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt)
cat "$out/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
TAG: ${{ steps.ver.outputs.tag }}
MOD_ID: "{{MOD_ID}}"
run: |
set -euo pipefail
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
range="${prev:+${prev}..}$GITHUB_SHA"
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.'
if [ -n "$changes" ]; then
notes+=$'\n\n## Changes\n\n'"$changes"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$VERSION" \
--notes "$notes" \
"dist/${MOD_ID}-${VERSION}.zip" \
"dist/sha256sums.txt"
echo "Published release $TAG"
+142 -1
View File
@@ -5,7 +5,7 @@
Subcommands:
scaffold <id> [--profile content|overhaul|total_conversion] [--api 2]
[--dest DIR] [--force]
[--github owner/repo] [--experimental] [--dest DIR] [--force]
translation <id> [--language NAME] [--base auto|fixture|imported]
[--refresh] [--dest DIR]
validate <id|path> [--strict] [--base auto|fixture|imported]
@@ -13,6 +13,8 @@ Subcommands:
pack <mod-dir> [-o out.modpkg]
bounce <song-id|--all> [--seconds N] [--out DIR]
docs [--out DIR]
set-github <id|path> <url> add/update manifest "github" (auto-update)
add-release-workflow <id|path> copy GitHub Actions release.yml into the mod
Global flags: --repo PATH, --json, --quiet.
Exit codes: 0 success, 1 validation/lint failure, 2 usage error.
@@ -330,10 +332,52 @@ MANIFEST_TEMPLATE = """{
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"incompatible": [],
"experimental": {{experimental}},{{github_line}}
"description": "TODO: one line about {{id}}"{{extra}}
}
"""
# owner/repo or https://github.com/owner/repo(.git)
GITHUB_RE = re.compile(
r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?/?$"
)
def normalize_github(value):
"""Return 'owner/repo' or None for empty; raise ValueError if malformed."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
match = GITHUB_RE.fullmatch(text)
if not match:
raise ValueError(
"github must be owner/repo or a github.com URL "
f"(got {value!r})")
owner, repo = match.group(1), match.group(2)
if repo.endswith(".git"):
repo = repo[:-4]
return f"{owner}/{repo}"
def check_github_field(manifest):
"""Optional github field: absent is fine (note), present must parse."""
findings, notes = [], []
raw = manifest.get("github")
if raw is None or raw == "":
notes.append(
'optional tip: set "github": "owner/repo" in manifest.json '
"to enable launcher auto-update and Other versions")
return findings, notes
try:
normalize_github(raw)
except ValueError as err:
findings.append(Finding(
"MK001", "error", str(err), "manifest.json"))
return findings, notes
MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2).
-- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat.
return function(mod)
@@ -428,13 +472,25 @@ def cmd_scaffold(args, repo):
next_major = int(engine.split(".")[0]) + 1
name = args.id.replace("_", " ").replace("-", " ").title()
github = ""
if getattr(args, "github", None):
try:
github = normalize_github(args.github) or ""
except ValueError as err:
print(f"modkit: {err}")
return 2
extra = ""
if profile == "total_conversion":
extra = ',\n "assets_transforms": "transforms.lua"'
github_line = f'\n "github": "{github}",' if github else ""
subst = {
"{{id}}": args.id, "{{name}}": name, "{{profile}}": profile,
"{{game_version}}": engine, "{{next_major}}": str(next_major),
"{{extra}}": extra,
"{{github_line}}": github_line,
"{{experimental}}": "true" if getattr(args, "experimental", False)
else "false",
}
def emit(rel, template):
@@ -753,6 +809,9 @@ def cmd_validate(args, repo):
if problem:
findings.append(problem)
else:
gh_findings, gh_notes = check_github_field(manifest)
findings.extend(gh_findings)
notes.extend(gh_notes)
findings.extend(check_permissions(repo, manifest))
run_loader(repo, mod_dir, findings, args.base, notes)
findings.extend(check_requires(repo, mod_dir, manifest))
@@ -762,6 +821,70 @@ def cmd_validate(args, repo):
notes)
def write_manifest(mod_dir, manifest):
path = os.path.join(mod_dir, "manifest.json")
with open(path, "w", encoding="utf-8") as handle:
json.dump(manifest, handle, indent=2, ensure_ascii=False)
handle.write("\n")
def cmd_set_github(args, repo):
"""Add or update the optional github field on an existing manifest."""
mod_dir = resolve_mod_dir(repo, args.mod)
if not mod_dir:
print(f"modkit: no mod at {args.mod!r}")
return 2
manifest, problem = read_manifest(mod_dir)
if problem:
print(problem.line())
return 1
try:
repo_slug = normalize_github(args.url)
except ValueError as err:
print(f"modkit: {err}")
return 2
if not repo_slug:
print("modkit: github url is empty")
return 2
manifest["github"] = repo_slug
write_manifest(mod_dir, manifest)
if not args.quiet:
print(f"set github to {repo_slug!r} in {mod_dir}/manifest.json")
print("launcher auto-update / Other versions will use this repo")
return 0
def cmd_add_release_workflow(args, repo):
"""Copy the standard GitHub Actions release workflow into a mod folder."""
mod_dir = resolve_mod_dir(repo, args.mod)
if not mod_dir:
print(f"modkit: no mod at {args.mod!r}")
return 2
manifest, problem = read_manifest(mod_dir)
if problem:
print(problem.line())
return 1
mod_id = manifest.get("id") or os.path.basename(mod_dir)
template = os.path.join(repo, "tools", "mod_release_workflow.yml")
if not os.path.isfile(template):
print(f"modkit: missing template {template}")
return 2
dest_dir = os.path.join(mod_dir, ".github", "workflows")
dest = os.path.join(dest_dir, "release.yml")
if os.path.exists(dest) and not args.force:
print(f"modkit: {dest} exists (use --force to overwrite)")
return 2
body = open(template, encoding="utf-8").read().replace("{{MOD_ID}}", mod_id)
os.makedirs(dest_dir, exist_ok=True)
with open(dest, "w", encoding="utf-8") as handle:
handle.write(body)
if not args.quiet:
print(f"wrote {dest}")
print("push this mod as its own GitHub repo (with manifest github set) "
"to publish installable .zip releases on every main push")
return 0
# ---------------------------------------------------------------- lint
def ahash(image):
@@ -1636,6 +1759,8 @@ def cmd_translation(args, repo):
"{{next_major}}": str(int(engine_version_.split(".")[0]) + 1),
"{{profile}}": "content",
"{{extra}}": "",
"{{github_line}}": "",
"{{experimental}}": "false",
"{{total}}": str(sum(counts.values())),
"{{table}}": "\n".join(
f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs),
@@ -1840,6 +1965,10 @@ def main(argv):
p.add_argument("--profile", default="content",
choices=["content", "overhaul", "total_conversion"])
p.add_argument("--api", type=int, default=2)
p.add_argument("--github", default="",
help="optional owner/repo (enables launcher auto-update)")
p.add_argument("--experimental", action="store_true",
help="mark the mod experimental (off until confirmed)")
p.add_argument("--dest")
p.add_argument("--force", action="store_true")
@@ -1877,6 +2006,16 @@ def main(argv):
p = sub.add_parser("docs", parents=[shared])
p.add_argument("--out")
p = sub.add_parser("set-github", parents=[shared],
help="add github field to an existing mod manifest")
p.add_argument("mod")
p.add_argument("url", help="owner/repo or https://github.com/owner/repo")
p = sub.add_parser("add-release-workflow", parents=[shared],
help="copy GitHub Actions release.yml into the mod")
p.add_argument("mod")
p.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
for dest, fallback in (("repo", None), ("json", False),
("quiet", False)):
@@ -1905,6 +2044,8 @@ def main(argv):
"bounce": cmd_bounce,
"translation": cmd_translation,
"docs": cmd_docs,
"set-github": cmd_set_github,
"add-release-workflow": cmd_add_release_workflow,
}[args.command]
return handler(args, repo)