From 53e1a535ddea86c8d3fe44c59be0a36f06abd2ec Mon Sep 17 00:00:00 2001 From: johnjohto Date: Wed, 29 Jul 2026 23:51:05 -0400 Subject: [PATCH 1/5] Support custom trainer palettes and base portraits --- src/battle/BattleState.lua | 24 +++++++++++++++++++++++- src/mods/Schemas.lua | 9 +++++++++ src/render/PaletteFX.lua | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ad0e5e2d..b6eb4821 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -187,6 +187,27 @@ local function namedPalette(data, name) return { name = key, colors = colors } end +-- Custom trainer portraits can opt into the same Advanced OBJ palette source +-- as their overworld walker. Vanilla trainers preserve the hardware-faithful +-- MEWMON fallback used during the battle introduction. +function BattleState.trainerPalette(data, trainer) + local source = trainer and trainer.paletteSource + if source then + local PaletteFX = require("src.render.PaletteFX") + local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id) + if colors then + return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors } + end + end + return namedPalette(data, "MEWMON") +end + +function BattleState.trainerPicPath(data, trainer) + if trainer and trainer.pic then return trainer.pic end + local base = trainer and trainer.basePic and data.trainers[trainer.basePic] + return base and base.pic or nil +end + -- The battle-BGP fade variant of a pic (AnimationFlashScreen and the -- SetAnimationBGPalette effects remap the four BG shades; on the SGB -- the colorizer then colors the REMAPPED shade, so a faded pic shows @@ -607,7 +628,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex) -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) - self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON")) + self.trainerPic = getImage(BattleState.trainerPicPath(game.data, self.trainer), + BattleState.trainerPalette(game.data, self.trainer)) self.introText = Strings("%s wants\nto fight!", self.trainer.name) return self end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index c73aa09b..7e0f7490 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -555,6 +555,11 @@ R.trainers = { index = f.opt(f.int(0, 255)), -- unused vanilla classes ship without a pic, so it cannot be required pic = f.opt(f.path), + -- Optional Advanced-mode OBJ palette source for a custom trainer portrait. + -- It follows the same ROM crosswalk form as sprites.paletteSource. + paletteSource = f.opt(f.str), + -- Reuse a base trainer class's portrait without redistributing its asset. + basePic = f.opt(f.id("trainers")), baseMoney = f.opt(f.int(0)), parties = f.list(f.list(f.rec{ level = f.int(1), species = f.id("pokemon") })), @@ -574,6 +579,10 @@ R.sprites = { frames = f.int(1), walker = f.opt(f.bool), trueColor = f.opt(f.bool), + -- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ + -- palette assignment without claiming that the image itself came from + -- the ROM (which is what `source` documents on imported records). + paletteSource = f.opt(f.str), }, example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })', } diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 4891f446..da89c91a 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -518,7 +518,7 @@ end function PaletteFX.spriteObp(spriteDef, seed) local pack = PaletteFX.gbcPack() local w = pack and pack.world - local src = spriteDef and spriteDef.source + local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) if not (w and src) then return nil end local idx = tonumber(src:match("%[(%d+)%]")) -- RedBikeSprite loads outside SpriteSheetPointerTable From 7aa06942bef48d2c6531bb3f0ec7904973eea131 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Wed, 29 Jul 2026 23:51:05 -0400 Subject: [PATCH 2/5] Support custom trainer palettes and base portraits --- src/battle/BattleState.lua | 24 +++++++++++++++++++++++- src/mods/Schemas.lua | 9 +++++++++ src/render/PaletteFX.lua | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 131ee8cf..9f3e3c85 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -188,6 +188,27 @@ local function namedPalette(data, name) return { name = key, colors = colors } end +-- Custom trainer portraits can opt into the same Advanced OBJ palette source +-- as their overworld walker. Vanilla trainers preserve the hardware-faithful +-- MEWMON fallback used during the battle introduction. +function BattleState.trainerPalette(data, trainer) + local source = trainer and trainer.paletteSource + if source then + local PaletteFX = require("src.render.PaletteFX") + local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id) + if colors then + return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors } + end + end + return namedPalette(data, "MEWMON") +end + +function BattleState.trainerPicPath(data, trainer) + if trainer and trainer.pic then return trainer.pic end + local base = trainer and trainer.basePic and data.trainers[trainer.basePic] + return base and base.pic or nil +end + -- The battle-BGP fade variant of a pic (AnimationFlashScreen and the -- SetAnimationBGPalette effects remap the four BG shades; on the SGB -- the colorizer then colors the REMAPPED shade, so a faded pic shows @@ -608,7 +629,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex) -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) - self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON")) + self.trainerPic = getImage(BattleState.trainerPicPath(game.data, self.trainer), + BattleState.trainerPalette(game.data, self.trainer)) self.introText = Strings("%s wants\nto fight!", self.trainer.name) return self end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index c3a9146d..391cacc4 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -561,6 +561,11 @@ R.trainers = { index = f.opt(f.int(0, 255)), -- unused vanilla classes ship without a pic, so it cannot be required pic = f.opt(f.path), + -- Optional Advanced-mode OBJ palette source for a custom trainer portrait. + -- It follows the same ROM crosswalk form as sprites.paletteSource. + paletteSource = f.opt(f.str), + -- Reuse a base trainer class's portrait without redistributing its asset. + basePic = f.opt(f.id("trainers")), baseMoney = f.opt(f.int(0)), parties = f.list(f.list(f.rec{ level = f.int(1), species = f.id("pokemon") })), @@ -580,6 +585,10 @@ R.sprites = { frames = f.int(1), walker = f.opt(f.bool), trueColor = f.opt(f.bool), + -- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ + -- palette assignment without claiming that the image itself came from + -- the ROM (which is what `source` documents on imported records). + paletteSource = f.opt(f.str), }, example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })', } diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 4af5ca02..2dcc6251 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -537,7 +537,7 @@ end function PaletteFX.spriteObp(spriteDef, seed) local pack = PaletteFX.gbcPack() local w = pack and pack.world - local src = spriteDef and spriteDef.source + local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) if not (w and src) then return nil end local idx = tonumber(src:match("%[(%d+)%]")) -- RedBikeSprite loads outside SpriteSheetPointerTable From 17eee1ca0eedc9fb44fc0c0c78e11171f2a7fc35 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Thu, 30 Jul 2026 00:00:36 -0400 Subject: [PATCH 3/5] Test custom trainer portrait support --- tests/mod_battle_tests.lua | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 1b5b6242..93c35319 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -11,6 +11,7 @@ local Font = require("src.render.Font") Font.load(Data) local BattleState = require("src.battle.BattleState") +local PaletteFX = require("src.render.PaletteFX") local Catching = require("src.battle.Catching") local Damage = require("src.battle.Damage") local Events = require("src.mods.Events") @@ -327,6 +328,25 @@ end -- ------- ai_classes: brains and layer records +-- ------- custom trainer portraits: palette sources and base portraits + +do + local oldMode = PaletteFX.mode + PaletteFX.mode = "redpp" + local custom = { id = "TEST_TRAINER", + paletteSource = "ROM:SpriteSheetPointerTable[21]" } + local pal = BattleState.trainerPalette(Data, custom) + local expected = PaletteFX.spriteObp({ paletteSource = custom.paletteSource }, custom.id) + PaletteFX.mode = oldMode + check(pal and expected and pal.colors[3][1] == expected[3][1] + and pal.colors[3][2] == expected[3][2] + and pal.colors[3][3] == expected[3][3], + "a custom trainer portrait resolves its Advanced OBJ palette source") + check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" }) + == Data.trainers.OPP_ENGINEER.pic, + "a custom trainer can reuse a base trainer portrait by id") +end + do Data.ai_classes = { OPP_YOUNGSTER = { brain = function(battle) return { id = "SPLASH", pp = 1, brained = true } From 7b208043b913de0892a95bbfb471655f23ecc0dc Mon Sep 17 00:00:00 2001 From: johnjohto Date: Thu, 30 Jul 2026 10:17:30 -0400 Subject: [PATCH 4/5] Fix Yellow NPC trades --- src/core/Data.lua | 23 +++++++++++++ tests/parity_yellow_trades.lua | 59 ++++++++++++++++++++++++++++++++++ tests/run_tests.lua | 1 + tools/make_yellow_manifest.py | 1 + tools/rom_manifest_yellow.json | 54 +++++++++++++++---------------- 5 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 tests/parity_yellow_trades.lua diff --git a/src/core/Data.lua b/src/core/Data.lua index 8ca5a734..a68c050d 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = { }, } +-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table. +-- Old Yellow imports were built from the Red manifest, so correct that +-- table after loading as well as in the fixed import manifest below. +local YELLOW_TRADES = { + { give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" }, + { give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" }, + { give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" }, + { give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" }, + { give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" }, + { give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" }, + { give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" }, + { give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" }, + { give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" }, + { give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" }, +} + -- field.boot is the total-conversion override point for the new game; the -- values match what SaveData.newGame and the Oak speech used to inline. local BOOT_DEFAULTS = { @@ -58,6 +74,12 @@ local function copy(value) return out end +function Data:applyVersionedFieldData() + if require("src.core.GameVersion").isYellow() then + self.field.trades = copy(YELLOW_TRADES) + end +end + -- Fills only what the cache is missing, so an importer that learns to -- stamp one of these keys silently takes over from the engine. function Data:seedDefaults() @@ -77,6 +99,7 @@ function Data:seedDefaults() if constants.dexDigits == nil then constants.dexDigits = math.max(3, #tostring(constants.dexSize)) end + self:applyVersionedFieldData() local boot = self.field.boot if boot == nil then boot = {} diff --git a/tests/parity_yellow_trades.lua b/tests/parity_yellow_trades.lua new file mode 100644 index 00000000..95e34bda --- /dev/null +++ b/tests/parity_yellow_trades.lua @@ -0,0 +1,59 @@ +-- Pokemon Yellow has a different TradeMons table from Red and Blue. +-- The import manifest must agree, and Data:seedDefaults repairs Yellow +-- caches made before that manifest carried the Yellow table (#453). + +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.field and Data.field.trades) then Data:load() end +local GameVersion = require("src.core.GameVersion") +local S = require("tests.harness").suite("parity Yellow trades") +local check, eq = S.check, S.eq + +local oldVersion = GameVersion.get() +local oldTrades = {} +for i, trade in ipairs(Data.field.trades) do + oldTrades[i] = {} + for key, value in pairs(trade) do oldTrades[i][key] = value end +end + +GameVersion.set("yellow") +Data:applyVersionedFieldData() + +local expected = { + { "LICKITUNG", "DUGTRIO", "GURIO" }, + { "CLEFAIRY", "MR_MIME", "MILES" }, + { "BUTTERFREE", "BEEDRILL", "STINGER" }, + { "KANGASKHAN", "MUK", "STICKY" }, + { "MEW", "MEW", "BART" }, + { "TANGELA", "PARASECT", "SPIKE" }, + { "PIDGEOT", "PIDGEOT", "MARTY" }, + { "GOLDUCK", "RHYDON", "BUFFY" }, + { "GROWLITHE", "DEWGONG", "CEZANNE" }, + { "CUBONE", "MACHOKE", "RICKY" }, +} + +local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r")) +local manifest = manifestFile:read("*a") +manifestFile:close() + +eq(#Data.field.trades, #expected, "Yellow has ten in-game trade rows") +for i, want in ipairs(expected) do + local got = Data.field.trades[i] + check(got.give == want[1] and got.get == want[2] and got.nickname == want[3], + ("Yellow trade %d is %s for %s (%s)"):format(i, want[1], want[2], want[3])) + local row = ('"get": "%s",%%s+"give": "%s",%%s+"nickname": "%s"') + :format(want[2], want[1], want[3]) + check(manifest:find(row) ~= nil, + ("Yellow manifest carries trade %d"):format(i)) +end + +local ricky = Data.field.trades[10] +check(ricky.give == "CUBONE" and ricky.get == "MACHOKE", + "the Underground Path trade is Cubone for Machoke") + +Data.field.trades = oldTrades +GameVersion.set(oldVersion) + +return S:finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 274789d1..0bf52a1c 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3277,6 +3277,7 @@ runSuites(orderedGlob("tests/parity_*.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", + "tests/parity_yellow_trades.lua", "tests/parity_intro.lua", "tests/parity_tilt.lua", "tests/parity_gbcfx.lua", })) diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index f1527e2b..aca456e9 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -382,6 +382,7 @@ def derive(red, pokeyellow, symbols_path): except SystemExit as exc: print(f"warning: parse_credits failed ({exc}); keeping Red credits") # TODO: hand-author a Yellow credits banner if pret layout drifts. + yellow["field"]["trades"] = field.parse_trades(pokeyellow) finally: util.ASM_DEFINES = saved diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9d565854..00c35a7a 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -10552,63 +10552,63 @@ "trades": [ { "dialogset": 1, - "get": "NIDORINA", - "give": "NIDORINO", - "nickname": "TERRY" + "get": "DUGTRIO", + "give": "LICKITUNG", + "nickname": "GURIO" }, { "dialogset": 1, "get": "MR_MIME", - "give": "ABRA", - "nickname": "MARCEL" + "give": "CLEFAIRY", + "nickname": "MILES" }, { "dialogset": 3, "get": "BEEDRILL", "give": "BUTTERFREE", - "nickname": "CHIKUCHIKU" + "nickname": "STINGER" }, { "dialogset": 1, - "get": "SEEL", - "give": "PONYTA", - "nickname": "SAILOR" + "get": "MUK", + "give": "KANGASKHAN", + "nickname": "STICKY" }, { "dialogset": 3, - "get": "FARFETCHD", - "give": "SPEAROW", - "nickname": "DUX" + "get": "MEW", + "give": "MEW", + "nickname": "BART" }, { "dialogset": 1, - "get": "LICKITUNG", - "give": "SLOWBRO", - "nickname": "MARC" + "get": "PARASECT", + "give": "TANGELA", + "nickname": "SPIKE" }, { "dialogset": 2, - "get": "JYNX", - "give": "POLIWHIRL", - "nickname": "LOLA" + "get": "PIDGEOT", + "give": "PIDGEOT", + "nickname": "MARTY" }, { "dialogset": 2, - "get": "ELECTRODE", - "give": "RAICHU", - "nickname": "DORIS" + "get": "RHYDON", + "give": "GOLDUCK", + "nickname": "BUFFY" }, { "dialogset": 3, - "get": "TANGELA", - "give": "VENONAT", - "nickname": "CRINKLES" + "get": "DEWGONG", + "give": "GROWLITHE", + "nickname": "CEZANNE" }, { "dialogset": 3, - "get": "NIDORAN_F", - "give": "NIDORAN_M", - "nickname": "SPOT" + "get": "MACHOKE", + "give": "CUBONE", + "nickname": "RICKY" } ], "warpCarpets": { From 8a649e8d95d86e8eeb810486091bb8d7d5c3795e Mon Sep 17 00:00:00 2001 From: johnjohto Date: Thu, 30 Jul 2026 10:38:24 -0400 Subject: [PATCH 5/5] Handle generic joystick input Unmapped handheld controllers emit joystick events, leaving the launcher cursor and in-game input inactive. --- main.lua | 24 ++++++++++++++++++++ src/core/Game.lua | 19 ++++++++++++++++ src/core/Input.lua | 45 ++++++++++++++++++++++++++++++++++++++ src/import/RomImporter.lua | 31 ++++++++++++++++++++++++++ src/update/Boot.lua | 4 +++- tests/input_hold_test.lua | 45 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 1 deletion(-) diff --git a/main.lua b/main.lua index f88a7381..d940b816 100644 --- a/main.lua +++ b/main.lua @@ -331,6 +331,30 @@ function love.gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value) end +function love.joystickpressed(joystick, button) + if editorMode 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 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 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 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 Importer then return end diff --git a/src/core/Game.lua b/src/core/Game.lua index 05c2427e..85324748 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -491,6 +491,25 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +function Game:joystickpressed(joystick, button) + TouchControls:noteGamepad() + Input:joystickpressed(joystick, button) +end + +function Game:joystickreleased(joystick, button) + Input:joystickreleased(joystick, button) +end + +function Game:joystickaxis(joystick, axis, value) + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:joystickaxis(joystick, axis, value) +end + +function Game:joystickhat(joystick, hat, direction) + if direction ~= "c" then TouchControls:noteGamepad() end + Input:joystickhat(joystick, hat, direction) +end + -- Window focus/visibility flips: a release due while unfocused/hidden can -- be swallowed by the OS. Reset on both edges -- gaining focus with a -- physically held key won't re-fire keypressed, so trusting leftover diff --git a/src/core/Input.lua b/src/core/Input.lua index d10feef1..f9985613 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = { local STICK_ON = 0.5 local STICK_OFF = 0.3 +-- Generic SDL joysticks expose the left stick as the first two numbered +-- axes and the D-pad as a hat. This is common on Linux handhelds whose +-- controller has no game-controller database entry. +local RAW_BUTTON_BINDINGS = { + [1] = "a", [2] = "b", + [7] = "select", [8] = "start", [9] = "select", [10] = "start", +} + +local HAT_DIRECTIONS = { + u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" }, + lu = { "left", "up" }, ru = { "right", "up" }, + ld = { "left", "down" }, rd = { "right", "down" }, +} + function Input:init() self:applyBindings(nil) self:reset() @@ -79,6 +93,7 @@ function Input:reset() self.sources = {} self.stickAxis = { x = 0, y = 0 } self.stickDir = nil + self.hatDirs = {} end -- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the @@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button) end end +function Input:joystickpressed(joystick, button) + local btn = RAW_BUTTON_BINDINGS[button] + if btn then press(self, btn, "joy:" .. button) end +end + +function Input:joystickreleased(joystick, button) + local btn = RAW_BUTTON_BINDINGS[button] + if btn then release(self, btn, "joy:" .. button) end +end + -- left stick treated as a continuous held direction, same 4-way rule as -- the touch swipe d-pad: whichever axis has the larger magnitude wins. function Input:gamepadaxis(joystick, axis, value) @@ -214,6 +239,26 @@ function Input:gamepadaxis(joystick, axis, value) end end +function Input:joystickaxis(joystick, axis, value) + if axis == 1 then + self:gamepadaxis(joystick, "leftx", value) + elseif axis == 2 then + self:gamepadaxis(joystick, "lefty", value) + end +end + +function Input:joystickhat(joystick, hat, direction) + local source = "hat:" .. hat + for _, btn in ipairs(self.hatDirs[hat] or {}) do + release(self, btn, source) + end + local dirs = HAT_DIRECTIONS[direction] or {} + for _, btn in ipairs(dirs) do + press(self, btn, source) + end + self.hatDirs[hat] = dirs +end + function Input:isDown(btn) return self.state[btn] or false end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 82609f51..0ea9452e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -540,6 +540,7 @@ function RomImporter.new(onComplete, opts) _padCursorActive = false, _padAxis = { leftx = 0, lefty = 0, righty = 0 }, _padDir = {}, + _rawHatDirs = {}, _padInited = false, }, RomImporter) @@ -1184,6 +1185,36 @@ function RomImporter:gamepadaxis(_, axis, value) end end +function RomImporter:joystickpressed(joystick, button) + if button == 1 then self:gamepadpressed(joystick, "a") end +end + +function RomImporter:joystickreleased(joystick, button) + if button == 1 then self:gamepadreleased(joystick, "a") end +end + +function RomImporter:joystickaxis(joystick, axis, value) + if axis == 1 then + self:gamepadaxis(joystick, "leftx", value) + elseif axis == 2 then + self:gamepadaxis(joystick, "lefty", value) + end +end + +function RomImporter:joystickhat(_, hat, direction) + for _, dir in ipairs(self._rawHatDirs[hat] or {}) do + self._padDir[dir] = nil + end + local dirs = ({ + u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" }, + lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" }, + ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" }, + })[direction] or {} + for _, dir in ipairs(dirs) do self._padDir[dir] = true end + self._rawHatDirs[hat] = dirs + if #dirs > 0 then self:_activatePadCursor() end +end + -- Player pressed Play on a game whose ROM is imported: hand off to boot. function RomImporter:play(version) if self.workState == "working" then return end diff --git a/src/update/Boot.lua b/src/update/Boot.lua index 3c706c4e..069fbe43 100644 --- a/src/update/Boot.lua +++ b/src/update/Boot.lua @@ -50,7 +50,9 @@ local CALLBACK_NAMES = { "keypressed", "keyreleased", "textinput", "mousepressed", "mousereleased", "mousemoved", "wheelmoved", "touchpressed", "touchmoved", "touchreleased", - "gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved", + "gamepadpressed", "gamepadreleased", "gamepadaxis", + "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", + "joystickremoved", "focus", "visible", "resize", "filedropped", "directorydropped", "errorhandler", "threaderror", "lowmemory", } diff --git a/tests/input_hold_test.lua b/tests/input_hold_test.lua index 9700e159..9ec07552 100644 --- a/tests/input_hold_test.lua +++ b/tests/input_hold_test.lua @@ -46,6 +46,51 @@ Input:step() check(Input:wasPressed("left"), "stick flick edges wasPressed") check(not Input:isDown("left"), "stick flick does not stick isDown") +-- Linux handhelds without an SDL game-controller mapping send raw joystick +-- axes and D-pad hats instead of gamepad events. +Input:reset() +Input:joystickaxis(nil, 1, -0.9) +Input:step() +check(Input:isDown("left"), "raw joystick left axis holds left") +Input:joystickaxis(nil, 1, 0) +check(not Input:isDown("left"), "raw joystick axis release clears left") + +Input:reset() +Input:joystickhat(nil, 1, "u") +Input:step() +check(Input:isDown("up"), "raw joystick hat holds up") +Input:joystickhat(nil, 1, "c") +check(not Input:isDown("up"), "raw joystick hat release clears up") + +Input:reset() +Input:joystickpressed(nil, 1) +Input:step() +check(Input:isDown("a"), "raw joystick primary button presses A") + +-- The launcher has a separate virtual cursor, so prove generic joystick +-- events reach its left-stick and D-pad state too. +local RomImporter = require("src.import.RomImporter") +local importer = setmetatable({ + _padCursor = { x = 0, y = 0 }, _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, _rawHatDirs = {}, _padInited = true, +}, RomImporter) +local clicked = false +function importer:mousepressed(_, _, button) + clicked = button == 1 +end +importer:joystickaxis(nil, 1, -0.8) +check(importer._padAxis.leftx == -0.8, + "raw joystick left axis reaches the launcher cursor") +importer:joystickhat(nil, 1, "r") +check(importer._padDir.dpright, + "raw joystick hat reaches the launcher cursor") +importer:joystickhat(nil, 1, "c") +check(not importer._padDir.dpright, + "raw joystick hat release clears the launcher cursor") +importer:joystickpressed(nil, 1) +check(clicked, "raw joystick primary button clicks the launcher cursor") + -- Drivers that only inject pressQueue still get a one-step hold. Input:reset() table.insert(Input.pressQueue, "down")