Compare commits

...

38 Commits

Author SHA1 Message Date
bryanthaboi e949c78639 Merge pull request #556 from bryanthaboi/dev 2026-07-31 23:03:09 -04:00
bryanthaboi 9bcfdb1f0d mobile fixes 2026-07-31 23:01:25 -04:00
bryanthaboi e2b2a5fb9f Merge pull request #555 from bryanthaboi/dev 2026-07-31 22:41:53 -04:00
bryanthaboi f2a3e5f05b mobile updates CLOSES #482, CLOSES #553 2026-07-31 22:39:01 -04:00
bryanthaboi 5f855568c8 Merge pull request #539 from hernan0078/ios-support 2026-07-31 22:16:24 -04:00
bryanthaboi 24696e3be2 Merge pull request #524 from kaosregulator/claude/multi-game-low-end-support-o16gz5
Claude/multi game low end support  and some fixes o16gz5
2026-07-31 20:26:09 -04:00
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -04:00
hernan0078 9c5f33e187 Fix #482: guard love.system.pickFile so Import ROM cannot crash
Pressing Import ROM on iOS takes the whole app down:

  src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)

love.system.pickFile is a NATIVE BRIDGE, not part of LOVE. It exists only on
builds that compiled one -- Android, and iOS builds patched by
mobile/ios/patch_love_src.py -- so on a build without it the field is simply
nil. RomImporter:546 routes iOS down the same path as Android
(`mobileOS == "Android" or mobileOS == "iOS"`), and all three mobile pick
sites called the field unguarded.

That is why the reports say "any version": nothing about it is version
specific. Red, Blue and Yellow all reach the same call.

Every one of those call sites already handles a device with no document
picker -- Choose falls back to "No picker available, copy your ROM into:"
plus the save directory, and the mod / save rows have their own notices --
and love.system.createFile at its single call site was already guarded this
way. These three were not, so the fallback that was written for exactly this
case could never be reached.

Route them through one small helper that answers false when the bridge is
absent. A build without a picker now degrades to the copy-into-the-save-folder
flow, which on iOS is a working path: the Files app exposes the app's
Documents folder and GRBootstrap sweeps what lands there into the save dir.

tests/rom_importer_no_picker_test.lua covers Import ROM, Import mod and
Import save with the bridge missing, and asserts the picker is still used
when it is present. Reverting the fix reproduces the reported error exactly.

Reported in #482 (confirmed by three people) and #512.
2026-07-31 16:38:15 -04:00
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04:00
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
Claude 74eeb411ba Add luacheck config + lint script
Introduce a tuned .luacheckrc and scripts/lint.sh so the engine has a
standing static-analysis baseline -- the tool that would have caught both
bugs in the previous commit before they shipped.

The config is high-signal by design: it keeps the categories that catch
real defects (undefined globals/locals, unused values, unreachable code)
and mutes the cosmetic ones the codebase deliberately lives with (a self/dt
an interface requires but a method ignores, documented empty fall-through
branches, long lines). It marks `love` mutable (games assign callbacks onto
it) and teaches it LuaJIT's table.unpack.

.luacheckrc is tracked via a .gitignore exception, matching how .github and
.gitignore opt out of the blanket dotfile ignore.

`luacheck src` now reports 7 benign warnings and 0 errors, down from 185.
Also drop one dead `require` (ItemEffects loaded src.pokemon.Pokemon and
never used it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:35:40 +00:00
Claude 040ca3f332 Fix two latent bugs surfaced by static analysis
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.

1. Music.lua: applyVolume built its `music.volume` hook context from the
   private `state` table, but was defined *above* `local state = {...}`, so
   those reads bound to the nil global `state`. Any mod registering the
   music.volume hook crashed with "attempt to index a nil value (global
   'state')" the first time a volume was applied. Forward-declare `state`
   above applyVolume. Regression test drives a file-backed song through the
   hook and asserts the context resolves.

2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
   had two defects. The and/or idiom truncates a call to its first result,
   so `why` (the specific failure reason) was always dropped and every link
   failure showed the generic "Link battle can't start" instead of e.g.
   "same mods on both games". Worse, when a host's newHost() returned nil,
   the `or` fell through and wrongly called newGuest() as the host. Split
   into an explicit if/else so the reason is preserved and each role calls
   its own constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:34:58 +00: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
Claude c8e035d332 Add graphics performance tier for low-end devices
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 01:44:17 +00: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
105 changed files with 9460 additions and 400 deletions
+1
View File
@@ -16,6 +16,7 @@ __pycache__/
.*
!.github/
!.gitignore
!.luacheckrc
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
mobile/android/app/build/
+43
View File
@@ -0,0 +1,43 @@
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
--
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
--
-- The point is a high-signal baseline: the categories left on are the ones
-- that catch real defects -- undefined globals/locals (the class that hid a
-- `music.volume` crash: applyVolume read a `state` that was still the nil
-- global), unused values, unreachable code, redefinitions. The cosmetic
-- categories the codebase deliberately lives with (a `self`/`dt` an
-- interface requires but a given method ignores, documented empty
-- fall-through branches, the odd long line) are muted so they don't drown
-- the signal.
std = "luajit"
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
-- read-only -- otherwise every callback registration reads as a violation.
globals = { "love" }
read_globals = {
"jit",
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
}
-- Vendored/native trees and the test suites have their own conventions.
exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
}
ignore = {
"212", -- unused argument -- self/dt kept for a shared method signature
"213", -- unused loop variable -- `for _, v in` where only v is wanted
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
"431", -- shadowing an upvalue
"432", -- shadowing an argument
"542", -- empty if branch -- documented fall-throughs, not gaps
"631", -- line is too long
}
+11
View File
@@ -108,6 +108,17 @@ supported out of the box.
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
+9 -1
View File
@@ -1,4 +1,9 @@
function love.conf(t)
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
-- to love.filesystem.getDirectoryItems without this.
love.filesystem.setSymlinksEnabled(true)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
@@ -62,7 +67,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,
},
}
@@ -0,0 +1,95 @@
-- Viridian School House's two readables (data/events/hidden_events.asm,
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#503). Same hook shape, and the same sibling asm
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
-- QUIT or B falls through to .exitBlackboard.
local STATUS_LABELS = {
{ " SLP", "_ViridianBlackboardSleepText" },
{ " PSN", "_ViridianBlackboardPoisonText" },
{ " PAR", "_ViridianBlackboardPrlzText" },
{ " BRN", "_ViridianBlackboardBurnText" },
{ " FRZ", "_ViridianBlackboardFrozenText" },
}
local function blackboard(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
end
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
-- pass, immediately before HandleMenuInput
function askHeading()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, row in ipairs(STATUS_LABELS) do
local label, key = row[1], row[2]
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
end }
end
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
askHeading))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
-- and NO stops the read; page 4 turns without asking and runs straight into
-- page 5, the girl catching you at it.
local function notebook(game)
local text = game.data.text or {}
local function page(n, after)
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
end
local function turnPage(nextPage)
return function()
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
nil, { choice = function(yes)
if yes then game.stack:push(nextPage()) end
end }))
end
end
local function page5() return page(5) end
local function page4() return page(4, function() game.stack:push(page5()) end) end
local function page3() return page(3, turnPage(page4)) end
local function page2() return page(2, turnPage(page3)) end
game.stack:push(page(1, turnPage(page2)))
end
return {
VIRIDIAN_SCHOOL_HOUSE = {
onInteract = function(game, ow, fx, fy)
if fx == 3 and fy == 0 then
blackboard(game)
return true
end
if fx == 3 and fy == 4 then
notebook(game)
return true
end
return false
end,
},
}
+1
View File
@@ -60,6 +60,7 @@ local files = {
"data.scripts.flavor.victory_road_2f",
"data.scripts.flavor.viridian_city",
"data.scripts.flavor.viridian_nickname_house",
"data.scripts.flavor.viridian_school_house", -- #503
"data.scripts.flavor.wardens_house",
}
+14 -3
View File
@@ -647,7 +647,12 @@ M.WARDENS_HOUSE = {
TEXT_WARDENSHOUSE_WARDEN = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
{ "jump_if_true", 13 }, -- 3
-- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough (row 13), so the warden said nothing
-- on every visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
-- so route here to the new row 17 that does the same.
{ "jump_if_true", 17 }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
@@ -658,9 +663,15 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 16 }, -- 13 (already got it)
{ "jump", 16 }, -- 14 (unused)
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
{ "jump", 18 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
},
},
}
+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,
}
-- -------------------------------------------------------------------
+12 -1
View File
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
-- PAD_RIGHT step after the refusal text, and only clear
-- wJoyIgnore / hand control back once that step finishes
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
ow:scriptMove(ow.player, "up", dist, shoveRight)
else
shoveRight()
end
end)
end)
+7
View File
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+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.
+71 -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".)
@@ -137,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -151,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
@@ -265,6 +313,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 +423,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.
+86 -15
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,12 +456,32 @@ 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 Importer then return Importer:mousepressed(x, y, button) end
function love.mousepressed(x, y, button, istouch)
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
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch rather than on the OS keeps a real mouse
-- (DeX, a Chromebook, a USB mouse) working, which an Android-wide return
-- would have broken.
if istouch then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
@@ -429,6 +491,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 +505,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 +516,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;
@@ -83,7 +84,15 @@ public class GameActivity extends SDLActivity {
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
// "Don't keep activities"). A recreated instance still receives
// onActivityResult, so without this a mod or save pick came back with the
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
// bad ROM instead of installing it (#553).
private String pendingPickFilename = PICKED_ROM_FILENAME;
private static final String STATE_PENDING_PICK = "pendingPickFilename";
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
@@ -148,6 +157,14 @@ public class GameActivity extends SDLActivity {
}
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
if (pick != null) pendingPickFilename = pick;
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
if (create != null) pendingCreateSuggestedName = create;
}
metrics = getResources().getDisplayMetrics();
// Set low-latency audio values
@@ -303,6 +320,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) {
@@ -490,6 +555,13 @@ public class GameActivity extends SDLActivity {
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
+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"
}
+120 -3
View File
@@ -65,6 +65,10 @@ DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -229,6 +233,68 @@ apply_ios_branding() {
}
# --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
# one is absent, and dev reads them off the source tree, so the miss only ever
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
# for exactly that reason.
#
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
# a fourth version cannot silently ship without its manifest, and a missing file
# is recovered from Git or the project repo the same way build_android.sh already
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
# a manifest carries extraction metadata only, never a ROM or game data.
manifest_paths() {
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
import re, sys
src = open(sys.argv[1]).read()
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
PY
}
manifest_is_valid() {
python3 - "$1" <<'PY'
import json, pathlib, sys
try:
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
sha = m.get("romSha1")
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
PY
}
ensure_manifests() {
MANIFESTS="$(manifest_paths)"
[ -n "$MANIFESTS" ] \
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
local rel staged
for rel in $MANIFESTS; do
if manifest_is_valid "$ROOT/$rel"; then continue; fi
warn "$rel is missing or invalid; recovering it before packaging"
staged="$(mktemp)"
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "restored $rel from this checkout's Git data"
continue
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "downloaded $rel from the project repository"
continue
fi
rm -f "$staged"
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
done
say "import manifests: $MANIFESTS"
}
pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
@@ -240,9 +306,10 @@ pack_game_love() {
# it reappears every launch. Mods install as .zips at runtime instead
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
# other platform.
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
$MANIFESTS \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
@@ -252,8 +319,18 @@ pack_game_love() {
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
# outright when a version's manifest is absent, so Import ROM on Yellow died
# in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua $MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
@@ -348,6 +425,43 @@ PY
}
# --------------------------------------------------------------- xcodebuild
# love.system.pickFile and createFile are a native bridge compiled in by
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
# patch still links and still runs, then finds the field nil the moment anyone
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
# rather than crash, which is the right floor, but a build with no picker at all
# is a silent downgrade, so fail here instead of shipping one.
#
# Checked against the built binary rather than the source, because patching
# love-src proves nothing about what Xcode actually compiled: the shipped
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
# a missing patch step were self-built IPAs, exactly the case this catches.
verify_native_bridge() {
local app="$1"
local exe bin missing=""
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping native bridge check"
return 0
fi
# grep -q here would race pipefail the same way pack_game_love documents:
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
# nondeterministically. >/dev/null keeps grep reading the whole stream.
for sym in pickFile createFile; do
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
done
if [ -n "$missing" ]; then
fail "built app has no native bridge (missing:$missing).
Import ROM would fall back to copy-into-Files instead of opening the picker.
mobile/ios/patch_love_src.py did not take. Re-run:
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
fi
say "native bridge present (pickFile, createFile)"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -455,6 +569,8 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
@@ -526,6 +642,7 @@ install_to_device() {
apply_ios_branding
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests
pack_game_love
ensure_game_love_in_xcode
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Static analysis for the engine Lua (config: .luacheckrc).
#
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
# defects that never run in a green test -- undefined globals/locals (the
# class that hid a music.volume crash: a hook read a `state` that was still
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
set -uo pipefail
cd "$(dirname "$0")/.."
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
+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]
+6
View File
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
-- failed accuracy roll (core.asm:5260), and the miss handler still
-- runs the explode effect ("even if Explosion or Selfdestruct
-- missed, its effect still needs to be activated", core.asm:3223),
-- so the user faints against a mid-Fly/Dig target too (#528)
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
return
end
+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",
+36 -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
@@ -618,7 +634,25 @@ function Game:applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
-- Scale the optional presentation extras to the device's performance
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
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
+8 -1
View File
@@ -23,6 +23,13 @@ local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
if not src then return end
local vol = VOLUME * volumeScale
@@ -63,7 +70,7 @@ local function applyFilter(src)
end
end
local state = {
state = {
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
+156
View File
@@ -0,0 +1,156 @@
-- Graphics performance tier: one knob that scales the port's optional,
-- non-faithful presentation extras down for weaker hardware.
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
--
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
local Performance = {}
-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }
Performance.LABELS = {
auto = "AUTO",
high = "HIGH",
balanced = "BALANCED",
low = "LOW",
}
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
-- a pre-boot launcher behaves exactly as it always did.
Performance.tier = "high"
local function loveOS()
if love and love.system and love.system.getOS then
return love.system.getOS()
end
return nil
end
local function processorCount()
if love and love.system and love.system.getProcessorCount then
return love.system.getProcessorCount()
end
return nil
end
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
-- clamping) so tooling is never surprised.
local function cpuArch()
return (jit and jit.arch) or nil
end
-- Default tier for the current device. Deliberately conservative: only
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
-- overridable, so a wrong guess costs one OPTIONS row. A normal
-- multi-core desktop -- the common case, and every existing install whose
-- options.lua predates this option -- resolves to HIGH and behaves exactly
-- as before.
function Performance.detect()
local os = loveOS()
local arch = cpuArch()
local isArm = arch == "arm" or arch == "arm64"
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
-- Desktop: a dual-core (or single-core) box is the low-end line.
if cores and cores <= 2 then
return "balanced"
end
return "high"
end
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
-- hand-edited garbage) through detect().
function Performance.resolve(value)
if value == "high" or value == "balanced" or value == "low" then
return value
end
return Performance.detect()
end
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
-- degrades to auto so a corrupt options.lua still lands on something sane.
function Performance.normalize(value)
for _, id in ipairs(Performance.TIERS) do
if value == id then return id end
end
return "auto"
end
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
function Performance.label(value)
return Performance.LABELS[Performance.normalize(value)]
end
-- Concrete caps for a stored option value (resolving auto).
function Performance.caps(value)
return Performance.CAPS[Performance.resolve(value)]
end
-- Resolve the tier for these options, record it live, and return its caps.
-- Called from Game:applyOptions, which clamps the live presentation modules
-- against the returned caps.
function Performance.applyOptions(opts)
local tier = Performance.resolve(opts and opts.performance)
Performance.tier = tier
return Performance.CAPS[tier]
end
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
-- negative dir wraps correctly.
function Performance.cycle(value, dir)
value = Performance.normalize(value)
local i = 1
for idx, id in ipairs(Performance.TIERS) do
if id == value then i = idx break end
end
local n = #Performance.TIERS
local nextIdx = (i - 1 + (dir or 1)) % n + 1
return Performance.TIERS[nextIdx]
end
return Performance
+23
View File
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
@@ -249,6 +254,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
+1682 -97
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -10,7 +10,6 @@
-- "ball" caller must throw it (battle only)
-- "learn", moveId caller must run the learn-move flow
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
-- on that carry -- surfing refuses the rod with the same OAK text as
-- the mid-battle case above, no rod-specific message (#533)
if ow and ow.player and ow.player.surfing then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
+8 -1
View File
@@ -128,7 +128,14 @@ end
-- ------- the link surface
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
-- in a link battle is a trainer-battle throw and always refused (pokered
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
-- surface delta is the Dragonair/Dragonite catch-rate bytes
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
-- vs 9), when the real cable links R/B/Y freely.
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
"growthRate", "evolutions" }
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
"priority", "highCrit", "fixedDamage", "multiHit",
+9 -12
View File
@@ -178,19 +178,16 @@ end
-- ------- subset negotiation
-- the species and moves this party actually references, so the exchange
-- stays small (six mons) instead of shipping the whole catalog
-- every record hash, not just this party's slice: the receiver filters
-- its OWN party against these (eligibleParty), so a message limited to the
-- sender's party read "not on the other game" for every species the two
-- parties did not happen to share, and a subset trade between different
-- games showed neither side (#511). The full catalog is ~300 short hash
-- strings -- still one message.
function Protocol.recordsMessage(data, party)
local species = Fingerprint.records(data, "pokemon")
local moves = Fingerprint.records(data, "moves")
local outSpecies, outMoves = {}, {}
for _, mon in ipairs(party or {}) do
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
for _, mv in ipairs(mon.moves or {}) do
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
end
end
return { type = "records", pokemon = outSpecies, moves = outMoves }
return { type = "records",
pokemon = Fingerprint.records(data, "pokemon"),
moves = Fingerprint.records(data, "moves") }
end
-- a mon may cross the wire only if both peers rebuild it identically: the
+10 -3
View File
@@ -384,9 +384,16 @@ function Tournament:update(dt)
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
-- Split rather than `cond and newHost() or newGuest()`: the and/or
-- idiom truncates a call to its first result, so the second return
-- (the specific reason) was always dropped and every failure showed
-- the generic fallback instead of "same mods on both games" etc.
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
+102 -12
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
@@ -213,7 +223,9 @@ local function discover()
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
if info and (info.type == "directory" or info.type == "symlink") then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
@@ -231,8 +243,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 +473,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 +529,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 +569,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]
+17 -1
View File
@@ -206,7 +206,11 @@ function Loader:_discover()
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
local path = root .. "/" .. name
local info = self.fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
-- own getInfo, only traversal into it. readManifest below still
-- correctly no-ops on a symlink that isn't a directory.
if info and (info.type == "directory" or info.type == "symlink") then
local manifest, err = readManifest(self.fs, path)
if manifest then
if self.mods[manifest.id] then
@@ -834,6 +838,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
+10
View File
@@ -10,6 +10,13 @@ local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
return lo, hi
end
+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)
+50
View File
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,21 @@ local function buildRows(game)
require("src.core.Music").setFilterLevel(o.musicFilter)
return true
end },
-- Heads the port's display group: one tier that scales the heavy extras
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
-- AUTO picks a default from the hardware; every tier is overridable.
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
{ id = "performance", label = Strings("PERFORMANCE"),
value = function(g)
return Strings(Performance.label(g.save.options.performance))
end,
step = function(g, dir)
local o = g.save.options
o.performance = Performance.cycle(o.performance, dir)
g:applyOptions(o)
return true
end },
{ id = "colors", label = Strings("COLORS"),
value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
@@ -322,6 +338,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 +366,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
+99 -12
View File
@@ -148,6 +148,37 @@ local SPEED_BARS = {
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
}
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
-- every iteration with its own `call DelayFrame`, so one scene handler costs
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
-- handler inside a single update() tick, so every one of those internal
-- DelayFrame calls has to be spent explicitly or the movie runs short of
-- hardware. Three places owe frames:
--
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
-- white on DMG), two DelayFrame calls, then DisableLCD.
-- Two internal plus the loop's own is a 3-frame
-- iteration, after which the next wait scene takes its
-- first timer decrement. Func_f9e9a restores rBGP $e4
-- at the end of the same handler.
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
-- and the first .loop pass, and YellowIntroScene0 then
-- owns a whole iteration of its own.
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
-- four DelayFrame calls clearing the tilemap and OAM
-- before the title screen is built.
--
-- These restore the movie's real elapsed length; they do NOT lengthen the
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
-- Music_YellowIntro off mid-phrase too (#523).
local SETUP_SCENE_DELAY = 3
local HEAD_FRAMES = 2
local EXIT_FRAMES = 4
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
self.palName = "MEWMON" -- PalPacket_Generic
self.objects = {}
self.cloudFrame = 0
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
self.pendingDelay = 0
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
end
end
-- Spend `frames` real frames, then run fn: the port's stand-in for the
-- DelayFrame calls a pokeyellow scene handler makes inside its own
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
function YellowIntro:enterDelay(frames, fn)
self.pendingDelay = frames
self.pendingThen = fn
end
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
function YellowIntro:enterSetupScene(scene)
self.bgp = 0x00
self:enterDelay(SETUP_SCENE_DELAY, function()
self.bgp = 0xE4
self:startScene(scene)
end)
end
function YellowIntro:enter()
if self.pre then return end -- pre-roll first; beginScenes takes over
self:beginScenes()
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
or songs.Music_IntroBattle and "Music_IntroBattle")
if song then pcall(Music.play, data, song, false) end
self:startScene(0)
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
-- plus YellowIntroScene0's own iteration. new() already laid down the
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
-- frames show what hardware shows: the letterbox with no pika yet.
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
end
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
@@ -586,16 +641,44 @@ function YellowIntro:finish()
if self.onDone then self.onDone() end
end
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
-- buffers before the title screen is built (#523). The pre-roll's own skip
-- is IntroMovie's path, not this one, and still finishes immediately.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
self.bgp = 0x00
self:clearObjects()
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
end
function YellowIntro:update(dt)
if self.finished then return end
if self.pre then
self.pre:update(dt)
return
end
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
-- top of each .loop iteration, never inside a handler, so hardware is deaf
-- for the DelayFrame calls these ticks stand in for (#523).
if self.pendingDelay > 0 then
self.pendingDelay = self.pendingDelay - 1
if self.pendingDelay == 0 then
local fn = self.pendingThen
self.pendingThen = nil
if fn then fn() end
end
self:updateObjects()
if self.bgDirty then self:rebuildBgCanvas() end
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
self:exitToTitle()
return
end
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(scene + 1)
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 3 then
if self.timer > 0 then
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
else
self:clearObjects()
self:startScene(4)
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 7 then
if self.timer > 0 then
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
self.wave[255] = first
else
self:clearObjects()
self:startScene(8)
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 11 then
if self.timer > 0 then
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(12)
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 13 then
if self.timer > 0 then
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
if v then
self.bgp = v
else
-- .expired: everything despawns, letterbox returns, logo/face
-- object $7 appears for the strobe scene
-- .expired: everything despawns and the letterbox returns, then the
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
-- and spawns the logo/face object $7 for the strobe scene (#523).
self:clearObjects()
self:bgLetterbox()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
self:enterDelay(3, function()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
end)
end
elseif scene == 15 then
if self.timer > 0 then
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
if self.timer > 0 then
self.timer = self.timer - 1
else
self:finish()
self:exitToTitle()
return
end
end
+110 -12
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")
@@ -359,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
-- boot only: the original persists the surf state. wWalkBikeSurfState
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
-- engine/menus/save.asm block-copies into sMainData on save and back out
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
-- it here, before Music.playMap reads it below and before
-- PikachuFollower.onMapEntered, matching LoadMapData calling
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
-- home/audio.asm). Without this the player resumed on foot on a water
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
-- picks land tile-pairs whenever mover.surfing is falsy, and land
-- tile-pairs never permit stepping off a water cell (#536). Same
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
if opts and opts.via == "boot" then
local ps = Game.save and Game.save.player
if ps and ps.surfing ~= nil then
self.player.surfing = ps.surfing and true or false
else
-- saves written before #536 carry no flag. Map:isWaterCell alone is
-- not self-sufficient (see src/world/Map.lua: water and shore share
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
-- water), so gate it the way facingIsShoreOrWater does and require a
-- cell you could not be standing on upright.
self.player.surfing = self:tilesetHasWater()
and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y)
end
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
@@ -550,9 +581,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
@@ -1040,19 +1088,38 @@ function OverworldState:handleInput()
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
-- walking animation" it jumps straight to .moveAhead): A, START and
-- direction initiation are only ever looked at while the player stands
-- on a tile, and a button pressed mid-step is simply never seen.
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
-- right there and froze Red between tiles, mid-animation (#286). Held
-- directions need no buffering -- isDown below picks them up on the
-- landing frame.
if self.player.moving then return end
-- direction initiation are only ever ACTED ON while the player stands on
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
-- StartMenu right there and froze Red between tiles, mid-animation
-- (#286). Held directions need no buffering -- isDown below picks them
-- up on the landing frame.
--
-- The original defers the poll rather than discarding it, though. Joypad
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
-- advances hJoyLast only when something calls it; the mid-step path never
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
-- hJoyLast is frozen for the whole animation, so a button pressed
-- mid-step and STILL HELD when the step lands reads as a fresh press at
-- the next poll -- one released before then is genuinely lost. Dropping
-- the edge outright made START a coin flip on the Cycling Road roll,
-- where the pull below re-arms a step on the single idle frame in
-- bikeStepFrames (#525).
if self.player.moving then
local held = self.joyLatch
if not held then held = {}; self.joyLatch = held end
if input:wasPressed("a") then held.a = true end
if input:wasPressed("start") then held.start = true end
return
end
local latch = self.joyLatch
self.joyLatch = nil
if input:wasPressed("a") then
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
self:interact()
return
end
if input:wasPressed("start") then
if input:wasPressed("start")
or (latch and latch.start and input:isDown("start")) then
require("src.core.Sound").play(Game.data, "Start_Menu")
Screens.push(Game, "StartMenu")
return
@@ -3915,6 +3982,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 +4246,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 +4571,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
@@ -4517,6 +4610,11 @@ function OverworldState:captureSave(save)
save.player.x = self.player.cellX
save.player.y = self.player.cellY
save.player.facing = self.player.facing
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
-- so the original saves and restores the surf state; setMap's boot path
-- reads this back (#536).
save.player.surfing = self.player.surfing and true or false
end
return OverworldState
+7 -3
View File
@@ -12,9 +12,13 @@ local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
-- a turn in place holds for the ~2 frames the original spends on the
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
-- returns to the loop without moving after a direction change)
-- a turn in place blocks movement for the one extra OverworldLoop pass the
-- original spends after a direction change: .handleDirectionButtonPress ends
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
local TURN_FRAMES = 2
function Player.new(data, cx, cy, facing)
+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
@@ -0,0 +1,99 @@
-- Driver: opening the START menu while the bike force-rolls down Cycling
-- Road (#525). src/world/OverworldController.lua handleInput() used to
-- drop A/START outright while self.player.moving, so a press that landed
-- mid-step and was still held when the step completed vanished -- on
-- Cycling Road's forced roll, where a step re-arms on the single idle
-- frame between steps, that made START a coin flip. The fix latches a
-- still-held press and acts on it on the landing frame instead
-- (see tests/parity_midstep_buttons.lua for the mechanical half).
--
-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \
-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34.
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
game.save.onBike = true
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(15)
local ow = game.overworld
check("standing on Cycling Road, on the bike",
ow.map.id == MAP and game.save.onBike)
-- hands off the pad: the slope pulls the bike south on its own
-- (field.forcedMovement.slopeMaps, same mechanism bug255 proved)
U.wait(20)
check("the roll is already under way", ow.player.moving == true
or ow.player.cellY > START_Y)
U.shot(game, DIR .. "/bug525_1_rolling.png")
-- press START mid-step and keep it held through the landing frame, the
-- exact shape a real thumb makes reaching for the menu while rolling
local pressedMidStep = false
local guard = 0
while guard < 60 do
guard = guard + 1
if ow.player.moving and ow.player.progress
and ow.player.progress > 2 and not pressedMidStep then
table.insert(game.input.pressQueue, "start")
game.input.state.start = true
pressedMidStep = true
U.log("pressed START mid-step at progress", ow.player.progress)
end
coroutine.yield()
if getmetatable(game.stack:top()) ~= nil
and game.stack:top() ~= ow and pressedMidStep then
break
end
end
U.wait(2)
game.input.state.start = false
local top = game.stack:top()
U.shot(game, DIR .. "/bug525_2_after_start.png")
check("START held through the landing opened the start menu",
top ~= ow and top ~= nil)
check("the player is not frozen mid-tile (px/py land on a 16px cell)",
ow.player.px % 16 == 0 and ow.player.py % 16 == 0)
-- close it and let the roll continue, proving the pull did not get
-- eaten along with the buffered button
if top ~= ow then
while game.stack:top() ~= ow do
U.tap(game, "b")
U.wait(10)
end
end
local yBeforeResume = ow.player.cellY
U.wait(48)
check("the roll resumes south after closing the menu",
ow.player.cellY > yBeforeResume)
U.shot(game, DIR .. "/bug525_3_resumed.png")
-- park with road left to roll, on the bike, before handing over
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(10)
game.save.onBike = true
U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y ..
"), rolling south hands-off.")
U.log("Press START right as you see a step land, and again mid-step")
U.log("while releasing before it lands. Right: a START you keep pressed")
U.log("into the landing opens the menu every time, one you let go of")
U.log("mid-step does nothing (same as standing still). #525 was the")
U.log("held case sometimes doing nothing at all, a coin flip on this hill.")
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
@@ -0,0 +1,107 @@
-- Driver: Fighting Dojo Karate Master does not leave his post (#502),
-- the opposite-direction report against the same script #495 fixed
-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep).
--
-- Before the fix his trainer header used range=4/DOWN sight aggro
-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his
-- column from below put him in CheckFightingMapTrainers' generic path:
-- the NPC walks toward the player before the pre-battle text, straight
-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the
-- "merges with a pupil" glitch, and it fired regardless of whether the
-- pupils in front of him had been beaten yet ("forces the fight early").
-- The fix sets range=0 and gates him on the single tile at his left
-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face
-- the player there, never steps.
--
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \
-- POKEPORT_IDENTITY=bug502 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function masterIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end
end
return nil
end
-- No pupil beaten, master not beaten: the exact state the report says
-- triggered the early merge (walking up before clearing the pupils).
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_KARATE_MASTER = nil
game.save.defeatedTrainers = {}
------------------------------------------------------------------
-- Direction 1 (#502): stand in the master's own sight column, below
-- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at
-- (5,7)) still in the way -- exactly the "walk towards Karate Master"
-- report. The old range=4/DOWN header put any of these cells in his
-- sight; his first step toward the player collided with BLACKBELT3's
-- tile. Collision blocks a real walk onto an NPC's cell, so probe by
-- teleport (a scripted approach cannot walk through them either) and
-- watch several fixed steps for the master moving or engaging at all.
------------------------------------------------------------------
local ow, master
local function probe(px, py, label)
U.teleport(game, "FIGHTING_DOJO", px, py, "up")
U.wait(10)
ow = game.stack:top()
master = masterIn(ow)
U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png")
local moved, engaged = false, false
for _ = 1, 40 do
U.wait(1)
if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end
if getmetatable(game.stack:top()) == TextBox then engaged = true; break end
end
U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png")
check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO")
check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")",
not moved)
check("standing at " .. label .. " started no battle on its own",
not engaged)
return moved or engaged
end
-- (5,4): directly below the master, one tile from BLACKBELT3 (5,5).
-- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old
-- sight range and still boxed in by pupils on both sides.
local bad1 = probe(5, 4, "below_master")
local bad2 = probe(5, 6, "between_pupils")
------------------------------------------------------------------
-- Direction 2 (#495, same script): the ONLY tile that should start his
-- fight is one step left of him. Confirm the gate still works so the
-- fix didn't just make him inert in both directions.
------------------------------------------------------------------
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
U.wait(10)
ow = game.stack:top()
master = masterIn(ow)
U.shot(game, DIR .. "/bug502_3_gate_before.png")
U.tap(game, "down")
U.wait(30)
local gateOpened = getmetatable(game.stack:top()) == TextBox
U.shot(game, DIR .. "/bug502_4_gate_after.png")
check("stepping onto (4,3) still starts the Master's challenge", gateOpened)
check("he turned to face the player instead of walking to them",
master.cellX == 5 and master.cellY == 3 and master.facing == "left")
U.log("Compare bug502_1/2: the Master's sprite should sit in the same")
U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.")
U.log("bug502_4 should show his pre-battle text box, with him still on")
U.log("(5,3), just turned to face left -- that is the correct trigger,")
U.log("distinct from the column walk that used to glitch him south.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,42 @@
-- Driver: a quick directional tap should turn the player in place, not
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
-- many fixed steps a facing change blocks movement for before a held
-- direction is allowed to commit to a step. This is an input-timing feel
-- bug: no assertion here can tell a quick tap from a held one the way a
-- human thumb can, so this hands the pad over rather than scripting taps
-- at a fixed, unrealistic frame count.
--
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
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
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
-- accidental step is obvious -- a stray step toward the stairs at
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
U.wait(10)
local ow = game.stack:top()
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
check("player starts facing down", ow.player.facing == "down")
U.log("Standing in the middle of the bedroom, facing down.")
U.log("Tap Up once, quickly, then tap Right once, quickly.")
U.log("Right: each tap only turns you to face that way -- your feet")
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
U.log("still slides you one tile in the new direction, same as holding.")
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
U.log("should feel identical to before -- no extra pause was added there.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,99 @@
-- Manual check that Yellow's bank $1f music headers, wave table and cry
-- data read from the right ROM addresses (#522). Before the fix every
-- header past Music_YellowIntro (a 3-channel header where Red's
-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's
-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every
-- song's channel 3 sampled engine code instead of a wave table.
-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ChipSynth = require("src.core.ChipSynth")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local audio = game.data.audio or {}
local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2
-- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065)
check("Music_Dungeon2 header reads pokeyellow.sym's address",
header and header.address == 17065 and header.bank == 31)
-- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602)
local cry = audio.cryData
check("cryData reads pokeyellow.sym's CryData",
cry and cry.address == 21602 and cry.bank == 14)
-- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26
local waves = audio.waveBanks or {}
local waveOk = true
for _, engine in ipairs({ "1", "2", "3" }) do
local w = waves[engine]
if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end
end
check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk)
-- Rebuild the engine exactly as ChipAudio does and count the channels a
-- misread header would drop to 1 (see headerChannels, ChipSynth.lua).
local engine, engErr
if header then
local ok
ok, engine = pcall(ChipSynth.newEngine, game.data, header)
if not ok then engErr = engine; engine = nil end
end
check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""),
engine ~= nil)
check("Music_Dungeon2 has all 4 channels, not the misread header's 1",
engine and #engine.channels == 4)
-- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1::
-- tempo 144"); a header pointed at the wrong row drops it and the engine
-- is left at the 0x100 default, ~1.78x slower.
if engine and engine.channels[1] then
engine.channels[1]:nextEvent()
end
check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default",
engine and engine.tempo == 144)
-- pokered data/maps/objects/ViridianForest.asm: the south gate warps land
-- around (16-18, 47); one step north of that is inside the forest proper.
local MAP = "VIRIDIAN_FOREST"
local STAND = { x = 16, y = 46, facing = "up" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local function nearestWalkable()
for dy = -3, 3 do
for dx = -3, 3 do
local cx, cy = STAND.x + dx, STAND.y + dy
if ow.map:isWalkableCell(cx, cy) then return cx, cy end
end
end
return nil
end
local cx, cy = nearestWalkable()
if cx then
U.log("start cell blocked, standing at", cx, cy, "instead")
U.teleport(game, MAP, cx, cy, "up")
end
end
if game.save and game.save.options and game.save.options.musicVol == 0 then
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
end
U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.")
U.log("Right: four voices at a brisk clip -- lead melody, a counter line")
U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.")
U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,")
U.log("draggy tempo, with no bass or hi-hat at all.")
U.log("Also listen to that bass note's timbre -- it should be a soft")
U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,73 @@
-- Manual check that the Yellow intro now runs long enough for
-- Music_YellowIntro's descending run and long final note to be audible
-- before title.asm's unconditional StopAllMusic swaps in the title theme
-- (#523). Never taps a button: Game:load() already pushed YellowIntro,
-- so this driver only watches it play out at real, unaccelerated speed.
-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz
-- accumulator (Game:update), decoupled from the fast-forwardable logic
-- clock, so speeding up the driver would desync the exact ordering under
-- test here instead of just playing it back faster.
-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("booted into a state (Yellow intro, per bootScreens)",
game.stack:top() ~= nil)
if game.save and game.save.options and game.save.options.musicVol == 0 then
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
end
local sawIntroSong = false
local sawTitleSong = false
-- Music.lua keeps the current song in a module-local `state` table with
-- no public getter, so watch the one seam that is public: wrap Music.play
-- itself for the life of this driver and log what it's handed. Both the
-- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then-
-- PlaySound land here.
local frame = 0
local originalPlay = Music.play
Music.play = function(data, song, loop, ctx)
if song and song:find("Intro") and not sawIntroSong then
sawIntroSong = true
U.log("Music_YellowIntro started")
end
if song == "Music_TitleScreen" and not sawTitleSong then
sawTitleSong = true
U.log("Music_TitleScreen started")
end
return originalPlay(data, song, loop, ctx)
end
-- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of
-- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands
-- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523).
while frame < 40 * 60 and not sawTitleSong do
frame = frame + 1
coroutine.yield()
end
check("Music_YellowIntro started", sawIntroSong)
check("the movie reached the title screen and Music_TitleScreen took over",
sawTitleSong)
U.log("Listen for Music_YellowIntro's ending: a short descending run of")
U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,")
U.log("right before the title theme cuts in. Before #523 the swap landed")
U.log("early and those descending notes never played -- the track just")
U.log("stopped mid-phrase. The swap itself is still an abrupt cut by")
U.log("design (pokeyellow's title.asm stops the intro song unconditionally,")
U.log("it never waits for it to finish); what changed is how much of the")
U.log("track plays before that cut lands.")
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")
+65
View File
@@ -0,0 +1,65 @@
-- Regression: the `music.volume` mod hook must not crash on Music's private
-- `state`. applyVolume (src/core/Music.lua) builds its hook context from
-- state.current/mapSong/onBike/... but was defined above the `local state`
-- table, so those reads bound to the nil global `state` instead -- a mod that
-- registered music.volume hit "attempt to index a nil value (global 'state')"
-- the first time any volume was applied. This drives a file-backed song
-- through Music with the hook installed and asserts the context resolves.
-- ROM-free: a fake audio source, no data/generated/.
-- luajit tests/engine/music_volume_hook_state.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
-- minimal audio source: only the methods Music calls on a file song
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping(v) self.looping = v end
function Source:setVolume(v) self.volume = v end
function Source:setFilter() end
love.audio = {
newSource = function(file) return setmetatable({ file = file }, Source) end,
}
local Runtime = require("src.mods.Runtime")
local Music = require("src.core.Music")
local events = require("src.mods.Events").new()
local hooks = require("src.mods.Hooks").new()
Runtime.install(events, hooks)
-- one file-backed song is enough; the chip path would pull in `bit`
local data = { audio = { songs = { TEST = { file = "test.ogg" } } } }
-- record every context the hook is handed, and scale the volume so the
-- return value is exercised too
local calls = {}
hooks:wrap("music.volume", function(_next, vol, ctx)
calls[#calls + 1] = ctx
return vol * 0.5
end, nil, "voltest")
T.check(Runtime.wantsHook("music.volume"), "the music.volume hook is registered")
-- Before the fix this call raised inside applyVolume; reaching the next line
-- at all is the core of the regression.
Music.play(data, "TEST")
T.check(#calls >= 1, "applyVolume ran the hook during play without crashing")
-- A second application, now that the song is current: state must resolve to
-- the real playback table, so the context carries the live song + scale.
local before = #calls
Music.setVolumeLevel(5)
T.check(#calls > before, "setVolumeLevel re-applies volume through the hook")
local ctx = calls[#calls]
T.check(type(ctx) == "table", "the hook receives a context table")
T.eq(ctx.song, "TEST", "ctx.song is the live song (state resolved, not nil)")
T.eq(ctx.optionScale, 5 / 7, "ctx.optionScale reflects the 0-7 volume level")
T.finish("music_volume_hook_state")
+113
View File
@@ -0,0 +1,113 @@
-- Graphics performance tier (src/core/Performance.lua): the AUTO device
-- heuristic, tier normalization/labels, per-tier caps, the option-row
-- cycle, and applyOptions recording the live tier. Pure logic over
-- stubbed love/jit globals, so it needs no ROM and runs in the T2 tier.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Performance = require("src.core.Performance")
-- detect() reads the live `love` and `jit` globals; swap them per scenario
-- and restore afterward so nothing leaks between checks (or into luajit).
local realLove, realJit = love, jit
local function device(os, arch, cores)
love = {
system = {
getOS = function() return os end,
getProcessorCount = function() return cores end,
},
}
jit = arch and { arch = arch } or nil
end
local function restore()
love, jit = realLove, realJit
end
-- ---------------------------------------------------------------- detect
device("Linux", "arm64", 4)
T.eq(Performance.detect(), "low", "ARM64 Linux handheld -> low")
device("Linux", "arm", 2)
T.eq(Performance.detect(), "low", "32-bit ARM Linux handheld -> low")
device("Android", "arm64", 8)
T.eq(Performance.detect(), "balanced", "Android phone -> balanced (not low)")
device("iOS", "arm64", 6)
T.eq(Performance.detect(), "balanced", "iOS -> balanced")
device("OS X", "x64", 8)
T.eq(Performance.detect(), "high", "8-core desktop -> high")
device("Windows", "x64", 2)
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
device("Windows", "x86", 1)
T.eq(Performance.detect(), "balanced", "single-core desktop -> balanced")
-- No love, no jit (a plain-Lua tool/test): nothing looks weak -> high, so
-- tooling that runs applyOptions is never surprised by a clamp.
love, jit = nil, nil
T.eq(Performance.detect(), "high", "no love/jit -> high (no clamping)")
restore()
-- ---------------------------------------------------------------- resolve
T.eq(Performance.resolve("high"), "high", "resolve passes concrete high")
T.eq(Performance.resolve("balanced"), "balanced", "resolve passes balanced")
T.eq(Performance.resolve("low"), "low", "resolve passes low")
device("Linux", "arm64", 4)
T.eq(Performance.resolve("auto"), "low", "resolve auto -> detect (handheld)")
T.eq(Performance.resolve(nil), "low", "resolve nil -> detect")
T.eq(Performance.resolve("bogus"), "low", "resolve garbage -> detect")
restore()
-- --------------------------------------------------------------- normalize
T.eq(Performance.normalize("auto"), "auto", "normalize keeps auto")
T.eq(Performance.normalize("low"), "low", "normalize keeps low")
T.eq(Performance.normalize("xyz"), "auto", "normalize garbage -> auto")
T.eq(Performance.normalize(nil), "auto", "normalize nil -> auto")
-- ------------------------------------------------------------------- label
T.eq(Performance.label("low"), "LOW", "label low")
T.eq(Performance.label("balanced"), "BALANCED", "label balanced")
T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
-- -------------------------------------------------------------------- caps
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
local bal = Performance.CAPS.balanced
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
device("Linux", "arm64", 4)
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")
restore()
-- ------------------------------------------------------------- applyOptions
local caps = Performance.applyOptions({ performance = "low" })
T.eq(caps, Performance.CAPS.low, "applyOptions returns the resolved caps")
T.eq(Performance.tier, "low", "applyOptions records the live tier")
Performance.applyOptions({ performance = "high" })
T.eq(Performance.tier, "high", "applyOptions high tier")
device("Linux", "arm64", 4)
Performance.applyOptions(nil)
T.eq(Performance.tier, "low", "applyOptions(nil) resolves auto to the device tier")
restore()
-- ------------------------------------------------------------------- cycle
T.eq(Performance.cycle("auto", 1), "high", "cycle auto -> high")
T.eq(Performance.cycle("high", 1), "balanced", "cycle high -> balanced")
T.eq(Performance.cycle("balanced", 1), "low", "cycle balanced -> low")
T.eq(Performance.cycle("low", 1), "auto", "cycle low wraps to auto")
T.eq(Performance.cycle("auto", -1), "low", "cycle back auto -> low")
T.eq(Performance.cycle("high", -1), "auto", "cycle back high -> auto")
T.finish("performance_tiers")
+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")
+1 -1
View File
@@ -1 +1 @@
9820543f7a223fab
7de3051a7b3c108e
+1 -1
View File
@@ -1 +1 @@
54a52ea81751495c
227fd737aba15763
+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"' }),
+16 -14
View File
@@ -282,7 +282,8 @@ local function optGame()
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter", "colors",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
@@ -320,35 +321,36 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows
-- ZOOM / VOID FILL rows (indices shifted +1 by the PERFORMANCE row spliced
-- in ahead of COLORS)
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[12].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[12].step(om.game, 1)
check(om.rows[13].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[13].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[15].value(om.game) == "60",
check(om.rows[16].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[15].step(om.game, 1)
om.rows[16].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[15].value(om.game) == "75", "the MAX FPS row renders the cap")
check(om.rows[16].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[15].step(om.game, 1)
om.rows[16].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[15].step(om.game, -1)
om.rows[16].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
@@ -378,7 +380,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[17].activate(mgGame)
om.rows[18].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -388,7 +390,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[18].activate(cbGame)
om.rows[19].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+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()
+130
View File
@@ -0,0 +1,130 @@
-- Regression (#518): without a BICYCLE, the Route 16/18 gate guard must
-- still shove the player one tile aside after refusing them, not leave
-- them parked against the counter.
--
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript (scripts/
-- Route16Gate1F.asm, Route18Gate1F.asm) print the refusal text, then
-- simulate one PAD_RIGHT step (StartSimulatingJoypadStates) and only clear
-- wJoyIgnore once that step lands. data/scripts/story5.lua's bikeGateGuard
-- walked the player up to the counter and stopped there -- the ported
-- refusal text played, but nothing ever moved the player off the doorway.
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 and Data.maps.ROUTE_16_GATE_1F) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity bike gate shove (#518)")
local check, eq = S.check, S.eq
-- ground truth: data/generated/maps.lua ROUTE_16_GATE_1F.objects, the guard
-- sits at (4,5) behind a solid counter row (row 6); rows 7-10 south of it
-- are the open waiting area bikeGateGuard's coords cover.
local r16 = MapLoader.load(Data, "ROUTE_16_GATE_1F")
check(r16:isWalkableCell(4, 7), "the counter-adjacent stop cell (4,7) is walkable")
check(r16:isWalkableCell(5, 7), "the shove target (5,7) is walkable")
check(not r16:isWalkableCell(4, 6), "row 6 (the counter) blocks straight through")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
-- stub TextBox the way parity_A.lua drives gym leader text: a bare table
-- capturing (text, onDone) so the test can dismiss it without pagination.
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, onDone = done } end,
}
local mapScripts = require("data.scripts.init")
local hooks = mapScripts.get("ROUTE_16_GATE_1F")
check(hooks and hooks.onStep, "ROUTE_16_GATE_1F has an onStep hook")
-- one 16-frame step per fixed update, same as parity_ledge_seam_hop.lua
local function runFrames(ow, n)
for _ = 1, n do
ow:updateScriptMoves()
ow.player:update()
end
end
-- dismiss the top stubbed box the way TextBox:update does: pop, then onDone
local function dismissTop()
local box = Game.stack:pop()
if box.onDone then box.onDone() end
return box
end
local function standNoBike(x, y, facing)
Game.save = SaveData.newGame()
Game.save.inventory.BICYCLE = nil
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_16_GATE_1F", x, y, facing or "up")
local ow = Game.stack:top()
ow.player.moving = false
return ow
end
-- --- approaching from three tiles out: walk-up then shove -----------------
do
local ow = standNoBike(4, 10)
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
check(handled, "onStep claims the coordinate trigger without a BICYCLE")
local box1 = Game.stack:top()
check(box1 ~= nil, "the wait-up refusal text box opened")
check(box1.text:find("Wait up", 1, true) ~= nil
or box1.text:find("Excuse me", 1, true) ~= nil,
"first box is the guard's stop line")
dismissTop()
local box2 = Game.stack:top()
check(box2 ~= nil, "the second (explanation) box opened")
eq(box2.text, Data.text._Route16Gate1FGuardNoPedestriansAllowedText,
"second box is the guard's no-pedestrians explanation, verbatim")
dismissTop()
check(#ow.scriptMoves > 0 or ow.player.moving,
"closing the explanation queues the walk-up-then-shove movement")
runFrames(ow, 200)
check(not ow.player.moving, "the scripted movement has settled")
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
eq(ow.player.cellX, 5, "the player ends one tile right of the counter (#518 shove)")
eq(ow.player.cellY, 7, "the player ends on the counter-adjacent row, not further")
end
-- --- already standing at the counter-adjacent row: shove with no walk-up --
do
local ow = standNoBike(4, 7)
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
check(handled, "onStep still claims it when already adjacent to the counter")
dismissTop() -- wait-up
dismissTop() -- explanation
runFrames(ow, 200)
check(not ow.player.moving, "the shove-only movement has settled")
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
eq(ow.player.cellX, 5, "still ends one tile right (#518 shove with dist=0)")
eq(ow.player.cellY, 7, "y is unchanged since there was no walk-up needed")
end
-- --- control: a BICYCLE in the bag lets the trigger pass through untouched
do
local ow = standNoBike(4, 10)
ow.player.moving = false
Game.save.inventory.BICYCLE = true
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
check(not handled, "with a BICYCLE, onStep does not intercept the step")
check(Game.stack:top() == ow, "no text box opened when riding a BICYCLE")
end
package.loaded["src.render.TextBox"] = realTB
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()
+72
View File
@@ -0,0 +1,72 @@
-- Parity test: Self-Destruct/Explosion against a mid-Fly/Dig
-- (semi-invulnerable) target still faints the user (#528).
--
-- EffectRegistry.runDamaging's target.invulnerable branch prints the miss
-- text and returned without ever calling record.onMiss, while the
-- accuracy-roll, type-immunity and floored-damage miss branches all did.
-- EXPLODE_EFFECT (src/battle/MoveEffects.lua) relies on onMiss to run
-- battle:selfDestruct(user); skipping it left the user at full HP.
--
-- pokered engine/battle/core.asm MoveHitTest: "bit INVULNERABLE, [hl] /
-- jp nz, .moveMissed" sets the same wMoveMissed as a failed accuracy roll,
-- and the shared miss handler runs the explode effect regardless of which
-- branch set it ("even if Explosion or Selfdestruct missed, its effect
-- still needs to be activated"). JUMP_KICK_EFFECT's onMiss filters on
-- reason == "accuracy", so a crash-damage kick must NOT also fire here.
--
-- Self-contained; run via `luajit tests/parity_explode_invulnerable.lua`.
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 explode invulnerable")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = require("src.core.SaveData").newGame()
-- Self-Destruct against a target mid-Fly (invulnerable, never-miss moves
-- aside): the move never rolls accuracy at all, it just hits the
-- invulnerable branch in EffectRegistry.runDamaging.
local function selfDestructVsInvulnerable(moveId)
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
local b = BattleState.newWild(Game, "PIDGEY", 20)
b.enemy.invulnerable = true -- mid-Fly/Dig, set by ChargeEffect on turn 1
local hpBefore = b.enemy.mon.hp
b:performMove(b.player, b.enemy, { id = moveId, pp = 5 })
return b, hpBefore
end
for _, moveId in ipairs({ "SELFDESTRUCT", "EXPLOSION" }) do
local b, enemyHpBefore = selfDestructVsInvulnerable(moveId)
eq(b.player.mon.hp, 0, moveId .. " vs an invulnerable target still faints the user")
eq(b.enemy.mon.hp, enemyHpBefore,
moveId .. " vs an invulnerable target deals the target no damage")
local sawMiss = false
for _, r in ipairs(b.queue) do
if r.text and r.text:find("attack missed", 1, true) then sawMiss = true end
end
check(sawMiss, moveId .. " still prints the miss text against an invulnerable target")
end
-- Companion: JUMP_KICK's onMiss must reject the "invulnerable" reason, so
-- a mid-Fly target does not also take Jump Kick's crash damage (its
-- onMiss guard only fires for reason == "accuracy").
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
local jk = BattleState.newWild(Game, "PIDGEY", 20)
jk.enemy.invulnerable = true
local jkHpBefore = jk.player.mon.hp
jk:performMove(jk.player, jk.enemy, { id = "JUMP_KICK", pp = 5 })
eq(jk.player.mon.hp, jkHpBefore,
"JUMP KICK vs an invulnerable target takes no crash damage (reason filter holds)")
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()
+102
View File
@@ -0,0 +1,102 @@
-- Parity test: the link fingerprint no longer hashes catchRate, so a
-- Yellow-shaped dataset that differs from Red/Blue only at
-- Dragonair/Dragonite's catch rate digests identical and the handshake
-- reports "full" (#511).
--
-- Real cross-version link play (two processes, Red vs Yellow) is outside
-- what this checkout can assert: it needs both a Red and a Yellow
-- data/generated/ cache and two live UDP peers. What IS assertable from
-- one process: (1) catchRate is excluded from the hashed species surface,
-- so editing only that field never moves the digest, and (2) a synthetic
-- Yellow stand-in built by copying the real Data and retuning just those
-- two species' catchRate (the only real R/B vs Yellow link-surface delta,
-- per data/pokemon/base_stats/dragonair.asm 45 vs 27 and dragonite.asm 45
-- vs 9) fingerprints identically and clears Handshake.checkCompat as
-- "full", not "subset".
--
-- A human still has to run the actual cross-version check: launch a Red
-- instance and a Yellow instance (POKEPORT_VERSION=red / =yellow,
-- distinct POKEPORT_IDENTITY sandboxes), host/join on localhost, and
-- confirm no "game data is not the same" notice appears and a trade goes
-- through both ways.
--
-- Self-contained; run via `luajit tests/parity_link_fingerprint_yellow.lua`.
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 link fingerprint yellow")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local function copy(record)
local out = {}
for k, v in pairs(record) do out[k] = v end
return out
end
local function cloneData(base)
local out = { pokemon = {}, moves = {}, type_chart = base.type_chart,
constants = base.constants }
for id, record in pairs(base.pokemon) do out.pokemon[id] = record end
for id, record in pairs(base.moves) do out.moves[id] = record end
return out
end
-- (1) catchRate alone must not move the digest: this is the field
-- allowlist change at src/link/Fingerprint.lua SPECIES_FIELDS.
local vanilla = cloneData(Data)
local first = Fingerprint.compute(vanilla, {})
local rateOnly = cloneData(Data)
local pidgeyRate = copy(Data.pokemon.PIDGEY)
pidgeyRate.catchRate = (pidgeyRate.catchRate or 0) + 1
rateOnly.pokemon.PIDGEY = pidgeyRate
eq(Fingerprint.compute(rateOnly, {}), first,
"a catchRate-only edit does not move the link fingerprint")
-- (2) a Yellow stand-in: same records, Dragonair/Dragonite catch rate
-- retuned to Yellow's values (the only real R/B vs Yellow link delta).
Fingerprint.forget(vanilla)
local yellowish = cloneData(Data)
if Data.pokemon.DRAGONAIR then
local dragonair = copy(Data.pokemon.DRAGONAIR)
dragonair.catchRate = 27 -- pokeyellow data/pokemon/base_stats/dragonair.asm
yellowish.pokemon.DRAGONAIR = dragonair
end
if Data.pokemon.DRAGONITE then
local dragonite = copy(Data.pokemon.DRAGONITE)
dragonite.catchRate = 9 -- pokeyellow data/pokemon/base_stats/dragonite.asm
yellowish.pokemon.DRAGONITE = dragonite
end
check(Data.pokemon.DRAGONAIR ~= nil and Data.pokemon.DRAGONITE ~= nil,
"fixture has Dragonair and Dragonite to retune")
local yellowPrint = Fingerprint.compute(yellowish, {})
eq(yellowPrint, first,
"a Yellow-shaped dataset (only Dragonair/Dragonite catch rate differs) " ..
"fingerprints identically to Red/Blue")
-- (3) the handshake actually reads "full" for that pairing, not "subset"
local redHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = first }
local yellowHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = yellowPrint }
local verdict, reason = Handshake.checkCompat(redHello, yellowHello)
eq(verdict, "full", "Red vs Yellow-shaped peer clears the handshake as full")
check(reason == nil, "a full verdict carries no mismatch reason")
-- control: a real gameplay-affecting edit (base stats) still splits the
-- two builds, so the fingerprint has not gone toothless
local buffed = cloneData(Data)
local strongPidgey = copy(Data.pokemon.PIDGEY)
strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats)
strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1
buffed.pokemon.PIDGEY = strongPidgey
local buffedPrint = Fingerprint.compute(buffed, {})
check(buffedPrint ~= first, "a baseStats edit still moves the digest")
local buffedHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = buffedPrint }
local verdict2 = Handshake.checkCompat(redHello, buffedHello)
eq(verdict2, "subset", "a genuinely different dataset still reads subset, not full")
S.finish()
+51 -12
View File
@@ -6,17 +6,25 @@
-- is nonzero ("the player sprite has not yet completed the walking
-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with
-- it the START check, the A check, and every direction initiation, only
-- ever runs while the player stands on a tile. A button pressed mid-step
-- is simply never seen.
-- ever runs while the player stands on a tile.
--
-- The port ran handleInput() every frame regardless of player.moving, so a
-- mid-step A/START press pushed its TextBox/StartMenu right there and
-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy
-- and mashing A stops him half off the tile).
--
-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is
-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an
-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput
-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN
-- for the whole animation. A button pressed mid-step and still held when
-- the step lands therefore reads as a fresh press at the next poll; one
-- released before the step lands is genuinely lost. The port used to drop
-- both, which on the Cycling Road roll made START a coin flip (#525).
--
-- The invariant: while a step is in progress, A and START change nothing
-- (no TextBox, no StartMenu, the step completes); once the player stands
-- on the tile again, both work.
-- (no TextBox, no StartMenu, the step completes). On the landing frame a
-- still-held A or START is acted on, a released one is not.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
@@ -52,6 +60,15 @@ local function step(pressedBtn)
ow:update(1 / 60)
end
-- A synthetic pressQueue inject has no source entry, so Input:step sets
-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the
-- harness models a HELD button. Most cases below want a tap, so release it
-- explicitly; the held cases are called out where they matter.
local function tap(btn)
step(btn)
Input.state[btn] = false
end
-- start a step south (held direction, like hJoyHeld)
Input.state.down = true
step()
@@ -67,14 +84,14 @@ ow.interact = function(self, ...)
return baseInteract(self, ...)
end
-- mid-step A press: nothing may happen (the original never sees it)
step("a")
-- mid-step A press: nothing may happen (the original acts on nothing here)
tap("a")
eq(interactCalls, 0, "mid-step A never reaches interact()")
check(Game.stack:top() == ow, "mid-step A pushes no TextBox")
check(ow.player.moving, "mid-step A does not interrupt the step")
-- mid-step START press: no start menu either
step("start")
tap("start")
check(Game.stack:top() == ow, "mid-step START opens no menu")
check(ow.player.moving, "mid-step START does not interrupt the step")
@@ -84,7 +101,10 @@ while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(ow.player.cellY, startY + 1, "the step completes onto the next tile")
-- the issue's actual repro ("press A quickly/early" running up to Nurse
-- Joy): start another step and press A on its FINAL mid-step frame
-- Joy): start another step and press A on its FINAL mid-step frame, then
-- RELEASE it before the step lands. hJoyLast is frozen through the
-- animation, so the next poll sees the button already up and computes no
-- edge (engine/joypad.asm) -- this press really is lost.
Input.state.down = true
step()
Input.state.down = false
@@ -93,17 +113,36 @@ guard = 0
while ow.player.moving and guard < 60 do
guard = guard + 1
if guard == (ow.player.stepFramesCur or 16) - 1 then
step("a") -- the last frame before landing
tap("a") -- the last frame before landing, released immediately
else
step()
end
end
check(not ow.player.moving, "the second step completes")
eq(interactCalls, 0, "a last-frame A press is still swallowed (no buffering)")
check(Game.stack:top() == ow, "last-frame A pushes no TextBox")
step() -- the landing frame, where a still-held button would be polled
eq(interactCalls, 0, "a mid-step A released before landing is still lost")
check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox")
-- ...but a mid-step A that is STILL HELD when the step lands is delivered
-- on the landing frame, because hJoyLast never advanced (#525). Nothing
-- happens mid-step either way: the poll is deferred, not the action.
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "third step starts")
step("a") -- pressed mid-step and left held
eq(interactCalls, 0, "the held A still does nothing mid-step")
check(ow.player.moving, "the held A does not interrupt the step")
guard = 0
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(interactCalls, 0, "still nothing while the step runs out")
step() -- landing frame
eq(interactCalls, 1, "a held mid-step A is polled on the landing frame")
Input.state.a = false
-- standing on the tile again, START and A work as always
step("start")
interactCalls = 0
tap("start")
check(Game.stack:top() ~= ow, "START opens the start menu on a tile")
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
+11 -1
View File
@@ -176,7 +176,14 @@ for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
end
-- run the rows through the real interpreter with only the leaf commands
-- stubbed, so the branch arithmetic is what is under test
-- stubbed, so the branch arithmetic is what is under test. Commands is a
-- shared module singleton read by every later suite dofile'd into this
-- same process (tests/run_tests.lua), so the three stubs must be restored
-- before this file falls off the end -- an unrestored give_item stub
-- silently swallows any later test's item grants, matching the save/
-- restore parity_gift_atomicity.lua already does for show_text.
local origFacePlayer, origShowText, origGiveItem =
Commands.face_player, Commands.show_text, Commands.give_item
local shown, given
Commands.face_player = function() end
Commands.show_text = function(_, key) shown[#shown + 1] = key end
@@ -214,4 +221,7 @@ do
"an unbeaten-Giovanni save still gets the thank-you and the ball")
end
Commands.face_player, Commands.show_text, Commands.give_item =
origFacePlayer, origShowText, origGiveItem
S.finish()
+46
View File
@@ -0,0 +1,46 @@
-- Parity test: all three fishing rods are refused while surfing (#533).
--
-- pokered's FishingInit (engine/items/item_effects.asm) reads
-- wWalkBikeSurfState after IsNextTileShoreOrWater passes, and refuses
-- with carry set when it equals 2 (surfing). Every ItemUseXRod entry does
-- `jp c, ItemUseNotTime` on that carry, so a surfing player gets the same
-- "OAK: %s! This isn't the time to use that!" text used for the
-- mid-battle refusal, and the rod is never consumed. The port's ow.player
-- surf flag was never checked, so BagMenu's isWaterCell check alone let a
-- surfing player fish (the faced cell while surfing is always water).
--
-- Self-contained; run via `luajit tests/parity_surf_rod_refusal.lua`.
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 surf rod refusal")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local ItemEffects = require("src.inventory.ItemEffects")
local save = require("src.core.SaveData").newGame()
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
-- surfing: refused even though ow/player exists and the faced cell
-- would otherwise be fishable water
local surfingOw = { player = { surfing = true } }
local result, msgs = ItemEffects.use(Data, save, rod, nil, false, nil, surfingOw)
eq(result, "failed", rod .. " is refused while surfing")
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
rod .. " surfing refusal uses the OAK 'not the time' text")
eq(save.inventory[rod], nil, rod .. " is not consumed while surfing")
-- not surfing: still allowed to fish
local groundOw = { player = { surfing = false } }
local r2 = ItemEffects.use(Data, save, rod, nil, false, nil, groundOw)
eq(r2, "fish", rod .. " still fishes normally when not surfing")
-- no overworld handle at all (e.g. called from a context without ow):
-- must not error, and must not silently allow fishing that a surfing
-- player would be refused
local r3 = ItemEffects.use(Data, save, rod, nil, false, nil, nil)
eq(r3, "fish", rod .. " with no ow handle falls through to fish (no surf info available)")
end
S.finish()
+93
View File
@@ -0,0 +1,93 @@
-- Regression (#536): a save made mid-surf must resume mid-surf.
--
-- wWalkBikeSurfState (ram/wram.asm) sits inside wMainDataStart..wMainDataEnd,
-- the range engine/menus/save.asm block-copies into sMainData on save and
-- back out on load, so the original persists the surf state across a save.
-- OverworldState:captureSave now writes save.player.surfing and
-- OverworldState:setMap's boot path (opts.via == "boot", only taken from
-- :enter, which every StateStack push runs through) restores it before
-- Music/PikachuFollower see the player. Before the fix, self.player.surfing
-- was never serialized at all, so a reload always came back on foot;
-- Collision.canMove (src/world/Collision.lua) then picks land tile-pairs for
-- a non-surfing mover, which never permit standing on a water cell, so the
-- reload could softlock on the water tile the save was made on.
--
-- Self-contained; run via `luajit tests/parity_surf_save_load_bug536.lua`.
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 and Data.maps.ROUTE_20) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity surf save/load (#536)")
local check, eq = S.check, S.eq
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
-- ground truth from parity_cinnabar_east_surf.lua: ROUTE_20 (0,8) is water.
local r20 = require("src.world.MapLoader").load(Data, "ROUTE_20")
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water")
-- --- a save made mid-surf on a water cell resumes surfing --------------
Game.save = SaveData.newGame()
Game.save.player.surfing = true
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
local ow = Game.stack:top()
eq(ow.player.surfing, true, "boot onto a water cell with surfing=true restores surfing")
-- sprite/movement mode: Player:pose() (src/world/Player.lua) only ever
-- selects surfSprite when self.surfing is truthy, so this is the same
-- switch that put the walking sheet back on a surfing player pre-fix
local sprite = ow.player:pose()
check(sprite == ow.player.surfSprite,
"restored surfing selects the surf sprite sheet, not the walking one")
-- --- a save made on foot on land resumes on foot ------------------------
Game.save = SaveData.newGame()
Game.save.player.surfing = false
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
ow = Game.stack:top()
eq(ow.player.surfing, false, "boot onto land with surfing=false stays on foot")
-- --- saves written before #536 (no surfing field at all) default safely -
Game.save = SaveData.newGame()
Game.save.player.surfing = nil
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
ow = Game.stack:top()
check(not ow.player.surfing, "a pre-#536 save with no surfing field boots on foot, not truthy-nil")
-- --- the true round trip: captureSave -> a fresh boot reads it back back ---
Game.save = SaveData.newGame()
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
ow = Game.stack:top()
ow.player.surfing = true -- player mounted SURF while already standing on water
local captured = SaveData.newGame()
ow:captureSave(captured)
eq(captured.player.map, "ROUTE_20", "captureSave records the map")
eq(captured.player.x, 0, "captureSave records x")
eq(captured.player.y, 8, "captureSave records y")
eq(captured.player.surfing, true, "captureSave records surfing=true")
-- reload from that captured save: a brand-new boot must come back surfing
Game.save = captured
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, captured.player.map, captured.player.x, captured.player.y,
captured.player.facing)
ow = Game.stack:top()
eq(ow.player.surfing, true,
"round trip: captureSave -> reboot restores surfing on the same water cell")
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()
@@ -0,0 +1,171 @@
-- Regression (#503): the Viridian School House blackboard and notebook
-- were both dead A presses -- data/events/hidden_events.asm's two
-- hidden_text_predef rows for this map never reached data/generated/
-- field.lua (tools/extract/field.py only parses `hidden_event` rows), and
-- there was no onInteract hook for this map at all. Same shape as the
-- Celadon Mansion roof house fix (#391, tests/parity_celadon_roof_
-- readables.lua): ViridianSchoolBlackboard (engine/events/hidden_events/
-- school_blackboard.asm) prints the intro, then loops a "which heading"
-- prompt and a 5-status menu (SLP/PSN/PAR/BRN/FRZ) plus QUIT until B/QUIT
-- closes it; ViridianSchoolNotebook (school_notebooks.asm) is a 5-page
-- book that asks to turn pages 1-3, auto-turns 4, and the girl catches you
-- on page 5.
--
-- Self-contained; run via
-- `luajit tests/parity_viridian_school_blackboard_bug503.lua`.
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 and Data.maps.VIRIDIAN_SCHOOL_HOUSE) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
local S = require("tests.harness").suite("parity Viridian School blackboard/notebook (#503)")
local check, eq = S.check, S.eq
local MAP = "VIRIDIAN_SCHOOL_HOUSE"
for _, key in ipairs({
"_ViridianSchoolBlackboardText1", "_ViridianSchoolBlackboardText2",
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
"_ViridianBlackboardFrozenText",
"_ViridianSchoolNotebookText1", "_ViridianSchoolNotebookText5",
"_TurnPageText" }) do
check(type(Data.text[key]) == "string" and Data.text[key] ~= "",
key .. " is extracted")
end
local hooks = MapScripts.get(MAP)
check(hooks and type(hooks.onInteract) == "function",
MAP .. " registers onInteract for the two readables")
local stack = {}
local game = {
data = Data,
save = { player = { name = "RED" } },
stack = {
push = function(_, state) stack[#stack + 1] = state end,
pop = function() return table.remove(stack) end,
top = function() return stack[#stack] end,
},
}
local ow = { player = { facing = "up" } }
local function pages(box)
local out = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, "\n")
end
-- cells that are neither the blackboard (3,0) nor the notebook (3,4) stay silent
for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do
eq(hooks.onInteract(game, ow, cell[1], cell[2]), false,
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
end
-- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) ===
stack = {}
eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed")
local intro = stack[#stack]
check(getmetatable(intro) == TextBox, "the blackboard opens a TextBox")
check(pages(intro):find("STATUS", 1, true) ~= nil,
"the intro is _ViridianSchoolBlackboardText1 (describes STATUS changes)")
check(type(intro.onDone) == "function", "the intro has a continuation")
intro.onDone()
local prompt = stack[#stack]
check(getmetatable(prompt) == TextBox
and pages(prompt):find("heading", 1, true) ~= nil,
"the intro leads into the which-heading prompt")
prompt.onDone()
local menu = stack[#stack]
check(getmetatable(menu) == Menu, "the prompt opens the status menu")
eq(#menu.items, 6, "five statuses plus QUIT")
local labels = {}
for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end
eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT",
"the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last")
eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it")
local STATUS_KEYS = {
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
"_ViridianBlackboardFrozenText",
}
for i, key in ipairs(STATUS_KEYS) do
stack = {}
menu.items[i].onSelect()
local blurb = stack[#stack]
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
local want = Data.text[key]:match("^[^\n\011\012]+")
check(pages(blurb):find(want, 1, true) ~= nil,
labels[i] .. " prints " .. key)
check(type(blurb.onDone) == "function",
labels[i] .. " returns to the prompt instead of dropping out (loops)")
blurb.onDone()
check(getmetatable(stack[#stack]) == TextBox,
"the prompt comes back after " .. labels[i])
stack[#stack].onDone()
check(getmetatable(stack[#stack]) == Menu,
"the menu comes back after " .. labels[i] .. " (loop, not exit)")
end
-- === the notebook: five pages, three yes/no turns, then the girl catches you ==
stack = {}
eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed")
local page1 = stack[#stack]
check(getmetatable(page1) == TextBox, "the notebook opens on page 1")
check(pages(page1):find("POKé BALL", 1, true) ~= nil,
"page 1 is _ViridianSchoolNotebookText1")
-- pages 1-3 ask "Turn the page?" and only turn on yes. The real ChoiceBox
-- pops the asking TextBox itself before invoking .choice (TextBox.lua:228-
-- 235); mirror that pop-then-call order here.
local function resolveChoice(box, yes)
table.remove(stack) -- the ChoiceBox pops the asking TextBox first
box.choice(yes)
end
page1.onDone()
local ask1 = stack[#stack]
check(getmetatable(ask1) == TextBox and ask1.choice ~= nil,
"page 1 asks _TurnPageText with a yes/no choice")
resolveChoice(ask1, true)
local page2 = stack[#stack]
check(pages(page2):find("weaken", 1, true) ~= nil, "yes turns to page 2")
page2.onDone()
resolveChoice(stack[#stack], true)
local page3 = stack[#stack]
check(pages(page3):find("engage", 1, true) ~= nil, "page 3 follows page 2")
page3.onDone()
resolveChoice(stack[#stack], true)
local page4 = stack[#stack]
check(pages(page4):find("ELITE FOUR", 1, true) ~= nil, "page 4 follows page 3")
-- page 4 turns automatically into page 5, no prompt
page4.onDone()
local page5 = stack[#stack]
check(getmetatable(page5) == TextBox, "page 4 auto-turns")
check(pages(page5):find("GIRL", 1, true) ~= nil,
"page 5 is the girl catching you reading her notes")
-- saying no on page 1 stops the read right there: no further page pushed
stack = {}
hooks.onInteract(game, ow, 3, 4)
stack[#stack].onDone()
local askAgain = stack[#stack]
local before = #stack
resolveChoice(askAgain, false)
eq(#stack, before - 1,
"saying no to the first turn pops the ask box and pushes nothing else")
S.finish()
+97
View File
@@ -0,0 +1,97 @@
-- Regression (#535): after handing over the GOLD TEETH and receiving
-- HM04, every later talk to the Warden must still say something.
--
-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the
-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump
-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran
-- straight past the end of the row list with zero show_text calls -- the
-- Warden went mute on every visit after the trade. pokered's .got_item
-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText
-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in
-- SAFARI ZONE") on every subsequent talk.
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 and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity wardens house (#535)")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
require("src.render.Font").load(Data)
local story = require("data.scripts.story")
local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
-- instrument show_text the way parity_gift_atomicity.lua does, to record
-- exactly which text ids actually printed
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
end
local function runScript()
shown = {}
StateStack:init()
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag ===
Game.save = SaveData.newGame()
Game.save.inventory.GOLD_TEETH = 1
check(runScript(), "give-the-teeth talk completes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText,"
.. "_WardensHouseWardenReceivedHM04Text",
"handing over the teeth shows the give/thanks/received sequence, nothing after")
check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set")
check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set")
check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag")
check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken")
-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is
-- set, must print the explanation text instead of nothing ===
check(runScript(), "post-gift talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"every subsequent talk now prints the HM04/Safari Zone explanation (#535)")
-- run it again to confirm this is not a one-shot: it repeats every visit
check(runScript(), "a third talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"the explanation text repeats on every later talk, not just the first")
-- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet ===
Game.save = SaveData.newGame()
check(runScript(), "empty-handed talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text",
"without the GOLD TEETH the Warden's gibberish line is unchanged")
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
Commands.show_text = origShow
S.finish()
+141
View File
@@ -0,0 +1,141 @@
-- #553: "App makes user import both game and/or mods twice before installing."
--
-- Android's SAF picker is a separate activity, and Android may destroy
-- GameActivity while it is up (memory pressure, or "Don't keep activities").
-- When that happens the app RESTARTS rather than resuming, so the
-- love.focus(true) that RomImporter:focus consumes a pick on never arrives.
-- GameActivity has already written picked_rom.gb / picked_mod.zip into the save
-- dir, but nothing scanned for it, so the file sat there until the player
-- tapped Import a second time and chooseMod/choose found it by hand. Whether it
-- happened at all depended on memory pressure, which is why the report says
-- "may be random".
--
-- _pollPickedFiles was the fix that already existed, gated to iOS. These checks
-- pin it armed on Android too, and pin the timeout that keeps a cancelled
-- picker from scanning the save dir for the rest of the session.
--
-- Self-contained: `luajit tests/rom_importer_double_pick_test.lua`; also
-- dofile'd by tests/run_tests.lua.
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("rom importer double pick (#553)")
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
getDirectoryItems = love.filesystem.getDirectoryItems,
getInfo = love.filesystem.getInfo,
read = love.filesystem.read,
remove = love.filesystem.remove,
}
-- A fake save dir we can drop a delivered pick into.
local saveDir = {}
love.filesystem.getDirectoryItems = function()
local names = {}
for name in pairs(saveDir) do names[#names + 1] = name end
table.sort(names)
return names
end
love.filesystem.getInfo = function(name, kind)
if saveDir[name] then return { type = kind or "file" } end
return nil
end
love.filesystem.read = function(name) return saveDir[name] end
love.filesystem.remove = function(name) saveDir[name] = nil; return true end
local function importer(os)
love.system.getOS = function() return os end
local ri = RomImporter.new(function() end, { launcher = true })
ri.ready = { red = false, blue = false, yellow = false }
return ri
end
-- 1. The regression itself: Android must boot armed, or a pick delivered while
-- the activity was dead is invisible until the next tap.
local android = importer("Android")
check(android.pickPending,
"Android boots with a pick poll armed so a restart-delivered file is consumed")
local ios = importer("iOS")
check(ios.pickPending, "iOS still boots armed (unchanged by this fix)")
love.system.getOS = function() return "OS X" end
local desktop = RomImporter.new(function() end, { launcher = true })
check(not desktop.pickPending, "desktop does not poll: it has no save-dir picks")
-- 2. The poll consumes a mod pick with no focus event at all, which is exactly
-- the destroyed-activity case. Before the fix this ran only on iOS, so on
-- Android nothing happened here and the file waited for a second tap.
local ri = importer("Android")
local consumed = false
ri.focus = function(self, f) if f then consumed = true end end
saveDir["picked_mod.zip"] = "PK\003\004 pretend mod"
ri:_pollPickedFiles(0.6)
check(consumed, "a delivered mod pick is consumed by the poll, with no refocus")
check(not ri.pickPending, "and the poll disarms once it has fired")
-- 3. A ROM pick goes the same way.
local ri2 = importer("Android")
local consumed2 = false
ri2.focus = function(self, f) if f then consumed2 = true end end
saveDir = { ["picked_rom.gb"] = "not a real cart" }
ri2:_pollPickedFiles(0.6)
check(consumed2, "a delivered ROM pick is consumed by the poll too")
-- 4. Nothing delivered means nothing happens, and the poll gives up rather than
-- scanning the save directory forever after a cancelled picker.
saveDir = {}
local ri3 = importer("Android")
local fired = false
ri3.focus = function(self, f) if f then fired = true end end
ri3:_pollPickedFiles(0.6)
check(not fired, "an empty save dir consumes nothing")
check(ri3.pickPending, "and stays armed while it is still within the timeout")
ri3:_pollPickedFiles(200)
check(not ri3.pickPending, "a cancelled pick disarms instead of polling forever")
-- 5. A pick that is still importing must not be double-started.
saveDir = { ["picked_mod.zip"] = "PK\003\004" }
local ri4 = importer("Android")
local fired4 = false
ri4.focus = function(self, f) if f then fired4 = true end end
ri4.workState = "working"
ri4:_pollPickedFiles(0.6)
check(not fired4, "the poll stands down while an import is already running")
-- 6. THE ACTUAL #553 CAUSE lives in main.lua, not here. On Android
-- love.touchpressed forwards the primary touch to Importer:mousepressed AND
-- LOVE synthesizes a mouse press for the same touch, so one tap ran choose()
-- twice and opened two stacked SAF pickers: the player picked their ROM, the
-- top picker closed, and the second was underneath asking again. main.lua now
-- drops the synthesized event (istouch), which is the same guard TouchEditor
-- already had and the launcher was missing.
--
-- Deliberately NOT deduped here: tests/engine/save_import_retry_bug420.lua
-- and rom_pick_error_bug442.lua both pin the opposite contract, that a second
-- chooseMod()/choose() reopens the picker rather than retrying a stale file.
-- Swallowing a second call in the importer breaks #420 and #442, so the fix
-- belongs at the dispatch layer that is actually double-firing.
saveDir = {}
local picks = 0
love.system.pickFile = function() picks = picks + 1; return true end
local contract = importer("Android")
contract:choose("red")
contract:choose("red")
check(picks == 2,
"choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.filesystem.getDirectoryItems = saved.getDirectoryItems
love.filesystem.getInfo = saved.getInfo
love.filesystem.read = saved.read
love.filesystem.remove = saved.remove
S.finish()
+97
View File
@@ -0,0 +1,97 @@
-- #482: pressing Import ROM crashed on iOS with
--
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
--
-- love.system.pickFile is a native bridge, not part of LÖVE, so it is absent
-- on any mobile build that did not compile one. The mobile path called it
-- unguarded, so a missing bridge took the app down instead of falling back to
-- the copy-into-the-save-folder flow each caller already had for a device with
-- no document picker.
--
-- Self-contained: `luajit tests/rom_importer_no_picker_test.lua`; also
-- dofile'd by tests/run_tests.lua.
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("rom importer without a picker")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
getSaveDirectory = love.filesystem.getSaveDirectory,
}
love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end
love.system.getOS = function() return "iOS" end
-- the condition the crash reports were in: a build with no native bridge
love.system.pickFile = nil
local function freshImporter()
return setmetatable({
android = true, -- RomImporter treats iOS as the mobile path
workState = nil,
ready = { red = false, blue = false, yellow = false },
notice = nil,
modNotice = nil,
saveNotice = {},
chooseVersion = nil,
startData = function(self, data, displayName)
self._started = { data = data, name = displayName }
end,
_installMod = function(self, name) self._mod = name end,
_importSave = function(self, version, name) self._save = name end,
}, RomImporter)
end
-- ------- Import ROM
local ri = freshImporter()
local ok, err = pcall(function() ri:choose("red") end)
check(ok, "Import ROM does not crash without a picker: " .. tostring(err))
check(ri.notice ~= nil, "and it explains itself instead of doing nothing")
eq(ri.notice.detail, "/tmp/pokemon-love2d",
"pointing at the folder to copy the ROM into")
check(not ri.pickPending, "with no pick left pending on a picker that never opened")
-- Yellow takes the same path: the crash was never version-specific.
ri = freshImporter()
ok = pcall(function() ri:choose("yellow") end)
check(ok, "Import ROM for Yellow does not crash either")
-- ------- Import mod .zip
ri = freshImporter()
ok, err = pcall(function() ri:chooseMod() end)
check(ok, "Import mod does not crash without a picker: " .. tostring(err))
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
"and reports that the picker could not open")
-- ------- Import save
ri = freshImporter()
ok, err = pcall(function() ri:chooseSaveImport("red") end)
check(ok, "Import save does not crash without a picker: " .. tostring(err))
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.ok == false,
"and reports that the picker could not open")
eq(ri.androidPendingVersion, nil,
"leaving no pending import for a pick that never happened")
-- ------- and the picker is still used when the bridge IS there
local pickCalls = 0
love.system.pickFile = function() pickCalls = pickCalls + 1 return true end
ri = freshImporter()
ri:choose("red")
eq(pickCalls, 1, "a build WITH the bridge still opens the picker")
check(ri.pickPending, "and waits for the pick to come back")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.filesystem.getSaveDirectory = saved.getSaveDirectory
S.finish()
+100
View File
@@ -528,5 +528,105 @@ do
end
end
do
-- #515: badge read/write agreement between SaveData/the in-game grant
-- and the editor. checkVictoryRewards (src/world/OverworldController.lua)
-- writes save.inventory[badge] = 1, a truthy number, not the boolean
-- `true` the editor used to compare against with `== true`. This drives
-- the same field through a real save encode/decode round trip and checks
-- src/inventory/Badges.count (what the in-game badge case/count reads)
-- agrees with what the editor's own badge state shows.
local Badges = require("src.inventory.Badges")
local save = SaveData.newGame()
local id = Badges.list(Data)[1].id
-- simulate the in-game grant's exact representation, not the editor's
save.inventory[id] = 1
eq(Badges.count(Data, save), 1, "Badges.count sees a numeric 1 grant as earned")
local encoded = SaveData.encode(save)
local back = SaveData.decode(encoded)
eq(back.inventory[id], 1, "save round trip preserves the numeric badge flag")
eq(Badges.count(Data, back), 1, "Badges.count still agrees after the round trip")
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
S.save = back
check(Ops.badgeIds(S)[1] ~= nil, "the editor's badge catalog is non-empty")
check(S.save.inventory[id] and true or false,
"the editor's own truthy read (panel badge chip state) sees the grant as earned")
-- toggling off then on again round-trips through the editor's own write
-- shape and still agrees with Badges.count
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], nil, "toggleBadge clears the badge (nil, not false)")
eq(Badges.count(Data, S.save), 0, "Badges.count agrees once cleared")
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], 1, "toggleBadge re-earns the badge as a truthy 1, matching the in-game grant")
eq(Badges.count(Data, S.save), 1, "Badges.count agrees once re-earned")
end
do
-- #529: focusing a text field raises the OS soft keyboard on Android/iOS
-- (love.keyboard.setTextInput(true, x, y, w, h)) and blurring lowers it;
-- desktop raises the same way but never lowers, since setTextInput is
-- global SDL state and the launcher's own text fields (RomImporter slot
-- rename, ROM finder) depend on it staying enabled.
--
-- This is the closest honest check this checkout can run: the real soft
-- keyboard is OS chrome outside the LOVE frame, unreachable by any
-- driver. A human still has to verify on an Android build that the
-- keyboard visibly rises over the Items search bar, typed characters
-- filter the list, and Enter/Escape/switching tabs lowers it again.
local Kit = require("Kit")
local calls = {}
local savedKeyboard, savedSystem = love.keyboard, love.system
local function stubOS(name)
love.system = { getOS = function() return name end }
love.keyboard = {
isDown = function() return false end,
setTextInput = function(...) calls[#calls + 1] = { ... } end,
}
end
-- Android: focusing raises with the field's rect, restaying focused on
-- the same id does not re-raise, and blur lowers it.
stubOS("Android")
Kit.focus = nil
Kit.beginFrame(15, 15, true)
Kit.textfield("kb-test", 10, 10, 100, 20, "", "type here")
eq(#calls, 1, "Android: focusing a field raises the soft keyboard")
check(calls[1][1] == true, "Android: raise call passes enable=true")
eq(calls[1][2], 10, "Android: raise call passes the field's x")
eq(calls[1][3], 10, "Android: raise call passes the field's y")
eq(calls[1][4], 100, "Android: raise call passes the field's w")
eq(calls[1][5], 20, "Android: raise call passes the field's h")
Kit.beginFrame(15, 15, false)
Kit.textfield("kb-test", 10, 10, 100, 20, "abc", "type here")
eq(#calls, 1, "Android: staying focused on the same field does not re-raise")
Kit.blur()
eq(#calls, 2, "Android: blur lowers the soft keyboard")
eq(calls[2][1], false, "Android: lower call passes enable=false")
check(Kit.focus == nil, "blur clears Kit.focus")
-- Desktop: focusing still raises (harmless there), but blur must not
-- disable text input globally -- the launcher's own fields rely on it
-- staying on.
calls = {}
stubOS("Mac OS X")
Kit.beginFrame(65, 65, true)
Kit.textfield("kb-test2", 60, 60, 80, 24, "", "")
eq(#calls, 1, "desktop: focusing a field still raises setTextInput")
Kit.blur()
eq(#calls, 1, "desktop: blur does not call setTextInput(false)")
love.keyboard, love.system = savedKeyboard, savedSystem
Kit.focus = nil
end
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+78
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
@@ -3265,6 +3337,10 @@ runSuites({ "tests/rom_importer_android_pick_test.lua" })
-- ---------------------------------------------- Android mod / save SAF pick
runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- import with no picker (#482)
runSuites({ "tests/rom_importer_no_picker_test.lua" })
runSuites({ "tests/rom_importer_double_pick_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity
@@ -3274,6 +3350,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",
+3 -2
View File
@@ -230,13 +230,14 @@ do
end
do
-- badges are boolean flags on inventory, toggled not stacked
-- badges are truthy flags on inventory, toggled not stacked; the engine
-- writes them as 1 (#515), so the editor must too
local S = newState()
local ids = Ops.badgeIds(S)
check(#ids > 0, "the catalog exposes badge ids")
local id = ids[1]
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], true, "toggleBadge earns the badge")
eq(S.save.inventory[id], 1, "toggleBadge earns the badge")
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)")
end
+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
+32
View File
@@ -235,6 +235,37 @@ def _rebuild_yellow_sourced(yellow, pokeyellow):
}
def _remap_audio(yellow, yellow_symbols):
"""Re-anchor the Red-copied audio section on pokeyellow.sym (#522).
Yellow's music bank $1f shifts after 1f:4294: Music_YellowIntro is a
3-channel header (pokeyellow audio/headers/musicheaders3.asm) where
Red's Music_IntroBattle had 4, so Red's addresses for every later
header land on a channel-2 row (one voice, and no tempo command --
Viridian Forest slow and hollow). Yellow also has a single
wave-sample table at Audio1_WavePointers (audio.asm "Music 1"), not
Red's per-engine 0x4373 copies, and CryData moved to 0e:5462. Names
absent from the sym (Music_IntroBattle, which Yellow only knows as
Music_YellowIntro) keep their positional mapping.
"""
for name, header in yellow["audio"]["musicHeaders"].items():
symbol = yellow_symbols.by_name.get(name)
if symbol is not None:
header["bank"] = symbol.bank
header["address"] = symbol.address
wave = yellow_symbols.by_name.get("Audio1_WavePointers.wave0")
if wave is None:
raise SystemExit("pokeyellow.sym missing Audio1_WavePointers.wave0")
for engine in yellow["audio"]["waveBanks"]:
yellow["audio"]["waveBanks"][engine] = {
"bank": wave.bank, "address": wave.address,
}
cry = yellow_symbols.by_name.get("CryData")
if cry is None:
raise SystemExit("pokeyellow.sym missing CryData")
yellow["audio"]["cryData"] = {"bank": cry.bank, "address": cry.address}
def derive(red, pokeyellow, symbols_path):
"""Return the Yellow manifest derived from the Red manifest dict."""
yellow = copy.deepcopy(red)
@@ -261,6 +292,7 @@ def derive(red, pokeyellow, symbols_path):
yellow = _drop_strings(yellow, dropped)
rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow)
_remap_audio(yellow, yellow_symbols) # #522
for label in YELLOW_EXTRA_TEXT_LABELS:
if label not in yellow["text"]["labels"]:
+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)

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