Compare commits

...

357 Commits

Author SHA1 Message Date
bryanthaboi 59a383736c Merge pull request #804 from bryanthaboi/dev
NOW AVAILABLE ON XBOX LFG
2026-08-04 11:03:21 -04:00
bryanthaboi 1820f411ae Merge pull request #802 from caorthann-celt/dev
Add Xbox Dev Mode UWP support
2026-08-04 11:01:15 -04:00
Caorthann 8327942a9c Pin Xbox UWP builds to Visual Studio 2022 2026-08-04 15:51:55 +01:00
Caorthann aec7f6001a Fix love payload version stamping in CI 2026-08-04 15:48:48 +01:00
github-actions d377518632 chore(ios): update app-repo.json [skip ci] 2026-08-04 10:41:03 -04:00
Caorthann ad9b03fe56 Add Xbox UWP build workflow 2026-08-04 15:36:35 +01:00
Caorthann 3b490bc42f Make UWP dependencies self contained 2026-08-04 15:35:52 +01:00
Caorthann dacd9abe73 Add Xbox UWP port 2026-08-04 15:35:52 +01:00
bryanthaboi f74e21782b Merge pull request #798 from bryanthaboi/dev
perhaps a massive PR
2026-08-04 10:35:29 -04:00
bryanthaboi 8fbe819493 bingus dingus 2026-08-04 10:32:51 -04:00
Shane McGovern 626080d228 Show mod downloads and release dates in the launcher MODS and Find Mods panels (#793)
* Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them

A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.

* Fix crash opening the Find Mods tab: rename the stats cache field

The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.

* Fix Find Mods crash: require ModUpdate in the find panel

buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.

* Retry Find Mods stats after failed repo fetches

A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.

* Add the MODS tab sort options to the Find Mods tab
2026-08-04 10:16:44 -04:00
bryanthaboi f56e82de81 translate the launcher: load mod string catalogs before it draws (#794)
the launcher runs before Game:load, so #767's Strings hooks had nothing
filling the catalog and a restart could not help - the ordering is the same
every launch. reads lang/strings.lua from enabled mods only, data not the
entry chunk, sandboxed.

plus a plain pixel fallback on the ui faces, or the kana draw as tofu.
2026-08-04 10:15:10 -04:00
bryanthaboi 7d3950658a launcher: route the last raw literals through strings (#791)
finishes what #767 started (thanks @hernan0078). most of that pr landed
already when the launcher panels moved to LauncherView; these six sites were
what was left, and they sit next to siblings that already call Strings.

also pins the delete chip to the wider of Delete/Sure? instead of to Delete.
english happens to have the longer word first, a translation need not.
2026-08-04 09:50:51 -04:00
bryanthaboi 3c1246ee06 Merge pull request #787 from luisgonzaleznf/fix/love12-scissor-bias
Fix LÖVE 12 anchored UI scissor seam
2026-08-04 09:45:17 -04:00
bryanthaboi d69965e3b1 Merge pull request #790 from bryanthaboi/ttf-box-color
fix ttf text erased after a box, and type names outside battle
2026-08-04 09:44:46 -04:00
bryanthaboi 30edac03a6 fix ttf text erased after a box, and type names outside battle
drawBox left the color white, which tiles ignore but ttf text does not, so
every label after a box drew white on white.

typechart only loaded on entering a battle, so the summary screen got raw
type ids back instead of display names.
2026-08-04 09:43:01 -04:00
bryanthaboi dd4aface93 test fixes 2026-08-04 09:41:44 -04:00
bryanthaboi b1350fdecd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-04 09:25:30 -04:00
bryanthaboi f2d9b74490 CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782 2026-08-04 09:25:28 -04:00
luisgonzaleznf 84d09d0894 Fix LÖVE 12 scissor seam 2026-08-04 15:20:06 +02:00
bryanthaboi e61f231316 Merge pull request #789 from bryanthaboi/ttf-tiles
add ttf tiles option
2026-08-04 09:14:49 -04:00
bryanthaboi 33998aab5d add ttf tiles option
lets a translation keep chosen characters on the rom tiles instead of the ttf.
needed for japanese: sizing the font for kana makes latin narrower, which
knocks the party menu numbers out of line.
2026-08-04 09:11:05 -04:00
bryanthaboi 9ce7379946 Merge pull request #786 from ShaneMcGovernIE/find-mods-release-stats
Show feed-published release stats on Find Mods rows
2026-08-04 08:00:46 -04:00
Shane McGovern b692474e24 Show feed-published release stats on Find Mods rows
A mod index can now publish three optional per-entry fields -- downloads
(total across every release), first_release and last_release (ISO days) --
which the FIND MODS listing shows in the same gold line the MODS tab uses.

The fields are additive by design: feeds that carry them stay readable by
every build that predates them (schema_version stays 1), and feeds that do
not render exactly as before. ModUpdate.statsLine builds the shared line;
the MODS tab reuses it. Parser, formatting, and parse coverage are tested.
2026-08-04 12:37:28 +01:00
bryanthaboi 6cfc85ca83 icons update 2026-08-04 06:52:06 -04:00
github-actions 7b060c19f4 chore(ios): update app-repo.json [skip ci] 2026-08-04 06:50:19 -04:00
bryanthaboi 604b9338f9 Merge pull request #783 from bryanthaboi/dev
fixing some bugs and adding another tool to the translation toolbelt
2026-08-04 06:45:30 -04:00
bryanthaboi 3a6557ffe2 CLOSES #779 , CLOSES #743 + new font 2026-08-04 06:41:34 -04:00
bryanthaboi fe139ca7f4 CLOSES #691 maybe 2026-08-04 05:47:58 -04:00
bryanthaboi 5c617d3e32 Merge pull request #776 from vegerot/max/yellow-docs 2026-08-03 23:20:24 -04:00
Max 👨🏽‍💻 Coplan 8d11c04d43 fix(docs): add support for Blue and Yellow 2026-08-03 17:09:20 -07:00
github-actions 92f73d8bed chore(ios): update app-repo.json [skip ci] 2026-08-03 17:28:07 -04:00
bryanthaboi 8da51aa7ba Merge pull request #759 from jherediagu/fix/rom-text-statuses-items-evolve
Extend ROM-text messages to statuses, items and the learn/evolve flows
2026-08-03 17:23:34 -04:00
bryanthaboi 78f87111de Merge pull request #762 from ShaneMcGovernIE/launcher-mod-downloads
Show mod downloads and release dates in the launcher MODS panel
2026-08-03 17:23:26 -04:00
bryanthaboi e3fcdd0776 Merge pull request #761 from bryanthaboi/dev
bugs and switch
2026-08-03 17:22:17 -04:00
Shane McGovern 6ea59f1a79 Show mod downloads and release dates in the launcher MODS panel
The MODS tab now shows each installed mod's total GitHub downloads
(summed asset download_count across all releases), its first and latest
release dates, and a Sort row (Name / Popularity / Release date /
Last updated) persisted in options.modSort.

The launcher already fetched per-repo release lists for update checks, so
the data rides the same cached fetch: parseRelease keeps download_count
and published_at, writeCache persists them, and a cache entry written
before the fields existed is treated as stale and refetched once instead
of hiding the line behind an old cache.
2026-08-03 22:17:56 +01:00
bryanthaboi 51fb4db82e Merge origin/main into dev; resolve Game.lua gamepad speed-hotkey conflict 2026-08-03 17:17:26 -04:00
bryanthaboi 7f78de8718 Merge pull request #574 from andrewqsantos/feat/switch-nx
Nintendo Switch / love-nx support (#531)
2026-08-03 17:08:54 -04:00
bryanthaboi 76aab74bf1 potentially CLOSES #758 2026-08-03 17:08:42 -04:00
Juan Heredia a2bf08c6ff Extend ROM-text messages to statuses, items and the learn/evolve flows 2026-08-03 23:04:29 +02:00
bryanthaboi b831076839 CLOSES #592 2026-08-03 16:38:07 -04:00
bryanthaboi 45575a1fd8 Merge pull request #757 from Bortlesboat/fix/launcher-save-slot-overlap
Fix launcher save-slot panel overlap
2026-08-03 16:21:47 -04:00
bryanthaboi 56d92246cb commit o 2026-08-03 16:19:39 -04:00
Andrew Barnes 4a00f2335c Fix launcher save-slot panel overlap 2026-08-03 16:18:35 -04:00
github-actions e9cb830d71 chore(ios): update app-repo.json [skip ci] 2026-08-03 16:18:21 -04:00
bryanthaboi c2666d2b43 Merge pull request #756 from ShaneMcGovernIE/fix/boulder-wall-push
Stop boulders being pushed through walls (#754)
2026-08-03 16:14:42 -04:00
bryanthaboi 9ceec3070d Merge pull request #742 from ShaneMcGovernIE/speed-triggers
Cycle game speed from the analog triggers too (L2/R2)
2026-08-03 16:13:27 -04:00
bryanthaboi f89977a8e7 Merge pull request #747 from johnjohto/fix-fly-animation-702
Play the full Fly departure and landing animation (#702)
2026-08-03 16:11:40 -04:00
bryanthaboi 8d512b95cb Merge pull request #755 from castdrian/ios
fix(ios): support mod index networking and text input dismissal
2026-08-03 16:11:27 -04:00
Andrew Barnes 0914387c16 Fix launcher save-slot panel overlap 2026-08-03 15:53:49 -04:00
Andrew Quenehen 105672c132 fix(switch): restore pad cursor in Touch Controls and launcher overlays
Opening Touch Controls dropped the launcher virtual cursor and swallowed
gamepad input while touch still worked. Share PadCursor with the save
editor, forward pad events, and centralize overlay handoff/resume so both
hosts park and re-arm the pointer cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:46:48 -03:00
Andrew Quenehen 0dd6ecc219 fix(switch): widen Pikachu PCM to 16-bit stereo without Source channel probe
Source:getChannelCount could skip the #626 widen on love-nx, and keeping
8-bit depth into a stereo buffer still sounded wrong on audren. Decode the
file via newSoundData, always emit 16-bit stereo like ChipSynth, and write
fresh pika-cry WAVs as stereo at extract time so re-imports skip the hop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:35:18 -03:00
Andrew Quenehen 9ef5c8dead test(switch): cover widenMono newSoundData hop and run NX suites in T0
The Yellow boot suite only recorded newSource, so a missing sound.newSoundData
wrap (the silent hole that motivated the full-surface overlay) could still go
green. Force mono on the Source stub, record newSoundData, and assert the cry
re-read lands on yellow/. Also pin the three NX suites in scripts/test.sh T0
and fix the seed_chunk global leak in the fallback suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:20:45 -03:00
Andrew Quenehen 12ff4dba2e fix(switch): wrap the whole read-side love API surface in the NX overlay
The overlay only wrapped the five loaders the boot path needed, leaving a
silent-failure hole: any future state (or current code like Sound.lua's
widenMono, which re-reads the pika-cry WAV via love.sound.newSoundData
with the caller's bare path) could load a generated asset through an
unwrapped API and silently degrade on hardware.

NxAssetOverlay now wraps every read-side love function that accepts a
filesystem path (filesystem.read/load/lines/newFileData/getInfo,
graphics.newImage/newFont, image.newImageData, audio.newSource,
sound.newSoundData, font.newFontData), so new states and mods fall inside
the Blue/Yellow fallback with zero per-call-site work.  Write-side
functions stay stock, proven by identity assertions in the fallback
suite.  The static guard's forbidden-literal list covers the same APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:14:33 -03:00
Shane McGovern ff9992dff8 Stop boulders being pushed through walls (#754)
checkBoulderPush had an isWarpTileCell escape hatch that let a boulder
be pushed onto any door/warp tile, walkable or not. In pokered,
CheckForCollisionWhenPushingBoulder walks the same wTilesetCollisionPtr
list as player movement (CheckTilePassable) -- there is no hole/warp
exception, so a boulder can never land on a cell the player cannot walk
onto.

The known push targets (CAVERN  holes, Victory Road switches) are
walkable tiles in their tileset's coll list already, so removing the
escape hatch only stops pushing boulders into walls.

Fixes #754
2026-08-03 20:09:29 +01:00
Andrew Quenehen 98c08e4b22 test(switch): close the silent-regression gaps the Yellow NX bug exposed
Three layers, all running without a ROM:

- nx_yellow_boot_test.lua: drives the real Yellow and Blue boot states
  (TitleState, YellowIntro + IntroMovie pre-roll for Yellow, IntroMovie
  direct for Blue, Sound.playPikaCry) against a broken-mount filesystem
  where generated art exists only under yellow|blue/, and asserts no bare
  assets/generated path ever reaches the raw love loaders.  This is the
  runtime complement to the static literal guard: data-driven manifest
  paths and formatted paths (cry_%02d.wav) are exactly what a source scan
  cannot see.  love_stub gains Image:setFilter/getFilter so IntroMovie
  constructs headless.

- CI path gate: switch-changes now also triggers on the NX runtime
  (NxAssetOverlay, Platform, GameVersion, CacheFs) and the NX engine
  suites, so src-side NX regressions rebuild the fused NRO instead of
  slipping through with green headless-only checks.

- switch-selftest runs the three NX engine suites headlessly on the
  fork-safe ubuntu runner, giving PR feedback before the self-hosted Mac
  build.  The content gate and switch-build.md docs were updated in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:07:41 -03:00
Andrew Quenehen 0bd3b26acc Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:47:28 -03:00
Adrian Castro 59b55a8f37 fix(ios): support mod index networking and text input dismissal 2026-08-03 20:43:27 +02:00
Andrew Quenehen 67e6b1fb04 fix(switch): centralize the NX asset fallback in a boot-time loader overlay
The scattered per-call-site prefix rewrites were a parallel track that any
future newImage("assets/generated/...") would silently bypass.  Replace
them with NxAssetOverlay: installed once from love.load on NX only, it
wraps newImage / newImageData / newSource / filesystem.read / getInfo so a
missing assets/generated path falls back to the active version's
blue|yellow copy.  Call sites return to plain love loader calls, and
Assets.resolve goes back to being the platform-free mod-override point.

Two deliberate exceptions remain: the chip-audio worker (separate Lua
state) keeps receiving the prefix explicitly via audio.programPrefix, and
data/generated module loads keep using CacheFs.readActive.

A new guard test (tests/engine/nx_generated_guard_test.lua) fails CI on
any direct love loader call with a literal assets/generated path, so the
class of bug cannot regress by accident.  scripts/test.sh --quick is
green across all tiers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:42:55 -03:00
Andrew Quenehen 17243a7d8b fix(switch): route remaining generated-art loads through Assets.resolve
Five more places called love.graphics.newImage directly on
assets/generated paths, bypassing the NX prefix rewrite:

- TradeAnim: cable/ball/bubble art
- TownMap: Kanto background, cursor, nest icon
- SurfingMinigame: surf bg/ob sheets
- BattleState: party ball row, substitute doll

All now resolve through Assets.resolve, which maps to the versioned
blue/ or yellow/ save-dir prefix on NX only. Desktop and Android keep
the mountVersion overlay behavior unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:31:58 -03:00
Andrew Quenehen 16b6b98ede fix(switch): pass NX cache prefix to the chip-audio worker
Yellow music was still silent because the background worker thread loads
ChipSynth.lua in a fresh Lua state with no GameVersion/Platform context.
The main thread's prefix never reached it.

ChipAudio.slimAudio now resolves the versioned cache prefix on the main
thread and includes it in the audio payload as `programPrefix`.
ChipSynth.loadBanks prefers `audio.programPrefix` when present, falling
back to its own NX detection for the sync path. Blue and Yellow are
handled the same way.

Tests cover the worker prefix hand-off and Blue's programs.bin path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:26:37 -03:00
Andrew Quenehen e4ce1063a1 fix(switch): extend NX-only asset prefix to audio and title/intro art
The previous NX gate only rewrote image paths that go through Assets.resolve.
Pokemon Yellow still had no sound and a blank title screen because:

- ChipSynth reads programs.bin directly via love.filesystem.read, bypassing
  Assets. On NX the unprefixed path is missing when the mount overlay fails,
  so the engine never built and every song/SFX was silent.
- Sound.playPikaCry loads pika_cries WAVs with love.audio.newSource, also
  bypassing Assets.resolve.
- TitleState, YellowIntro, and IntroMovie call love.graphics.newImage
  directly on unprefixed assets/generated paths, so the Pikachu title and
  intro atlases failed to load.

Fix: apply the same NX-only prefix rewrite in those four places.
Desktop/Android keep the existing mountVersion overlay behavior.

Also add ChipSynth._loadBanksForTest and tests covering the new paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:19:15 -03:00
Andrew Quenehen f099593136 fix(switch): write nx-asset-probe.log on every NX Play
Capture resolve paths and newImage open results for Yellow/Blue art
triage without enabling switch-debug.txt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:01:58 -03:00
Andrew Quenehen f944507520 fix(switch): gate Blue/Yellow asset path rewrite to NX only
Desktop and Android keep mountVersion as the overlay; only love-nx
resolves assets/generated to yellow|blue/ save-dir paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:59:44 -03:00
Andrew Quenehen 6824949247 fix(switch): resolve Blue/Yellow art to prefixed save-dir paths
NX fused mount often cannot expose assets/generated; open the real
yellow|blue/assets/generated file with newImage instead of FileData.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:56:19 -03:00
Andrew Quenehen 4f54255518 fix(switch): prefer Yellow/Blue asset bytes when mount overlay lies
Probe generated canaries after mountVersion and always readActive for
prefixed caches so sprites are not blanked by empty PhysFS stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:50:28 -03:00
Andrew Quenehen cfe482e1fc fix(switch): stop pad cursor flicker from SDL mouse drift
Disable mouse-yield on NX where stick/touch moves the system pointer between sparse axis events, clamp pad dt, pixel-snap the overlay, and soften FlexLove GC so the launcher cursor stays steady.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:31:05 -03:00
Andrew Quenehen 0e5ea26e36 fix(switch): stop NxDisplay setMode flicker on the launcher
Only resize when width/height change; love-nx flag mismatches were
recreating the EGL surface every frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:28:13 -03:00
Andrew Quenehen d5886e69aa fix(switch): load Yellow/Blue assets when PhysFS mount hides them
Mirror Data:load's versioned CacheFs read in Assets so Yellow-only NX
Play survives intro without needing a Red root cache mask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:20:53 -03:00
github-actions 32fa215243 chore(ios): update app-repo.json [skip ci] 2026-08-03 13:15:01 -04:00
Andrew Quenehen dd5d8afd82 feat(switch): switch handheld 720p / docked 1080p at runtime
Unlock love-nx SDL dock/undock resizing and sync via NxDisplay so
booting docked is not stuck on the conf 720p hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:12:51 -03:00
johnjohto f19f4e9341 Play the full Fly departure and landing animation (#702) 2026-08-03 13:11:40 -04:00
bryanthaboi 3aec2ca89b Merge pull request #746 from bryanthaboi/dev
scrolling and bugs
2026-08-03 13:08:44 -04:00
bryanthaboi 0f7261dd92 CLOSES #623, CLOSES #624, CLOSES #636, CLOSES #637, CLOSES #639, CLOSES #650, CLOSES #697, CLOSES #704, CLOSES #722 2026-08-03 13:05:57 -04:00
Andrew Quenehen 0886573d34 fix(switch): cut launcher pad-cursor lag on NX
Skip per-frame mouse warps and FlexLove perf sampling on Switch, feed pad coords through a getPosition bridge, and park that shim before the save editor so desktop paths stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:00:06 -03:00
bryanthaboi 5c041ea857 snip for scroll 2026-08-03 12:39:36 -04:00
Andrew Quenehen 30cd9afc92 fix(build): keep desktop packer aligned with upstream dev
Leave pack_love.sh for Switch-only packaging so merge conflicts follow origin/dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:37:56 -03:00
Andrew Quenehen cceb74a025 Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:35:35 -03:00
Andrew Quenehen 42540076c1 Fix NX Scan again to import only the open tab's ROM SHA-1.
A shared imports/ inbox with Red+Yellow was starting Red from the Yellow tab; match by GameVersion.forSha1 for the selected game and document the tab-scoped rescan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:33:07 -03:00
github-actions a528868656 chore(ios): update app-repo.json [skip ci] 2026-08-03 12:30:20 -04:00
bryanthaboi c82af28fd4 Merge pull request #744 from bryanthaboi/dev
fix builds
2026-08-03 12:26:05 -04:00
bryanthaboi d72e5db0a5 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-03 12:21:46 -04:00
bryanthaboi 9907cfb0a0 fix builds 2026-08-03 12:21:32 -04:00
Shane McGovern a3cd5a132e Cycle game speed from the analog triggers too (L2/R2) 2026-08-03 17:19:53 +01:00
github-actions 0971b47bec chore(ios): update app-repo.json [skip ci] 2026-08-03 12:09:08 -04:00
bryanthaboi f2ea250364 Merge pull request #739 from bryanthaboi/dev
ui updates and responsiveness - and bug fixes
2026-08-03 12:04:47 -04:00
Andrew Quenehen d86ac70d9e Merge branch 'dev' into feat/switch-nx 2026-08-03 13:03:25 -03:00
Andrew Quenehen be8023a994 Merge branch 'dev' into feat/switch-nx
Bring FlexLove launcher UI ("big ui moment"), iOS picker dismiss, and Metal updates into the Switch NX branch. Keep pack_love (with libs/), NX inbox/rescan paths, SwitchDiagnostics, and Save Editor PadInput; port Scan again labels into LauncherView.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:02:08 -03:00
bryanthaboi a0ab949399 Merge pull request #738 from johnjohto/fix-yellow-route18-gate-trade
Wire up Yellow's Route 18 gate trade cook (#651)
2026-08-03 12:00:09 -04:00
bryanthaboi 30ed68ae22 Merge pull request #736 from johnjohto/fix-flavor-talk-double-text
Show only one branch of the gated flavor talks (#719)
2026-08-03 11:59:36 -04:00
bryanthaboi f2aa376304 Merge pull request #733 from johnjohto/fix-fuchsia-exhibit-pokedex
Show the exhibited species' Pokédex entries in Fuchsia City (#646)
2026-08-03 11:58:43 -04:00
bryanthaboi 1b48862923 Merge pull request #732 from jherediagu/fix/rom-text-move-effects
Extend ROM-text messages to move effects and the overworld
2026-08-03 11:58:26 -04:00
bryanthaboi 25417ffac8 Update IntroMovie.lua 2026-08-03 11:56:56 -04:00
johnjohto 4dfbc5a828 Wire up Yellow's Route 18 gate trade cook (#651) 2026-08-03 11:52:48 -04:00
bryanthaboi f0f5bc7551 big ui moment 2026-08-03 11:50:49 -04:00
johnjohto f002db2929 Show only one branch of the gated flavor talks (#719) 2026-08-03 11:22:40 -04:00
johnjohto c5791574f5 Show the exhibited species' Pokédex entries in Fuchsia City (#646) 2026-08-03 11:01:39 -04:00
Juan Heredia 8c1fbfb429 Extend ROM-text messages to move effects and the overworld 2026-08-03 16:18:44 +02:00
bryanthaboi 8e5501a23b Merge pull request #728 from hernan0078/ios-picker-dismiss
iOS: a file picker dismissed by swiping locks out every later picker
2026-08-03 09:43:02 -04:00
Andrew Quenehen 50fd0e9c7d refactor(import): drop redundant mobileFileBridge or and unused ready
scanInbox never consulted ready, and mobileFileBridge already mirrors
android on Android/iOS, so the dual guard was a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:02:17 -03:00
Andrew Quenehen 17843c2f7a ci(nx): run switch transfer docs gate on Switch path changes
Wire tests/switch_transfer_docs_test.lua into switch-changes detection,
switch-selftest, and the ROM-free T0 lane so docs drift fails CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:01:47 -03:00
Andrew Quenehen 54c8d2706b docs(nx): drop VoxelMod from Switch chord and install docs
Ship stock engine chords only; community mods own their rebinds, and keys
2/3/5 are claimed by the engine before pipeline hotkeys run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:00:59 -03:00
Andrew Quenehen 44bfb1b93f test(nx): move ROM-free NX suites into the engine tier
platform_nx_* and rom_importer_nx_* run with the love stub and must execute
in CI's ROM-free lane via tests/run_engine.lua, not only T3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:58:53 -03:00
Andrew Quenehen 7a78992881 chore: drop tracked .specs from the tree
Local planning artifacts belong under gitignore (.*) and must not ship in
the PR; useful Switch decisions already live in docs/switch-*.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:58:26 -03:00
bryanthaboi 6ed41c3b87 Merge pull request #725 from castdrian/metal 2026-08-03 08:57:49 -04:00
hernan 7414c176d9 ios: a picker dismissed by swiping locks out every later picker
UIDocumentPickerViewController reports an interactive dismissal only through
UIAdaptivePresentationControllerDelegate. PickerDelegate implemented
didPickDocumentsAt and documentPickerWasCancelled, so a sheet swiped away
reached neither, onFinish never ran, and the delegate stayed in
liveDelegates -- after which the re-present guard refused every later picker
while still answering true.

Implements the dismissal callback, and self-heals when UIKit is presenting
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 08:34:35 -04:00
Andrew Quenehen d7581dbece Ship Switch releases as an SD-ready zip only.
Players extract one zip at the microSD root for install and update; saves under pokemon-love2d/ survive merge. Drop the bare .nro from GitHub Release assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:04:25 -03:00
Andrew Quenehen eeb8c89d3d fix(nx): avoid Strings gate false positive on hash ledger
Write the imported-sha1 newline with string.char(10) so gate_strings_coverage
does not treat the filesystem ledger separator as player-visible text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:46:17 -03:00
Andrew Quenehen 2591fde764 feat(nx): per-game save inbox and export folders
Split Import/Export paths into imports/saves/{red,blue,yellow}/ and
exports/{red,blue,yellow}/ so MTP destinations match each launcher tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:34:13 -03:00
Andrew Quenehen a8f3a3155c fix(nx): harden save inbox against slot clones
Retire successful imports to *.sav.imported and record content hashes so
re-pressing Import save (or the same bytes under a new name) cannot clone
slots. Surface multi-import counts and the active game tab in the notice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:26:56 -03:00
Andrew Quenehen 2a927651c5 docs(specs): mark switch-save-sav-inbox Execute complete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:13:38 -03:00
Andrew Quenehen 62ef647811 docs(specs): re-validate NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:12:57 -03:00
Andrew Quenehen fe4491a87b test(nx): strengthen saves inbox RES-01 and edge coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:11:05 -03:00
Andrew Quenehen 105bf65e02 docs(specs): validation report for NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:09:15 -03:00
Andrew Quenehen 8120f113af test(nx): wire saves inbox suite into run_tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:06:35 -03:00
Andrew Quenehen 7e0c64cb78 docs(specs): AD-012 NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:06:16 -03:00
Andrew Quenehen 4afb54c54f docs(nx): save .sav inbox and exports paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:05:53 -03:00
Andrew Quenehen 74f6b68034 feat(nx): Export save shows MTP exports path
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:02:38 -03:00
Andrew Quenehen 5d1e7ff3c1 feat(nx): Import save uses imports/saves inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:02:04 -03:00
Andrew Quenehen 3c628140f7 feat(nx): saves inbox scan with AppleDouble/retain guards
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:01:14 -03:00
Adrian Castro 01f68d4038 fix(ios): consolidate mobile integration changes 2026-08-03 11:09:30 +02:00
Andrew Quenehen 8bf99c3318 fix(switch): restore Save Editor pad and touch input on NX
Route gamepad/touch into the editor instead of dropping them in editorMode,
and hit-test clicks at event coords so a finger tap is not lost under the
Joy-Con virtual cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:04:37 -03:00
Andrew Quenehen f0829ed54f Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:30:18 -03:00
bryanthaboi 21276e20d1 Merge pull request #707 from ShaneMcGovernIE/fix/champion-theme-timing 2026-08-02 21:26:03 -04:00
bryanthaboi 9fb9648e4c Merge pull request #708 from ShaneMcGovernIE/fix/picker-path-sanitizing 2026-08-02 21:25:43 -04:00
bryanthaboi af7689a1bc Merge pull request #710 from ShaneMcGovernIE/fix/faint-cry-parity 2026-08-02 21:25:20 -04:00
Andrew Quenehen 59dba325b9 docs(switch): finalize Switch docs for PR review
Drop WIP status language, document Joy-Con controls and shortcuts,
credit the port and V1 testing help, and record community V1 boot evidence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:23:29 -03:00
Andrew Quenehen d13064ee64 ci(switch): run fused build only after offline selftest
Avoid burning the self-hosted Mac when the ubuntu packaging gate fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:49 -03:00
Andrew Quenehen 669c9f4d8b ci(switch): skip fused build on fork pull requests
Keep ubuntu selftest for fork→canonical PRs; leave iOS build policy alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:36 -03:00
Andrew Quenehen 228306f883 ci(switch): run workflow content gate in headless suite
Path-gate the Lua gate file and invoke it from scripts/test.sh T0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:15 -03:00
Andrew Quenehen 0a2ef854e6 test(switch): harden CI workflow content gate
Forbid soft-fail on Switch jobs and require the sha256 sidecar path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:58 -03:00
Andrew Quenehen 61c471343a ci: stop artifact commenters from deleting each other
Upsert via comment-tag only so iOS and Switch PR comments coexist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:50 -03:00
Shane McGovern 175bff4b29 Match the original faint sound sequence per side (#709)
pokered plays no 'pitched-down faint cry': the player mon's faint is its
ordinary species cry (RemoveFaintedPlayerMon -> PlayCry) with no
Faint_Fall, and the enemy faint plays no species cry at all -- trainer
battles get SFX_FAINT_FALL then SFX_FAINT_THUD, wild battles go straight
to the victory music (FaintEnemyPokemon core.asm:732-796).

The port played the species cry AND Faint_Fall on every faint, so a
fainted enemy sounded its full battle cry and a fainted player mon got
the fall whistle the hardware never plays.

BattleState.onFaint now:
- player: Sound.playCry only
- enemy trainer: Faint_Fall then Faint_Thud (after the slide)
- enemy wild: no faint sfx (victory music already queued)

Adds tests/parity_faint_cry_bug709.lua asserting the per-side sequence.

Fixes #709
2026-08-03 01:55:52 +01:00
Andrew Quenehen 95eb1c3a35 ci(release): clarify Switch hard-fail vs path-gated PR CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:53:19 -03:00
Andrew Quenehen cf50976d65 docs(switch): document CI selftest, fused PR builds, release hard-fail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:58 -03:00
Andrew Quenehen 3fcee7f951 ci(switch): comment NRO artifacts on pull requests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:36 -03:00
Andrew Quenehen 17a4c83970 ci(switch): add canonical fused NRO artifact job
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:24 -03:00
Andrew Quenehen 9712fb1e9b ci(switch): path-gate offline selftest like iOS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:01 -03:00
Andrew Quenehen cbf622f4aa Merge branch 'dev' into feat/switch-nx
Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve
Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path
gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods
(#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L
display chords still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:36:56 -03:00
Shane McGovern 7c1cdea2eb Sanitize picker file paths (spaces / non-ANSI) and shell prompts
Issue #665: the Windows ROM and save pickers returned the raw chosen
path. io.open on Windows needs ANSI bytes, so a path with accented
characters (Pokemon -> Pok\x82mon, or a folder like 'Pokemon Gen1')
could never be opened -- the same bug #325 already fixed for mod zips
by copying to a plain-ASCII temp name.

Apply that fix to the ROM and .sav pickers: each copies its pick to an
ASCII temp name (pokeport_rom_pick.gb / pokeport_sav_pick.sav) before
answering, exactly like chooseZip does.

Also sanitize the prompt strings interpolated into the picker shell
commands: '%' would be eaten as a string.format directive, and quotes
would break the AppleScript/zenity argument or the surrounding shell
string.

Fixes #665
2026-08-03 01:04:59 +01:00
Shane McGovern 2bfa491b6a Start champion theme when rival dialogue ends, not at battle
The Elite Four champion script showed the rival's intro text and then
jumped straight into the battle; the wipe-time playBattle('final')
started Music_FinalBattle only when the battle began. The original
(ChampionsRoomRivalReadyToBattleScript) plays MUSIC_FINAL_BATTLE right
after the dialogue ends, before the battle.

Add a play_music Music_FinalBattle row between the intro text and the
battle, and bump the two past-the-end jump targets 25 -> 26 for the new
row count. pushBattle's wipe-time playBattle('final') no-ops on the
already-playing song, so the theme stays continuous into the fight.

Fixes #706
2026-08-03 00:52:46 +01:00
github-actions 41fbfc9ba6 chore(ios): update app-repo.json [skip ci] 2026-08-02 19:15:43 -04:00
bryanthaboi 1fa7c0eb43 Merge pull request #705 from bryanthaboi/dev 2026-08-02 19:11:23 -04:00
bryanthaboi 839b238e74 Merge pull request #699 from ShaneMcGovernIE/feature/3x-speed-and-shoulder-hotkeys 2026-08-02 19:08:25 -04:00
Shane McGovern ec22d57cd1 Document GAME SPEED hotkeys in README
Maintainer review on #699 asked to document the hotkeys. Added key 1
(cycle speed up) and controller R2/L2 to the README Hotkeys table, and
GAME SPEED to the Options-menu note.
2026-08-03 00:07:31 +01:00
bryanthaboi 23ef283272 Merge pull request #701 from ShaneMcGovernIE/fix/revive-exp-participants 2026-08-02 19:02:16 -04:00
bryanthaboi 327579f9b6 Merge pull request #700 from ShaneMcGovernIE/fix/pp-up-stats-display 2026-08-02 19:02:06 -04:00
Shane McGovern e5f9d2903d Fix revived Pokemon not receiving experience share
When a Pokemon faints in battle, onFaint() clears it from the battle
participant set. The revive item restored HP but never re-added the
mon to self.participants, so at awardExp() time the revived mon passed
the HP check but failed the participant gate -- getting no exp.

Fix: re-add the revived mon to battle.participants in the revive item
effect, so it's counted as a participant and receives its share of
experience at battle end.

Fixes #648
2026-08-02 23:22:34 +01:00
Shane McGovern 1f3aa4e111 Fix PP UP max PP not shown on stats/summary screen
The SummaryMenu (pause menu stats screen and in-battle stats screen)
displayed the base PP from the move definition as the max value,
ignoring PP Up bonuses. After using a PP UP, the screen would show
e.g. 6/5 instead of 6/6.

Fix: calculate maxPP with the PP Up bonus (basePP + ppUps * basePP/5),
matching the formula used everywhere else -- battle fight menus,
ETHER restore, Pokemon Center heal, link protocol, and save editor.

Fixes #641
2026-08-02 23:16:10 +01:00
Shane McGovern 3f88d1c49a Add 3X game speed and R2/L2 shoulder button speed hotkeys
Add 3X as a speed option between 2X and 4X in GameSpeed.LEVELS (#677).

Add controller hotkeys: rightshoulder (R2) cycles speed up through the
level list, leftshoulder (L2) cycles speed down. Keyboard equivalent
is hotkey 1 (cycles up). All hotkeys are gated during transitions,
scripted cutscenes, and link play (same guards as the color hotkey
at key 2).

The _cycleSpeed helper wraps the save-options update with the same
busy/overworld guard used by the existing color-cycle hotkey.

Fixes #677
2026-08-02 23:10:06 +01:00
bryanthaboi 5348ba1c65 Merge pull request #690 from spiritsnails/fix/faithful-ratio-mobile 2026-08-02 17:58:41 -04:00
bryanthaboi 2f35fd44a1 Merge pull request #693 from ShaneMcGovernIE/fix/celadon-diner-table-alias 2026-08-02 17:58:17 -04:00
bryanthaboi 470fe70f07 Merge pull request #696 from ShaneMcGovernIE/fix/oaks-lab-rival-exit-music 2026-08-02 17:57:58 -04:00
bryanthaboi 399bf557f3 Merge pull request #698 from ShaneMcGovernIE/fix/pc-back-and-hole-sfx 2026-08-02 17:57:44 -04:00
Shane McGovern fc2ca6cc26 Fix PC B-button navigation and hole-fall sound effect
Issue #695: Pressing B in PC submenus (BoxMenu, PlayerPC) was exiting
the entire PC session instead of returning to the main PC menu. The
three main-menu items (Bill's PC, player's PC, Prof. Oak's PC) were
missing keepOpen=true, so selecting one popped the main menu off the
stack. Added keepOpen to all three, matching the pattern already used
by BoxMenu and PlayerPC's own rows.

Issue #694: Falling through boulder holes in Seafoam Islands, Victory
Road, and Pokemon Mansion played no sound effect. Added Faint_Fall sfx
before every hole warp -- the scripted onStep holes in seafoam.lua,
story.lua, and story6.lua, plus the warp-tile-based hole detection in
OverworldController takeWarp. Faint_Fall is the companion to Faint_Thud
(already played when boulders fall into holes).

Fixes #695
Fixes #694
2026-08-02 22:11:15 +01:00
Shane McGovern d9d42ec956 Play rival encounter music when rival leaves Oak's Lab after battle
The parcel scene in Oak's Lab plays Music_MeetRival on both the rival's
arrival and departure (lines 144-146 in oaks_lab.lua), but the post-battle
onStep exit sequence only played the fanfare when the rival approached
(fixed in #596). It was missing when the rival walks out after the battle.

Add stop_music + play_music Music_MeetRival before the rival's exit
walk-out in both oaks_lab.lua and oaks_lab_yellow.lua, matching the
parcel scene's double-fanfare pattern from the original ROM.

Fixes #683
2026-08-02 21:49:56 +01:00
Shane McGovern 5e41c74682 Add tile aliases for LOBBY table blocks 45 and 49
The Celadon Diner uses three LOBBY table blocks that share tile 0x37 on
their flat surfaces. Only block 29 had the 0x37->0x5a BROWN alias; blocks
45 and 49 showed raw tile 0x37 in ROOF (blue-gray), creating a blue
square on the second/third tables with the Advanced Colors preset.

Add alias entries for blocks 45 (cells 13/14) and 49 (cells 1/2).

Fixes #689
2026-08-02 21:20:28 +01:00
spiritsnails 02ad846dfa fix: FAITHFUL RATIO works on Android and iOS
apply() returned false on its first line for mobile, so the option did
nothing there. A phone has no window to resize, so the lock caps the
render scale instead: the largest whole multiple of 160x144 the display
holds, centred, black around it.

Two parts beyond that. The scale is read off the display rather than from
the desktop's 1X-4X ladder, which named a different fraction of every
device and left the useful levels off the list; mobile shows ON or OFF.
And the world pass, which expands to cover the whole display so letterbox
becomes more map, is now sized against the locked viewport, so the lock
reaches the overworld instead of showing more of it.

Pixel perfect throughout, whole multiples only. Desktop and OFF are
unchanged. Renames the row to FAITHFUL RATIO on both platforms; the saved
key stays faithfulRes so existing settings carry over.
2026-08-02 12:44:38 -06:00
bryanthaboi 4e7eda65ed Merge pull request #685 from castdrian/metal 2026-08-02 14:34:08 -04:00
Adrian Castro 3fabe4f591 fix(ios): use square mobile icon in AltSource 2026-08-02 19:27:55 +02:00
github-actions 81234ef2ab chore(ios): update app-repo.json [skip ci] 2026-08-02 13:21:15 -04:00
bryanthaboi 4bd28390ea Merge pull request #680 from bryanthaboi/dev 2026-08-02 13:13:50 -04:00
bryanthaboi fa886899fc Merge pull request #653 from jherediagu/fix/oak-intro-name-confirmation 2026-08-02 13:11:51 -04:00
bryanthaboi a81126a03a Merge pull request #667 from castdrian/metal 2026-08-02 13:11:27 -04:00
bryanthaboi 164c555bb4 Merge pull request #669 from jherediagu/fix/battle-messages-use-rom-text 2026-08-02 13:10:15 -04:00
bryanthaboi e5926893a9 Merge pull request #670 from ShaneMcGovernIE/fix/oak-starter-jingle-668 2026-08-02 13:07:38 -04:00
bryanthaboi dbcaf705c2 Merge pull request #672 from ShaneMcGovernIE/fix/faint-animation-671 2026-08-02 13:05:23 -04:00
bryanthaboi f6809be81d Merge pull request #678 from spiritsnails/feat/ui-layout-option 2026-08-02 13:03:29 -04:00
spiritsnails 3ddf70888e test: ui_layout_option runs ROM-free
The row assertions called Data:load(), which needs data/generated/. The
T1/T2 tier runs without a ROM in CI, so the suite died on the import
rather than failing an assertion. Use T.fixtures.load() like the other
engine suites do.

Verified by moving data/generated aside and re-running: 20/20 with no
imported data present.
2026-08-02 10:54:04 -06:00
spiritsnails fe7dcf33ec feat: UI LAYOUT option, centered by default
Edge docking and zoom-linked UI scaling shipped as unconditional
behaviour. Both are departures from how the port composed the screen, so
they become a setting instead: UI LAYOUT = CENTERED (the default) or
DYNAMIC.

CENTERED is a fixed letterbox. Elements stay where they were drawn in the
160x144 canvas, and the UI does not follow the survey zoom, so screen
furniture neither moves nor resizes under the player. That is what the
pre-anchoring builds did. DYNAMIC is the current behaviour, unchanged.

Both halves matter together: gating only the anchoring would stop the
dialogue box moving but leave it resizing with the zoom, which is the same
complaint in a different form.

Gated at Renderer:setUIAnchor and Renderer:uiScale rather than at each
caller, so one switch covers the dialogue box, its YES/NO, the START menu
and anything anchored later, and no caller knows the option exists.
Game.dynamicUI answers true only for an explicit "dynamic", so a save
written before this keeps the layout it already had.

Independent of it, deliberately: BATTLE SIZE still works under either mode
(uiFill overrides the scale later, in endFrame), and a battle still holds
its own prompts inside its screen under DYNAMIC.

Also includes the Oak intro fix (previously #674): the speech fills white
over the UI canvas while its dialogue box docks to the window edge, so
under DYNAMIC black showed between the two. letterboxWhite closes it, and
the shrink beat's replica box rides the same anchor as the real box it
stands in for.
2026-08-02 10:47:27 -06:00
Shane McGovern 733450bf86 Fix faint slide starting partway down (#671)
The faint slide was shortened from 30 to Timing.FAINT_SLIDE (14) frames
in the timing-parity pass, but fxFaintOffset still computed the offset
with a stale (30 - frames) * 2.  With frames starting at 14 the sprite
teleported 32px down on the first frame and only slid the remaining
28px, cutting the animation short.

SlideDownFaintedMonPic drops the pic one 8px row per 2-frame step, so
the offset advances Timing.FAINT_SLIDE_STEP (4px) per frame at 1x and
covers the full 56px PIC_HEIGHT over the 14-frame budget.
2026-08-02 16:34:02 +01:00
Shane McGovern 1dae9622e1 Play the jingle when Oak hands over the starter (#668)
The starter balls' scripts showed the received-mon text but never played
the sound_get_key_item fanfare that the text carries in the original
(scripts/OaksLab.asm OaksLabReceivedMonText / OaksLabRivalReceivedMonText).
Add play_sound Get_Key_Item before each received text, mirroring the
Yellow starter port.
2026-08-02 16:21:49 +01:00
Juan Heredia da0fa5c9ad Use the ROM's own battle text instead of paraphrasing it 2026-08-02 17:09:08 +02:00
Adrian Castro e576dea676 fix(ios): silence remaining build warnings and refresh artifact comments 2026-08-02 17:07:13 +02:00
github-actions 4cac51a831 chore(ios): update app-repo.json [skip ci] 2026-08-02 08:55:51 -04:00
bryanthaboi fef844e220 Merge pull request #663 from bryanthaboi/dev 2026-08-02 08:49:26 -04:00
bryanthaboi 0f45bb5792 ci: push iOS app-repo.json to main via release deploy key
The release workflow's direct commit of mobile/ios/app-repo.json was
rejected once main went PR-only (GH006). main's protection now lives in
a ruleset whose only bypass actor is deploy keys, so the step commits
with git and pushes over SSH using the RELEASE_DEPLOY_KEY secret
instead of EndBug/add-and-commit with GITHUB_TOKEN.

Also syncs the iOS app repo steps and app-repo.json into dev; they
previously existed only on main.
2026-08-02 08:45:35 -04:00
bryanthaboi c195c5da7d Merge pull request #662 from bryanthaboi/dev 2026-08-02 08:28:57 -04:00
bryanthaboi cb26367f46 Merge pull request #655 from spiritsnails/fix/warden-yesno-645 2026-08-02 08:26:05 -04:00
bryanthaboi 31dc89c0d7 Merge pull request #652 from spiritsnails/fix/enemy-sendout-early-sprite 2026-08-02 08:24:32 -04:00
bryanthaboi c95c3003f7 Merge pull request #661 from castdrian/metal 2026-08-02 08:24:10 -04:00
bryanthaboi 35b3fa6d9c CLOSES #455, CLOSES #487, CLOSES #501, CLOSES #540, CLOSES #585, CLOSES #591, CLOSES #593, CLOSES #595, CLOSES #597, CLOSES #599, CLOSES #600, CLOSES #606, CLOSES #607, CLOSES #610, CLOSES #613, CLOSES #616, CLOSES #620, CLOSES #626, CLOSES #632, CLOSES #633, CLOSES #647 2026-08-02 08:19:28 -04:00
Adrian Castro e217d62f42 chore(ci): add iOS build metadata to artifact comment 2026-08-02 13:44:59 +02:00
Adrian Castro e62801caaf chore: remove legacy install badge filenames 2026-08-02 13:42:16 +02:00
Adrian Castro e6008bb299 chore: simplify install badge filenames 2026-08-02 13:41:51 +02:00
Adrian Castro 9a49646d8a feat(ios): add app repository and install badges 2026-08-02 13:34:26 +02:00
spiritsnails 9934e4d765 fix: Warden's House gibberish line offers YES/NO
WardensHouseWardenText prints Gibberish1, calls YesNoChoice, and answers
with Gibberish2 on yes / Gibberish3 on no (scripts/WardensHouse.asm). The
port printed the question and ended the script. Both reply strings were
already extracted and unused, so this is script wiring only.

The branch targets become labels: they were hand-numbered absolute rows,
already mis-pointed once (#535), and any insert meant renumbering jumps
that had no way to announce they were stale.

Also fixes the parity suite's show_text instrumentation, which stopped
forwarding arguments at `subs` and so dropped the extraOpts carrying
Commands.ask's choice callback -- silently turning every ask under test
back into a plain show_text.
2026-08-02 00:34:46 -06:00
Juan Heredia b6a397460e fix: Play Oak's name confirmation lines 2026-08-02 08:18:33 +02:00
spiritsnails 2b75c07571 fix: enemy mon appears before its send-out animation
The trainer intro was the only enemy send-out path that never set
enemySendingOut, so the front sprite drew at full size the moment the
trainer pic walked off, held through "X sent out Y!", and the grow-in
then played over a mon that had already arrived. Set it with the pic
teardown and clear it with startGrowIn, matching the mid-battle
replacement and the player's own send-out.

Also repairs two parity suites that could not load at all: their game
stubs lacked input.isDown, which battle text has read every frame since
typing started honouring PrintLetterDelay. intro_chrome additionally
pressed A inside PromptText's ProtectedDelay3 hold, which ignores the
button for TEXT_PRE_ADVANCE frames.
2026-08-01 23:55:23 -06:00
bryanthaboi ebbc55c4d0 Merge pull request #586 from andimiller/surfing-pikachu-fix
feat(yellow): port SurfingPikachu behaviour
2026-08-02 00:58:32 -04:00
bryanthaboi 4e6ee3629f Merge pull request #649 from spiritsnails/fix/battle-fixed-ui-scale
fix: battle screens keep their composition when they open a menu or prompt
2026-08-02 00:58:14 -04:00
spiritsnails 46bd0f6709 fix: battle screens keep their composition when they open a menu or prompt
BATTLE SIZE "fixed" draws the battle as a discrete letterbox rather than
filling the window, and BATTLE BG "world" composes it over the live map.
Everything the battle then opens broke out of that composition, because
each piece of the frame's geometry was read off a fact about THIS FRAME
instead of about the battle:

* Renderer:uiScale follows the survey zoom only while a world is behind
  the UI, gated on worldActive -- this frame's world pass.  PartyMenu and
  ListMenu are opaque, so pushing one makes StateStack:visibleBase skip
  the map, no world pass runs, and the menu loses the step-down and blits
  a whole integer scale larger than the battle it just covered.  Held
  with uiWorldHold, the same whole-stack rule uiFill and the battle dim
  already use.  ("fill" hid this: it overrides the scale outright.)

* Game:draw started the frame at visibleBase, so that same opaque menu
  cut the overworld -- and the world pass with it -- out of the frame
  entirely, collapsing a "world" backdrop to endFrame's flat black clear.
  A world-bg battle now keeps the frame starting from underneath itself
  (drawBaseInStack).  Only the START of the draw moves; the clear stays
  keyed to the real visibleBase, so the menu still gets its opaque canvas
  and draws exactly as before.

* worldZones was keyed to that same clear base, so it came out nil for a
  frame whose world pass HAD run -- dropping endFrame's world blit onto
  the UI zone list instead, smearing the party menu's own HP-bar palettes
  across a world-canvas-sized image.  Keyed to whether the map drew.

* endFrame's letterbox clear read letterboxWhite off visibleBase alone,
  so an opaque menu over a BG "white" battle flipped its surround to
  black the same way.  Same whole-stack hold.

* ChoiceBox bottom-anchored unconditionally, docking it to the WINDOW
  edge.  That is only right when it rides the dialogue box beneath it,
  which is anchored there too; TextBox now passes the anchor and nothing
  else does, so the battle's switch offer and the shop/PC confirms stay
  over the screen that pushed them.

* TextBox anchors likewise: a battle is a self-contained SCREEN, not the
  window, and pokered prints its text box in the same 160x144 tilemap as
  the HUD.  The caught-mon nickname prompt was landing a whole letterbox
  below the blanked battle field it is printed on.  BattleState.holdsUI-
  Anchors holds setUIAnchor off while a battle is in the stack; the
  overworld's own dialogue box still docks to the screen edge.
2026-08-01 22:55:58 -06:00
bryanthaboi e6c1ed8753 Merge pull request #643 from spiritsnails/fix/yesno-box-overlap
fix: yes/no box popping up after the question text closes, not over it
2026-08-02 00:14:47 -04:00
spiritsnails 2b92562538 fix: yes/no box popping up after the question text closes, not over it
several places (Commands.ask, the give-a-nickname prompt, PC box release/
change confirms, start menu save/quit) pushed a bare ChoiceBox after the
preceding text box already popped on an A press, instead of riding
TextBox's opts.choice like the rest of the engine. the YES/NO box now
comes up while the question is still on screen, matching the original.
2026-08-01 21:36:30 -06:00
bryanthaboi 078bb1ade6 Merge pull request #634 from bryanthaboi/dev 2026-08-01 21:19:29 -04:00
bryanthaboi f205464aa4 Merge pull request #627 from spiritsnails/parity-fixes 2026-08-01 21:18:52 -04:00
spiritsnails b820d3917c fixing failed tests/harnesses 2026-08-01 19:13:03 -06:00
bryanthaboi fea81f03fc Merge branch 'dev' 2026-08-01 21:10:08 -04:00
bryanthaboi 75aa71a886 Merge pull request #629 from bryanthaboi/dependabot/github_actions/actions/upload-artifact-7
build(deps): bump actions/upload-artifact from 4 to 7
2026-08-01 20:54:55 -04:00
bryanthaboi 398eefc380 Merge pull request #630 from bryanthaboi/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 4 to 7
2026-08-01 20:54:41 -04:00
bryanthaboi b549e319c2 Update README.md 2026-08-01 20:54:08 -04:00
dependabot[bot] 1a57b67e14 build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 23:57:13 +00:00
bryanthaboi 378246728b Merge pull request #628 from spiritsnails/fix/oaks-lab-rival-music 2026-08-01 19:57:10 -04:00
dependabot[bot] 41e326f376 build(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 23:57:09 +00:00
bryanthaboi f9adeca5ca Merge pull request #618 from castdrian/metal 2026-08-01 19:56:29 -04:00
bryanthaboi 838f87c224 Merge pull request #619 from ShaneMcGovernIE/fix/viridian-old-man-yellow-617 2026-08-01 19:55:49 -04:00
spiritsnails 5b519165fc Fix #596: missing rival battle music in Oak's Lab
The Yellow port (oaks_lab_yellow.lua) already swaps in Music_MeetRival
for the "Hey wait! Let's check out our Pokemon!" challenge and restores
Music_OaksLab once the rival walks out, but the Red/Blue script never
did, leaving the lab battle silent before and after the fight.
2026-08-01 17:41:35 -06:00
Andrew Quenehen 0bb0518093 Merge branch 'dev' into feat/switch-nx
Bring Switch WIP up to date with current prod/dev (v0.1.54 / main tree).
2026-08-01 20:25:52 -03:00
Andrew Quenehen 41f549b271 fix(build): stop capturing pack_love stdout into LOVE_FILE
Status lines from pack_love polluted the path and broke version stamping, which would fail the desktop release step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 20:24:30 -03:00
spiritsnails 6b012a31ce Guarantee the post-battle fade-in by moving it into BattleState:finish()
pushBattle wrapped onFinish to push Transition.battleReturn on any
non-lose result, but that only fires for callers that route through
pushBattle correctly. Moving the same push into BattleState:finish()
instead -- the one choke point every battle (wild, trainer, walk-up,
scripted, link) already passes through on exit -- makes the fade
unconditional rather than dependent on each call site's wiring.
2026-08-01 16:41:51 -06:00
Adrian Castro 1a2b23a066 ci: configure dependabot for actions 2026-08-02 00:26:48 +02:00
spiritsnails f109530c5f Route scripted battles through pushBattle so the entry wipe actually plays
Commands.lua's start_battle and old-man-demo battle commands pushed
BattleState straight onto the stack, bypassing pushBattle (and therefore
BattleTransition) entirely. Every script-triggered battle -- gym leaders,
the rival, Giovanni, the catch tutorial -- cut straight to the battle
screen with no transition wipe. Only the walk-up trainer-sight path
(OverworldState:engageTrainer) went through pushBattle already.
2026-08-01 16:12:29 -06:00
spiritsnails 4cb132199c Fix ADVANCED palette pack using Gen 2 per-species palettes instead of Gen 1's
pokered-gbc's palettes.asm carries two species->palette tables gated on
GEN_2_GRAPHICS; data/palettes_gbc.lua had imported the per-species Gen 2
table, shaded for Gen 2 sprite art this port doesn't use, instead of Gen
1's own assignments. Bulbasaur wore PAL_BULBASAUR's red-orange, Squirtle
wore PAL_SQUIRTLE's shell brown on his head. Palette values are unchanged;
only which palette each species points at is corrected.
2026-08-01 16:04:22 -06:00
Adrian Castro fe16b3259c ci(ios): comment artifacts after pull request builds 2026-08-02 00:01:53 +02:00
Adrian Castro 2e46954fe0 ci(ios): use release signing in verification 2026-08-01 23:56:04 +02:00
spiritsnails aedc63c40d Port timing/parity fixes, seamless battle transitions, faithful-res lock, and zoom-aware UI anchoring
Ports from a downstream fork, hand-surgered hunk-by-hunk to exclude the
fork's randomizer/pokescript work and to skip a FixedStep jitter-tolerance
attempt that never fixed the stutter it targeted.

- src/core/Timing.lua: hardware-accurate frame-delay catalog ported from
  pret/pokered, feeding BattleState:waitNext, EffectRegistry's miss/crit
  beats, TextBox/ChoiceBox scroll and prompt holds, and the battle
  silhouette slide/shake/blink/faint timings.
- Seamless battle transitions: Renderer:drawBattleWipe replaces the old
  160x144-only cascade with one wipe drawn over the whole surface at any
  zoom or window size; BattleTransition's per-style frame lengths are
  corrected against pokered-c's derivation; Transition.battleReturn adds
  the post-battle GBFadeInFromWhite the port never had.
- BATTLE SIZE / BATTLE BG options (BattleState:wantsFillScale/bgMode,
  Game.fillScaleInStack/worldBgBattleDim): battle surface can fill the
  window instead of the fixed integer letterbox, and the area around it
  can show white/black/the dimmed overworld instead of only white.
- src/core/FaithfulRes.lua: locks the window to an exact 160x144 multiple.
- Zoom-aware UI anchoring: Renderer:uiScale steps the UI down with survey
  zoom (gated to worldActive so the title/intro never shrink);
  Renderer:setUIAnchor lets TextBox, ChoiceBox, and an opted-in Menu
  (the START menu) pin themselves to a screen edge instead of the
  zoomed-out letterbox.
2026-08-01 15:52:34 -06:00
Adrian Castro 00d13d3c35 ci(ios): comment release artifacts on pull requests 2026-08-01 23:43:12 +02:00
Adrian Castro 1b8b3ad538 ci(ios): build device release on canonical repo 2026-08-01 23:43:11 +02:00
Adrian Castro 5f75cfd691 ci: verify release builds on fork runners 2026-08-01 23:43:11 +02:00
Adrian Castro 5f89def2ce ci: select hosted runners for forks 2026-08-01 23:43:08 +02:00
Adrian Castro 7c26eb9a24 ci(ios): gate hosted build on iOS changes 2026-08-01 23:43:07 +02:00
Shane McGovern d951fe8fc5 Fix Viridian old man tutorial dialog in Yellow (#617) 2026-08-01 22:38:09 +01:00
bryanthaboi ac01135ca8 Merge pull request #615 from bryanthaboi/dev 2026-08-01 16:53:54 -04:00
bryanthaboi 5e89e35e02 Merge pull request #614 from castdrian/metal 2026-08-01 16:52:41 -04:00
bryanthaboi 7e7afeaf82 Mask the self-hosted runner's home path in release logs 2026-08-01 16:45:37 -04:00
Adrian Castro c6fa6d294b fix(ios): support LÖVE 12 file bridge 2026-08-01 22:22:49 +02:00
bryanthaboi fc2d17fc78 Merge pull request #612 from bryanthaboi/dev 2026-08-01 16:03:28 -04:00
Andi Miller 6fb5aa08ff Merge branch 'dev' into surfing-pikachu-fix 2026-08-01 21:02:06 +01:00
bryanthaboi 9f072285a6 Merge pull request #611 from ShaneMcGovernIE/fix/oak-last-ball-601 2026-08-01 16:00:52 -04:00
bryanthaboi c58ebe4b9b Merge pull request #609 from castdrian/metal 2026-08-01 15:59:33 -04:00
Shane McGovern 0dd187fe30 Fix wrong dialogue on Prof. Oak's leftover ball (#601)
After the player picks a starter and the rival takes his, every ball on
the lab table fell through to 'Those are POKé BALLs' instead of the
leftover-mon line.  Port the pokered OaksLabSelectedPokeBallScript ->
OaksLabLastMonScript beat: with EVENT_GOT_STARTER set, Oak turns to face
the player and reads 'That's PROF.OAK's last Pokémon!'.  The ROM's
'#MON' ligature is spelled out as Pokémon.

The renumbering also drops the table's nine out-of-range 'jump 21'
rows (run-time 'end' idioms) for explicit "end" targets, so the
script now validates cleanly.

Tests: T2 suite drives the ball talk table through a ScriptRunner-
compatible executor (leftover text, pre-escort text, pre-pick offer)
plus a T3 driver that talks to the leftover ball in a real game.
2026-08-01 20:55:31 +01:00
Adrian Castro ea78792c03 fix(ios): build releases with LÖVE 12 2026-08-01 21:45:23 +02:00
Andrew Quenehen ba8aa7b263 fix(switch): use English Scan again for NX inbox rescan
Match the project English default instead of a hardcoded Portuguese label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:21:19 -03:00
Andrew Quenehen 243cb6a477 Merge branch 'dev' into feat/switch-nx
Sync upstream v0.1.52/v0.1.53 fixes: Oak PC flow, bindings, Android host restart, UTF-8 mod manifests.
2026-08-01 15:48:00 -03:00
Andrew Quenehen c987fefede docs(switch): document VoxelMod chords and Switch performance tips
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:46:35 -03:00
bryanthaboi 9954225701 Merge pull request #603 from bryanthaboi/dev 2026-08-01 14:32:23 -04:00
Andrew Quenehen 2a038f7112 fix(switch): send build progress to stderr during love packing
Keep pack_game_love command-substitution returning only the archive path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:27:24 -03:00
bryanthaboi 6bb2e078c0 Scrub mod manifest strings to valid UTF-8
A manifest whose name, version, description, or category carries invalid
UTF-8 (a BOM, Latin-1 bytes) crashed the launcher's MODS panel, since
love.graphics.printf raises on invalid UTF-8. Manifest.validate now drops
invalid bytes and a leading BOM from those strings, in place so the
badge's raw.category read agrees.
2026-08-01 14:22:30 -04:00
bryanthaboi 323c59e54f Merge pull request #594 from bryanthaboi/dev 2026-08-01 13:03:22 -04:00
bryanthaboi d26d63ed38 Merge pull request #588 from techmore/fix/prof-oaks-pc-flow 2026-08-01 12:58:03 -04:00
bryanthaboi 2009df3dd1 CLOSES #575, CLOSES #578, CLOSES #584, CLOSES #589 2026-08-01 12:56:13 -04:00
Andrew Quenehen 548e75a15e fix(switch): harden transfer docs gate and per-OS SD/FTP fallbacks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:43:43 -03:00
Andrew Quenehen 339c85b590 docs(switch): close NXMOD-12 VoxelMod OLED smoke as pass
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:41:39 -03:00
Andrew Quenehen 0f0420314b docs(switch): point development guide at multi-method transfer docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:41:15 -03:00
Andrew Quenehen a08b5e65f7 docs(switch): link install and build guides to transfer runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:40:54 -03:00
Andrew Quenehen 99b89c4e5e docs(switch): add multi-OS transfer runbook and content gate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:40:46 -03:00
Andrew Quenehen 20586e0c48 fix(switch): show creator and porter in fused NRO author
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:19:16 -03:00
Andrew Quenehen b54d0c605a fix(switch): stamp Version.lua, gate resume music, keep NX import hint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:13:09 -03:00
Andrew Quenehen 2f75af902c docs(switch): record SWBLD fetch-fused Mac smoke and SD install
Operator confirmed scripts/build_switch.sh --fetch --fused --version
0.0.0-test produced gen1recomp-0.0.0-test-switch.nro (9147a64) and
copied it to sdmc:/switch/gen1recomp/ per switch-install.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:06:45 -03:00
Andrew Quenehen 9147a6413e fix(switch): detail love-nx fetch failures with retry hint
SWBLD-05: on download failure print URL, curl/wget exit and HTTP status,
and an explicit retry: scripts/build_switch.sh --fetch line. Extend the
offline selftest to cover that path without a live network asset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:53:06 -03:00
Andrew Quenehen 9a467d4bf1 docs(switch): link install and build guides from README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:50:17 -03:00
Andrew Quenehen e40f9337b6 docs(switch): add switch-install player guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:57 -03:00
Andrew Quenehen 1f0712d2d5 docs(switch): add switch-build contributor guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:45 -03:00
Andrew Quenehen 345bc9519a ci(release): publish fused Switch NRO on GitHub Releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:25 -03:00
Andrew Quenehen 0b30bb2eff test(switch): add offline build_switch packaging selftest
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:48:31 -03:00
Andrew Quenehen f0e88aa581 feat(switch): native-or-Docker fused NRO packaging with clear errors
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:48:04 -03:00
Andrew Quenehen 317893bd61 feat(switch): add --fetch and mode glossary to build_switch.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:39 -03:00
Andrew Quenehen bd181271d9 fix(switch): tell users to run --fetch when love-nx pin missing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:26 -03:00
Andrew Quenehen 4df09010a8 feat(switch): fetch pinned love-nx with checksum verify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:09 -03:00
Andrew Quenehen 1a5b2b96dc chore(switch): pin devkitPro Docker image for fused fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:46:57 -03:00
Andrew Quenehen 43d89813fc feat(switch): add shared packaging helpers in common.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:46:51 -03:00
Andrew Quenehen 5bc76ce6fe Merge branch 'dev' into feat/switch-nx
Bring latest upstream fixes (Metal/iOS, encounter slide, second-screen seam) into the Switch NX feature branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:12:07 -03:00
techmore f0f3e9634b fix: restore PROF. OAK's PC intro, jingle timing and closing link
CLOSES #576

The launcher skipped oaks_pc.asm's whole session -- the access text and
the 'Want to get your #DEX rated?' YES/NO -- and played the Pokedex_Rating
jingle the moment the entry was picked, before any text printed.  Now the
access text types out, the YES/NO pops, and only once the completion line
and the rating tier have printed does the jingle sound (DisplayDexRating
-> PlayPokedexRatingSfx, auto.wait hands the box to the A/B path), then
the 'Closed link to PROF.OAK's PC.' tail closes the session.
2026-08-01 11:01:19 -04:00
Andi Miller 1fe35335a5 feat(yellow): port SurfingPikachu behaviour
Yellow's IsSurfingPikachuInParty swaps the player's overworld sheet to
a Pikachu-on-a-surfboard when the party mon that knows SURF is a
Pikachu. The recomp was missing both halves of this: the sheet was
never extracted, and the engine had no seam for the swap.

Extraction: SurfingPikachuSprite (gfx/sprites/surfing_pikachu.2bpp)
loads outside SpriteSheetPointerTable via
LoadSurfingPlayerSpriteGraphics2, the same bypass RedBikeSprite uses.
Added the symbol to the Yellow manifest and a parallel extract in
RomExtractor / build_rom_data / extract/sprites, minting
SPRITE_SURFING_PIKACHU. PaletteFX colors it (player OBP palette, same
as the bike).

Engine: new field.playerSprites.surfPikachu (default
SPRITE_SURFING_PIKACHU, guarded so before extraction the ride keeps
the Seel). Player.new caches surfPikachuSprite; pose() picks it when
surfing and the SURF-mon is a Pikachu. New
OverworldState:syncSurfingPikachu derives the flag from partyKnows at
every surf-state toggle (mount, dismount, fly, teleport, blackout,
forced-surf tile, boot-restore). Runtime-only, re-derived at load so a
party change between save and load is honored.

Lane B: RFC at docs/rfcs/0001-surfing-pikachu-sprite.md.
Backward-compatible — existing mods see no change (surf still defaults
to SPRITE_SEEL; surfPikachu only resolves on a Yellow import after
regeneration). Parity tests in tests/parity_surfing_pikachu_sprite.lua
(12/12) and tests/mod_world_tests.lua (19229/19229 with the new
boot-seed checks). tests/parity_cinnabar_east_surf.lua (24/24)
unchanged.

Regeneration required: re-run make_yellow_manifest.py against a
pret/pokeyellow checkout, then re-import the Yellow ROM.
2026-08-01 15:50:22 +01:00
bryanthaboi ca1beaefe7 Merge pull request #582 from castdrian/metal 2026-08-01 10:41:40 -04:00
bryanthaboi 24cf1b3317 Merge pull request #583 from BartInTheField/fix-encounter-slide-577 2026-08-01 10:41:25 -04:00
Bart in 't Veld 88e2ec2042 Fix encounter silhouette slide speed and blackout (#577)
The battle intro slide ran at 4px/frame over 40 frames -- twice the
original speed -- and drew the pics in their normal palette instead of
as black silhouettes.

SlidePlayerAndEnemySilhouettesOnScreen scrolls SCX from $90 to 0 at
2px/frame (~72 frames) under the %11100100 silhouette palette, only
running SET_PAL_BATTLE once the pics land. Match that: introSlide starts
at 80 with a 2px/frame offset (80 frames over the full 160px width), and
picImage bakes both pics through PAL_BLACK while introSlide > 0, exactly
like the evolution movie (#279).

parity_battle_intro_chrome waited a hardcoded 45 frames for the slide to
land; bumped to 85 to cover the slower slide.
2026-08-01 15:25:54 +02:00
Adrian Castro be60c7c1d5 fix(ios): present voxel pipeline canvases 2026-08-01 14:37:18 +02:00
bryanthaboi 9be21380c7 Merge pull request #581 from bryanthaboi/dev
bug smashing on a saturday
2026-08-01 08:14:38 -04:00
bryanthaboi 22fd196045 Merge pull request #579 from castdrian/metal
feat: metal support and make ios usable
2026-08-01 08:13:34 -04:00
bryanthaboi d5293dffb8 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-01 08:10:22 -04:00
bryanthaboi 9326b07583 CLOSES #415, CLOSES #484, CLOSES #488, CLOSES #492, CLOSES #497, CLOSES #541, CLOSES #559, CLOSES #562, CLOSES #563, CLOSES #564, CLOSES #565, CLOSES #566, CLOSES #567, CLOSES #568, CLOSES #569, CLOSES #570, CLOSES #571, CLOSES #572 2026-08-01 08:10:20 -04:00
Adrian Castro 90437a5d8d fix(ios): export saves from Love directory 2026-08-01 13:07:36 +02:00
Adrian Castro c4ec5dc1c5 test(ios): cover save export picker 2026-08-01 12:58:42 +02:00
bryanthaboi 3b19826f1e Merge pull request #543 from BartInTheField/render-second-screen-seam 2026-08-01 06:56:38 -04:00
Adrian Castro 95e4715bc3 fix(ios): declare Love 12 compatibility 2026-08-01 12:54:05 +02:00
Adrian Castro 81edc97d86 fix(ios): package updater boot module 2026-08-01 12:44:26 +02:00
Adrian Castro 92c8bac714 fix(ios): support direct picker results 2026-08-01 12:36:18 +02:00
Adrian Castro 1df04aa18d fix(ios): route native picker results 2026-08-01 12:32:22 +02:00
Andrew Quenehen 8654444d1a docs(switch): document WIP status, OLED-only limits, and manual deploy
Clarify that Mac+OpenMTP is contributor tooling (not the final product
contract), record Dusklight method references, and surface how/what we
tested so reviewers and the community can help finish the Switch port.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 06:07:18 -03:00
Andrew Quenehen 772c39edb8 feat(switch): use gen1recomp cover art for NRO icon
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 06:00:19 -03:00
Andrew Quenehen 74e7474349 fix(nx-mods): skip ROM AppleDouble sidecars and surface mixed failures
Ignore hidden ._*.gb in the ROM inbox like zips, keep mixed-rescan success notices while appending sibling errors, and cover FileData mount / PK rejection in unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:58:17 -03:00
Andrew Quenehen 3aefe41ea1 fix(nx-mods): skip MTP AppleDouble zips and mount archives in memory
Mac OpenMTP leaves ._*.zip sidecars that fail PhysFS mount and hide a good install; prefer FileData mount on Horizon and keep success notices when a sibling fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:53:48 -03:00
Andrew Quenehen 2223c31e93 docs(switch): add VoxelMod OLED smoke evidence scaffold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:50 -03:00
Andrew Quenehen 568f0fa9eb docs(switch): document mod zip inbox and Joy-Con display chords
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:34 -03:00
Andrew Quenehen e91cf0f0c2 test(nx-mods): lock zip and chord edge regressions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:07 -03:00
Andrew Quenehen 8a1f583d88 feat(nx-mods): fire display hotkeys from Select+face chords
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:26:20 -03:00
Andrew Quenehen 7518319c80 feat(nx-mods): map Select+face chords to display hotkeys
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:25:26 -03:00
Andrew Quenehen 304df44742 feat(nx-mods): show Procurar novamente and MTP hint on MODS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:23:19 -03:00
Andrew Quenehen e92bab6f4f feat(nx-mods): route chooseMod to inbox rescan on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:22:08 -03:00
Andrew Quenehen 43755c5457 feat(nx-mods): rescan installs zips and retains failures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:21:01 -03:00
Andrew Quenehen d4455c508f feat(nx-mods): scan imports/mods for zip candidates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:19:59 -03:00
Andrew Quenehen 7d01242cd1 feat(nx-mods): add imports/mods inbox dir and MTP hint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:19:26 -03:00
Andrew Quenehen 9d77189cec docs(switch): sync P0 fused matrix with T24 hardware pass
Verifier flagged stale deferred rows for P0-12/14/15 after fused
deploy evidence closed T24.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:05:43 -03:00
Andrew Quenehen c8adbdcc64 docs(switch): record fused NRO deploy evidence
T24 closed: fused boot/import, Red+Blue Play after Blue mount fix,
and NRO-only replace keeps saves on OLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:03:31 -03:00
Andrew Quenehen ac6dfe7134 fix(import): mount Blue/Yellow save-dir cache without FFI
NX Play for Blue failed because mountVersion relied on absolute
PHYSFS_mount first. Prefer love.filesystem.mount of blue|yellow, overlay
generated trees by version prefix, and align CacheFs.prefix in bootGame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:57:17 -03:00
Andrew Quenehen b1ad7c7254 fix(switch): unhide fused save-dir generated cache on Play
PhysFS does not merge archive data/ with save-dir data/generated, so
fused NX Play crashed after import. Prepend-mount generated trees,
fall back to CacheFs.read in Data:load, keep multiline lua-error logs,
and skip Boot.run when network is unvalidated. T24 stays open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:55:08 -03:00
Bart in 't Veld 218b9249a2 render: add render.compose seam + second-screen bridge
Expose a generic seam so a mod can drive a second screen without the
engine owning any dual-screen layout policy:

- render.compose hook in Renderer:endFrame hands a mod the finished
  world + UI canvases, their SGB zones, the frame metrics,
  Renderer:blitCanvas (lifted from the internal blit closure) and the
  SecondScreen bridge. Return true to take over the window; no wrap (or
  calling next) runs the normal single-window composite byte-for-byte.
- SecondScreen.lua + the Android Presentation bridge (love_android_
  secondary_* in common/android.cpp, GameActivity secondary display)
  as the optional physical-second-display transport.

No battle-render changes: a mod lays out the two screens (including any
battle split) itself. Ships with a unit test, no-mod parity via
gate_hooks, and docs/modding.md (D14).
2026-08-01 09:35:24 +02:00
Andrew Quenehen 6fb5602cb0 docs(switch): outline upstream PR split
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:23 -03:00
Andrew Quenehen b942d37944 docs(switch): complete P0/P1 hardware matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:19 -03:00
Andrew Quenehen b7ee191b6c feat(debug): log Lua errors and document NX crash triage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:14 -03:00
Andrew Quenehen 4ea24a0a6f feat(switch): build fused gen1recomp NRO
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:32:01 -03:00
Andrew Quenehen 6ad8c8d73d feat(switch): embed build-info metadata in artifacts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:57 -03:00
Andrew Quenehen 8982952fa4 feat(switch): reject private content in love payload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:37 -03:00
Andrew Quenehen b135878824 refactor(build): extract shared pack_love.sh helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:06 -03:00
Andrew Quenehen e9463d39aa docs(switch): record suspend and save persistence evidence
T19 closed: quit/reopen, suspend×10, and reboot persistence all pass
on OLED (operator report 2026-08-01).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:29:26 -03:00
Andrew Quenehen dcee7ca8e4 fix(build): avoid SIGPIPE from grep -q on love zip listing
Under pipefail, unzip|grep -q exits 141 when grep closes early on a
match and aborts mac pack. List once to a file, then grep the listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:28:31 -03:00
Andrew Quenehen 1653aa0a5d docs(switch): close T16 naming re-verify with Nintendo UX
OLED pass at 2699c9a: physical A confirms, B cancels; game.love
a208b21e… recorded. T19 suspend×10 still open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:28:31 -03:00
Andrew Quenehen 2699c9a2f9 fix(input): Nintendo A/B face map on NX
SDL labels south as a and east as b; on Switch remap so physical A
confirms and physical B cancels in launcher and NamingScreen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:24:57 -03:00
Andrew Quenehen efd81d8e34 fix(input): ignore raw face presses when Joy-Con is gamepad
love-nx emits gamepad+raw on one press; NamingScreen saw a+b and always
erased. Skip raw when isGamepad(); align NX Y→a/X→b; prefer A if both.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:15:47 -03:00
Andrew Quenehen 925b224eda docs(switch): record Joy-Con input evidence
T16 launcher/overworld pass; naming fail with dual-path diagnosis.
T19 quit/reopen save pass; suspend×10 still pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:15:47 -03:00
Andrew Quenehen 7504753ea8 fix(input): recover cleanly on joystick reconnect
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:43 -03:00
Andrew Quenehen da60f40dfc fix(input): reset controls on focus loss
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:37 -03:00
Andrew Quenehen 560ebc5997 fix(input): apply Switch-measured controller fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:27 -03:00
Andrew Quenehen 361d4b81df feat(debug): add opt-in Switch input diagnostics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:20 -03:00
Andrew Quenehen 83cf1fdb00 refactor(input): share gamepad map between launcher and gameplay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:13 -03:00
Andrew Quenehen 228a0baf81 docs(switch): record OLED ROM import evidence
Phase 0 probe confirmed getOS NX and Joy-Con events; Red inbox
import reached Play. Naming-screen confirm flake deferred to input tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:48:57 -03:00
Andrew Quenehen df7cea4387 feat(import): scan NX inbox and import by canonical SHA-1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:16 -03:00
Andrew Quenehen 13b55a797e feat(import): show NX inbox path and rescan action
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:12 -03:00
Andrew Quenehen 1ec33b1374 refactor(import): separate NX capabilities from Android flags
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:09 -03:00
Andrew Quenehen c06991e03d fix(switch): disable unvalidated network features on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:15:31 -03:00
Andrew Quenehen 2fcd7abbfd fix(platform): skip host shell spawn on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:15:13 -03:00
Andrew Quenehen 5ca17cfc99 feat(conf): configure LÖVE window hints for NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:45 -03:00
Andrew Quenehen 05f1e3852c feat(platform): add NX capability detection module
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:38 -03:00
Andrew Quenehen 3b22a45a23 docs(switch): add MTP and Phase 0 hardware runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:25 -03:00
Andrew Quenehen f172d3129f feat(switch): assemble loose nro + game.love dist
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:11 -03:00
Andrew Quenehen a7a84b6fab chore(switch): add minimal love-nx hardware probe
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:13:57 -03:00
Andrew Quenehen d4afa09e03 docs(switch): pin love-nx 11.5-nx1 manifest layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:13:46 -03:00
bryanthaboi 50947eec2a Merge pull request #489 from mresnick67/android-step-bridge 2026-08-01 00:21:40 -04:00
bryanthaboi 2624bb0210 Merge pull request #537 from sirj0k3r/hook/exp 2026-08-01 00:21:20 -04:00
bryanthaboi 6e9685f302 Merge pull request #538 from sirj0k3r/hook/low-health 2026-08-01 00:21:10 -04:00
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
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
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03: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
sirj0k3r 33899c237e + Implemented tests for battle.low_health_alarm hook 2026-07-31 22:17:19 +01:00
sirj0k3r 0fad222569 + Implemented tests for battle.catch_exp and battle.exp_award hooks 2026-07-31 22:10:26 +01: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
sirj0k3r ddcce5b95a + Implemented Exp hook 2026-07-31 21:14:23 +01:00
sirj0k3r 6fd9741fab + Implemented Low Health Alert hook for modding 2026-07-31 21:12:21 +01: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
Myles Resnick caa65182dc Android: hardware step-counter bridge for step-sync mods
love.system.syncHealthSteps() now exists on Android, matching the iOS
Health bridge merged in #452 and using the same JNI route as the SAF
picker (wrap_System.cpp -> System.cpp -> common/android.cpp ->
GameActivity over JNI):

- GameActivity.syncHealthSteps: one-shot read of the hardware
  TYPE_STEP_COUNTER sensor (cumulative since boot, counted by the OS
  whether or not any app runs). The reading is anchored in
  SharedPreferences so a walk is never credited twice; a reading below
  the anchor means the phone rebooted, which re-anchors without
  crediting. Deltas (50k clamp) merge into steps_pending.json in the
  save identity dir - the same contract as the iOS GRHealthBridge, so
  the Pokewalker mod works unchanged on both platforms.
- ACTIVITY_RECOGNITION declared in the app manifest (Android 10+
  runtime prompt on first sync; granted -> the sensor read runs
  immediately via onRequestPermissionsResult). The build script's
  permission trim leaves it alone.
- Nothing in the base game calls the new seam; without a consumer mod
  the only cost is one dormant manifest permission.
- build_android.sh: shadow-build from a space-free temp dir when the
  checkout path contains spaces - ndk-build is GNU make underneath and
  cannot cope with paths like "xCode Projects".
- mobile/ANDROID.md: step-bridge dev notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -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
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
641 changed files with 134859 additions and 5555 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

+241 -6
View File
@@ -10,8 +10,6 @@ name: ci
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
# data/generated/ is absent and skips it rather than failing.
#
# Runs alongside release.yml, which is untouched by this file.
on:
push:
# Integration branch + release branch. PRs already run via pull_request
@@ -24,12 +22,249 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ios-changes:
name: detect iOS changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
ios-build:
name: iOS build
needs: ios-changes
if: needs.ios-changes.outputs.changed == 'true'
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
outputs:
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
steps:
- uses: actions/checkout@v7
- name: import signing certificate
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
p12="$ci_dir/signing.p12"
passfile="$ci_dir/signing.pass"
[ -f "$p12" ] && [ -f "$passfile" ] || exit 1
p12pw="$(cat "$passfile")"
kcpw="$(openssl rand -base64 24)"
echo "::add-mask::$kcpw"
security delete-keychain "$keychain_path" 2>/dev/null || true
security create-keychain -p "$kcpw" "$keychain_path"
security set-keychain-settings "$keychain_path"
security unlock-keychain -p "$kcpw" "$keychain_path"
security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
security list-keychains -d user -s "$keychain_path" $existing
- name: install xcbeautify
run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: build iOS release
env:
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
if [ "$CANONICAL_REPOSITORY" = true ]; then
scripts/build_ios.sh --fetch --device --release
else
scripts/build_ios.sh --fetch --release
fi
- name: upload iOS release artifact
id: upload-ipa
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/upload-artifact@v7
with:
name: gen1recomp-ios-ipa
path: dist/ios/gen1recomp.ipa
if-no-files-found: error
retention-days: 7
- name: clean up signing keychain
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
switch-changes:
name: detect Switch changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
switch-selftest:
name: Switch offline selftest
needs: switch-changes
if: needs.switch-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: Switch offline selftest
run: bash scripts/switch/selftest_build_switch.sh
- name: verify_payload self-test
run: bash scripts/switch/verify_payload.sh --self-test
- name: Switch CI workflow content gate
run: luajit tests/switch_ci_workflows_test.lua
- name: Switch transfer docs content gate
run: luajit tests/switch_transfer_docs_test.lua
# NX runtime regressions gate this job via switch-changes; run the NX
# engine suites here too so a PR touching them gets feedback on the
# fork-safe ubuntu runner before the self-hosted Mac build.
- name: NX engine suites (headless)
run: |
luajit tests/engine/assets_version_fallback_test.lua
luajit tests/engine/nx_generated_guard_test.lua
luajit tests/engine/nx_yellow_boot_test.lua
switch-build:
name: Switch fused build
needs: [switch-changes, switch-selftest]
if: |
always()
&& needs.switch-changes.outputs.changed == 'true'
&& needs.switch-selftest.result == 'success'
&& github.repository == 'bryanthaboi/gen1recomp'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ["self-hosted", "macOS"]
steps:
- uses: actions/checkout@v7
- name: Build Switch fused NRO
run: |
set -euo pipefail
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
scripts/build_switch.sh --fetch --fused --version "$VER"
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
- name: upload Switch NRO artifact
uses: actions/upload-artifact@v7
with:
name: gen1recomp-switch-nro
path: |
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
if-no-files-found: error
retention-days: 7
xbox-uwp-changes:
name: detect Xbox UWP changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
xbox-uwp-selftest:
name: Xbox UWP offline selftest
needs: xbox-uwp-changes
if: needs.xbox-uwp-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Xbox UWP offline selftest
run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
- name: Build shared payload
run: |
scripts/pack_love.sh \
--output .bazinga/work/ci-game.love \
--listing .bazinga/work/ci-love-listing.txt \
--version 0.0.0
- name: Upload shared payload
uses: actions/upload-artifact@v7
with:
name: gen1recomp-xbox-uwp-payload
path: .bazinga/work/ci-game.love
if-no-files-found: error
retention-days: 1
xbox-uwp-build:
name: Xbox UWP build
needs: [xbox-uwp-changes, xbox-uwp-selftest]
if: |
always()
&& needs.xbox-uwp-changes.outputs.changed == 'true'
&& needs.xbox-uwp-selftest.result == 'success'
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-xbox-uwp-payload
path: .bazinga/work
- name: Build Xbox UWP package
shell: bash
run: |
bash scripts/build_xbox_uwp.sh \
--release \
--version 0.0.0 \
--game-love .bazinga/work/ci-game.love
- name: Upload Xbox UWP package
uses: actions/upload-artifact@v7
with:
name: gen1recomp-xbox-uwp
path: |
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256
if-no-files-found: error
retention-days: 7
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
# written to Lua 5.1 semantics, so CI must run the interpreter the
@@ -47,7 +282,7 @@ jobs:
name: fixture dataset integrity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
@@ -89,7 +324,7 @@ jobs:
name: screenshot differ (capture not yet wired)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: python3 -m pip install --upgrade pillow
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
@@ -137,7 +372,7 @@ jobs:
name: mod lint (no ROM-derived content)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# the MK305 dump check key-diffs shipped tables through luajit, and
# modkit treats a missing interpreter as a fatal MK100 -- without
@@ -0,0 +1,55 @@
name: iOS artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- id: artifact
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
- name: Get build info
id: build-info
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
echo "time=$build_time" >> "$GITHUB_OUTPUT"
- name: comment iOS artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: ios-build-result
github-token: ${{ github.token }}
+262 -32
View File
@@ -1,8 +1,9 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
# GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -24,6 +25,7 @@ on:
paths-ignore:
- '.github/**'
- '**.md'
- 'mobile/ios/app-repo.json'
workflow_dispatch:
inputs:
version:
@@ -41,16 +43,17 @@ concurrency:
cancel-in-progress: false
jobs:
release:
runs-on: [self-hosted, macOS]
version:
name: determine release version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Determine version
id: ver
env:
@@ -58,7 +61,6 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
# 1) Explicit override from a manual run.
@@ -88,7 +90,6 @@ jobs:
| grep -E "$semver_re" \
| sort -t. -k1,1n -k2,2n -k3,3n \
| tail -1 || true)"
if [ -z "$latest" ]; then
version="0.1.0"
echo "No existing release tag; starting at $version"
@@ -116,11 +117,129 @@ jobs:
echo "::error::Release $tag already exists. Pick a different version."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
love-payload:
name: build release game.love
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build shared payload
run: |
scripts/pack_love.sh \
--output dist/payload/game.love \
--listing dist/payload/love-listing.txt \
--version "${{ needs.version.outputs.version }}"
- name: Upload shared payload
uses: actions/upload-artifact@v7
with:
name: gen1recomp-release-love
path: dist/payload/game.love
if-no-files-found: error
retention-days: 1
xbox-uwp:
name: build Xbox UWP release
needs: [version, love-payload]
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: .bazinga/work
- name: Prepare signing certificate
shell: pwsh
env:
CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
if ($env:CANONICAL_REPOSITORY -eq 'true' -and
[string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.'
}
if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
"UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append
exit 0
}
$pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx'
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
$flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
$pfx, $env:CERTIFICATE_PASSWORD, $flags)
$cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer'
[IO.File]::WriteAllBytes(
$cer,
$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
"UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append
"UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append
"UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append
- name: Build Xbox UWP package
shell: bash
run: |
bash scripts/build_xbox_uwp.sh \
--release \
--version "${{ needs.version.outputs.version }}" \
--publisher "$UWP_PUBLISHER" \
--game-love .bazinga/work/game.love
- name: Sign and stage Xbox UWP release
shell: pwsh
env:
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
run: |
if (-not $env:UWP_PFX) {
exit 0
}
scripts/xbox-uwp/stage_release.ps1 `
-Version '${{ needs.version.outputs.version }}' `
-Configuration Release `
-BuildInfo .bazinga/work/xbox-uwp-build-info.json `
-CertificatePath $env:UWP_PFX `
-CertificatePassword $env:CERTIFICATE_PASSWORD
- name: Upload Xbox UWP release
uses: actions/upload-artifact@v7
with:
name: gen1recomp-xbox-uwp-release
path: |
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256
if-no-files-found: error
retention-days: 1
- name: Remove signing certificate
if: always()
shell: pwsh
run: |
if ($env:UWP_CERT_THUMBPRINT) {
Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue
}
if ($env:UWP_PFX) {
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
}
release:
needs: [version, xbox-uwp]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
# The self-hosted runner lives under the machine owner's home
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
@@ -163,30 +282,51 @@ jobs:
# notarize separately below so it uses secret credentials, not a
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
- name: Build Android
run: |
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
- name: Build iOS
- name: Install xcbeautify
run: |
set -euo pipefail
# Device Release IPA; signs with the Apple Development identity on
# the runner (auto team detection). Users on other Apple IDs still
# re-sign or build via docs/ios-install.md.
scripts/build_ios.sh --fetch --device --release \
--version "${{ steps.ver.outputs.version }}"
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: Build iOS
env:
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
set -euo pipefail
if [ "$CANONICAL_REPOSITORY" = true ]; then
scripts/build_ios.sh --fetch --device --release \
--version "${{ needs.version.outputs.version }}"
else
scripts/build_ios.sh --fetch --release \
--version "${{ needs.version.outputs.version }}"
fi
- name: Build Switch
run: |
set -euo pipefail
# Hard-fail gate: Switch ships with every release (never soft-fail).
# PR CI is path-gated (ubuntu selftest + canonical fused); release
# always builds Switch regardless of which files changed.
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
# Mac self-hosted runner; see docs/switch-build.md.
scripts/build_switch.sh --fetch --fused \
--version "${{ needs.version.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
# runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
- name: Notarize & staple macOS app
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
@@ -220,11 +360,19 @@ jobs:
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
echo "Notarized + stapled ✓"
- name: Download Xbox UWP release
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/download-artifact@v8
with:
name: gen1recomp-xbox-uwp-release
path: dist/xbox-uwp
- name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
v="${{ needs.version.outputs.version }}"
outdir="dist/release"
rm -rf "$outdir"
mkdir -p "$outdir"
@@ -239,6 +387,16 @@ jobs:
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
# publishes the SD-ready zip only.
uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip"
[ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; }
cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -260,12 +418,13 @@ jobs:
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
if: github.repository == 'bryanthaboi/gen1recomp'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}"
v="${{ needs.version.outputs.version }}"
tag="${{ needs.version.outputs.tag }}"
# Issues this release closes. Three sources, deduped by number:
# 1. GitHub's own "closing issues" links on every PR whose
@@ -349,22 +508,93 @@ jobs:
fi
printf 'Release notes:\n%s\n' "$notes"
release_files=(
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
"dist/release/gen1recomp-${v}.love"
"dist/release/sha256sums.txt"
)
gh release create "$tag" \
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
"${release_files[@]}"
echo "Published release $tag"
- name: Update iOS app repository
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then
localized_description="$release_notes"
fi
entry="$(jq -n \
--arg version "$v" \
--arg date "$date" \
--arg download_url "$download_url" \
--arg localized_description "$localized_description" \
--argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
if jq -e --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
"$app_repo" >/dev/null; then
jq --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
"$app_repo" > "$app_repo.tmp"
else
jq --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
"$app_repo" > "$app_repo.tmp"
fi
mv "$app_repo.tmp" "$app_repo"
# main is PR-only for everyone except deploy keys (the "main protection"
# ruleset's bypass actor), so this push must authenticate with the
# RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
# would be rejected by the branch protection.
- name: Commit iOS app repository
if: github.repository == 'bryanthaboi/gen1recomp'
env:
DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
run: |
set -euo pipefail
git add mobile/ios/app-repo.json
if git diff --cached --quiet; then
echo "app-repo.json unchanged; nothing to push"
exit 0
fi
key="$RUNNER_TEMP/release-deploy-key"
printf '%s\n' "$DEPLOY_KEY" > "$key"
chmod 600 "$key"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "chore(ios): update app-repo.json [skip ci]"
git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
rm -f "$key"
- name: Clean up signing keychain
if: always()
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: |
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/release-deploy-key"
@@ -0,0 +1,55 @@
name: Switch artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- id: artifact
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
- name: Get build info
id: build-info
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
echo "time=$build_time" >> "$GITHUB_OUTPUT"
- name: comment Switch artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: switch-build-result
github-token: ${{ github.token }}
+10 -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/
@@ -30,7 +31,10 @@ mobile/ios/love-src/
mobile/ios/cache/
mobile/ios/build/
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
.bazinga/love-nx/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/
# Legacy manual convenience-copy location (superseded by /dist/android/)
@@ -42,3 +46,8 @@ mobile/dist/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
# Xbox UWP build output
/ports/uwp/build/
/ports/uwp/third_party/*/source/
/ports/uwp/third_party/angle/depot_tools/
+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
}
+10
View File
@@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods.
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
| `AUDIO` | Music, sfx, cries | content |
| `UI` | New or modified screens, menus, overlays | content / overhaul |
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
@@ -128,6 +129,15 @@ not a hard error, so the list can grow without breaking old mods.
keeps validating with the value it has shipped since before the taxonomy
existed.
A translation may also set `"language": true` in the manifest. That is the
one claim online play acts on: an install running nothing but verified
translations may take an ONLINE MATCH or a TOURNAMENT instead of being
asked to restart vanilla. The claim is checked, not taken -- the mod
qualifies only if every record it writes lands in `text`, `strings` or
`font`, it wraps no hook, subscribes to no event and requests no
permission. Anything else and it is an ordinary content mod that happens to
ship text.
### 4. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
+64 -2
View File
@@ -4,6 +4,9 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
> [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
@@ -75,6 +78,19 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
### A note on Windows Defender warnings
Windows Defender sometimes flags the Windows build with a generic
machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
a known false positive: the exe is the official LÖVE runtime with the game
archive appended (the standard way LÖVE games ship), and Defender's
heuristics distrust unsigned executables with appended data. Every release
publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
download, and you can confirm a flagged file yourself on
[VirusTotal](https://www.virustotal.com), where these builds come back clean
on every engine except Defender's heuristic. False positives are reported to
Microsoft as they come up.
## Controls
@@ -96,6 +112,7 @@ supported out of the box.
| Key | What it does |
| --------- | ---------------------------------------------------- |
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
@@ -105,8 +122,19 @@ supported out of the box.
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, 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
@@ -184,6 +212,16 @@ Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md).
<div>
<a href="https://intradeus.github.io/http-protocol-redirector?r=sidestore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/sidestore-badge.png" alt="Add to SideStore" height="60"></a>
&nbsp;
<a href="https://intradeus.github.io/http-protocol-redirector?r=feather://source/https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/feather-badge.png" alt="Add to Feather" height="60"></a>
&nbsp;
<a href="https://intradeus.github.io/http-protocol-redirector?r=altstore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/altstore-badge.png" alt="Add to AltStore" height="60"></a>
&nbsp;
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
</div>
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
@@ -191,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## Nintendo Switch
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
(author) and **V1 / Erista** boot (community).
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
zip, extract at the microSD root (install or update), title-override
launch, import your own legal ROM, Joy-Con controls and shortcuts.
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
Limitations, Dusklight-derived method, and how we tested:
[docs/switch-development.md](docs/switch-development.md) and
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
help — especially HOS / love-nx packaging and broader hardware coverage — is
welcome.
## Modding
The game ships a native mod platform: content registries, events and hooks,
@@ -238,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
# Plain Pixel font
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
CC-BY 4.0: https://burpyfresh.itch.io
Version 0.009 (CJK character additions), unmodified. Characters for most
languages have a 5x11 base but can extend vertically; double-width
characters such as Hiragana and Katakana are 11x11.
Bundled so a translation mod can opt into TTF text rendering
(`mod.content.font:register("ttf", {})`; see the Translation support
section of docs/new-features.md) instead of drawing hundreds of glyph-page
tiles. The tile font extracted from the player's ROM stays the default.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+1 -1
View File
@@ -89,7 +89,7 @@ mkdir -p "$GAME_SRC"
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src data assets tools/save-editor \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
+21 -2
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
@@ -44,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
@@ -53,7 +58,17 @@ function love.conf(t)
-- engine before conf runs (LÖVE 11.x / 11.5).
local osName = love._os
local mobile = osName == "Android" or osName == "iOS"
if mobile then
local nx = osName == "NX"
if nx then
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
-- (720p↔1080p) only when the window is resizable and not exclusive
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
t.window.width = 1280
t.window.height = 720
t.window.fullscreen = false
t.window.resizable = true
t.window.highdpi = false
elseif mobile then
-- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
@@ -65,6 +80,10 @@ function love.conf(t)
-- 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.
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
-- re-triggers the request, from main.lua for the launcher and from
-- Game:applyOptions in game.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
+165 -151
View File
@@ -1683,158 +1683,172 @@ return {
{ 0, 0, 0 },
},
},
-- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
-- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
--
-- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
-- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
-- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
-- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
-- branch is the matching one. The per-species table was imported here by
-- mistake, which pointed every mon at colours shaded for different art:
-- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
-- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
--
-- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
-- richer colours -- only which palette each species points at changed.
pokemon = {
ABRA = "ABRA",
AERODACTYL = "AERODACTYL",
ALAKAZAM = "ALAKAZAM",
ARBOK = "ARBOK",
ARCANINE = "ARCANINE",
ARTICUNO = "ARTICUNO",
BEEDRILL = "BEEDRILL",
BELLSPROUT = "BELLSPROUT",
BLASTOISE = "BLASTOISE",
BULBASAUR = "BULBASAUR",
BUTTERFREE = "BUTTERFREE",
CATERPIE = "CATERPIE",
CHANSEY = "CHANSEY",
CHARIZARD = "CHARIZARD",
CHARMANDER = "CHARMANDER",
CHARMELEON = "CHARMELEON",
CLEFABLE = "CLEFABLE",
CLEFAIRY = "CLEFAIRY",
CLOYSTER = "CLOYSTER",
CUBONE = "CUBONE",
DEWGONG = "DEWGONG",
DIGLETT = "DIGLETT",
DITTO = "DITTO",
DODRIO = "DODRIO",
DODUO = "DODUO",
DRAGONAIR = "DRAGONAIR",
DRAGONITE = "DRAGONITE",
DRATINI = "DRATINI",
DROWZEE = "DROWZEE",
DUGTRIO = "DUGTRIO",
EEVEE = "EEVEE",
EKANS = "EKANS",
ELECTABUZZ = "ELECTABUZZ",
ELECTRODE = "ELECTRODE",
EXEGGCUTE = "EXEGGCUTE",
EXEGGUTOR = "EXEGGUTOR",
FARFETCHD = "FARFETCH_D",
FEAROW = "FEAROW",
FLAREON = "FLAREON",
GASTLY = "GASTLY",
GENGAR = "GENGAR",
GEODUDE = "GEODUDE",
GLOOM = "GLOOM",
GOLBAT = "GOLBAT",
GOLDEEN = "GOLDEEN",
GOLDUCK = "GOLDUCK",
GOLEM = "GOLEM",
GRAVELER = "GRAVELER",
GRIMER = "GRIMER",
GROWLITHE = "GROWLITHE",
GYARADOS = "GYARADOS",
HAUNTER = "HAUNTER",
HITMONCHAN = "HITMONCHAN",
HITMONLEE = "HITMONLEE",
HORSEA = "HORSEA",
HYPNO = "HYPNO",
IVYSAUR = "IVYSAUR",
JIGGLYPUFF = "JIGGLYPUFF",
JOLTEON = "JOLTEON",
JYNX = "JYNX",
KABUTO = "KABUTO",
KABUTOPS = "KABUTOPS",
KADABRA = "KADABRA",
KAKUNA = "KAKUNA",
KANGASKHAN = "KANGASKHAN",
KINGLER = "KINGLER",
KOFFING = "KOFFING",
KRABBY = "KRABBY",
LAPRAS = "LAPRAS",
LICKITUNG = "LICKITUNG",
MACHAMP = "MACHAMP",
MACHOKE = "MACHOKE",
MACHOP = "MACHOP",
MAGIKARP = "MAGIKARP",
MAGMAR = "MAGMAR",
MAGNEMITE = "MAGNEMITE",
MAGNETON = "MAGNETON",
MANKEY = "MANKEY",
MAROWAK = "MAROWAK",
MEOWTH = "MEOWTH",
METAPOD = "METAPOD",
MEW = "MEW",
MEWTWO = "MEWTWO",
MOLTRES = "MOLTRES",
MR_MIME = "MR_MIME",
MUK = "MUK",
NIDOKING = "NIDOKING",
NIDOQUEEN = "NIDOQUEEN",
NIDORAN_F = "NIDORAN_F",
NIDORAN_M = "NIDORAN_M",
NIDORINA = "NIDORINA",
NIDORINO = "NIDORINO",
NINETALES = "NINETALES",
ODDISH = "ODDISH",
OMANYTE = "OMANYTE",
OMASTAR = "OMASTAR",
ONIX = "ONIX",
PARAS = "PARAS",
PARASECT = "PARASECT",
PERSIAN = "PERSIAN",
PIDGEOT = "PIDGEOT",
PIDGEOTTO = "PIDGEOTTO",
PIDGEY = "PIDGEY",
PIKACHU = "PIKACHU",
PINSIR = "PINSIR",
POLIWAG = "POLIWAG",
POLIWHIRL = "POLIWHIRL",
POLIWRATH = "POLIWRATH",
PONYTA = "PONYTA",
PORYGON = "PORYGON",
PRIMEAPE = "PRIMEAPE",
PSYDUCK = "PSYDUCK",
RAICHU = "RAICHU",
RAPIDASH = "RAPIDASH",
RATICATE = "RATICATE",
RATTATA = "RATTATA",
RHYDON = "RHYDON",
RHYHORN = "RHYHORN",
SANDSHREW = "SANDSHREW",
SANDSLASH = "SANDSLASH",
SCYTHER = "SCYTHER",
SEADRA = "SEADRA",
SEAKING = "SEAKING",
SEEL = "SEEL",
SHELLDER = "SHELLDER",
SLOWBRO = "SLOWBRO",
SLOWPOKE = "SLOWPOKE",
SNORLAX = "SNORLAX",
SPEAROW = "SPEAROW",
SQUIRTLE = "SQUIRTLE",
STARMIE = "STARMIE",
STARYU = "STARYU",
TANGELA = "TANGELA",
TAUROS = "TAUROS",
TENTACOOL = "TENTACOOL",
TENTACRUEL = "TENTACRUEL",
VAPOREON = "VAPOREON",
VENOMOTH = "VENOMOTH",
VENONAT = "VENONAT",
VENUSAUR = "VENUSAUR",
VICTREEBEL = "VICTREEBEL",
VILEPLUME = "VILEPLUME",
VOLTORB = "VOLTORB",
VULPIX = "VULPIX",
WARTORTLE = "WARTORTLE",
WEEDLE = "WEEDLE",
WEEPINBELL = "WEEPINBELL",
WEEZING = "WEEZING",
WIGGLYTUFF = "WIGGLYTUFF",
ZAPDOS = "ZAPDOS",
ZUBAT = "ZUBAT",
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 = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
world = {
+9 -7
View File
@@ -1,10 +1,9 @@
-- BikeShop (BIKE_SHOP) flavor dialogue
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
--
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
-- scope of these two flavor NPCs and is left unported here.
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
return {
BIKE_SHOP = {
@@ -20,12 +19,15 @@ return {
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
-- before the player owns a bike -> TheseBikesAreExpensiveText
-- after the player owns a bike -> CoolBikeText
-- The check reads the bag, not the event: the port hands out the
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
-- reads right on saves written before the clerk set the event (#567).
TEXT_BIKESHOP_YOUNGSTER = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "check_item", "BICYCLE" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
},
},
@@ -35,14 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
-- .loop: print WhichBadgeText, then show the badge list menu again
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local items = {}
for _, id in ipairs(BADGE_ORDER) do
local idef = game.data.items[id]
items[#items + 1] = { label = idef and idef.name or id, value = id }
end
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
-- list's $FF terminator (home/list_menu.asm), so every
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
-- exit as B (#569)
items[#items + 1] = { label = Strings("CANCEL") }
local menu = ListMenu.new(game, "", items, {
onChoose = function(item)
game.stack:pop()
if not item.value then
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
return
end
push(game, t[BADGE_TEXT[item.value]], loop)
end,
onCancel = function()
@@ -14,9 +14,9 @@ return {
-- else -> .WhatsLostIsLostText (player has TM_DIG)
TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = {
{ "check_item", "TM_DIG" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" },
},
},
+80
View File
@@ -0,0 +1,80 @@
-- Fuchsia City (pokered/scripts/FuchsiaCity.asm)
--
-- The exhibit signs are text_asm bodies: PrintText of a single text_far,
-- then DisplayPokedex for the exhibited species. The preview marks the
-- species seen but not owned, same as the S.S. Anne passenger's Snorlax.
--
-- The fossil sign branches on the Mt. Moon fossil events. The exhibit
-- holds the fossil the player did NOT take: taking the Dome Fossil puts
-- Omanyte on display, taking the Helix Fossil puts Kabuto. With neither
-- event set the sign only prints its undetermined line and no dex entry
-- opens.
--
-- The other text pointers (city sign, Safari Game signs, mart/center/gym
-- and warden signs, the four NPCs, the exhibited-mon FuchsiaCityPokemonText
-- rows) are plain text_far wrappers that resolve through Data:resolveText,
-- so they are not ported here.
return {
FUCHSIA_CITY = {
talk = {
-- FuchsiaCityChanseySignText: PrintText(_FuchsiaCityChanseySignText),
-- then DisplayPokedex CHANSEY.
TEXT_FUCHSIACITY_CHANSEY_SIGN = {
{ "show_text", "_FuchsiaCityChanseySignText" },
{ "mark_seen", "CHANSEY" },
{ "push_screen", "DexEntryMenu", "CHANSEY" },
},
-- FuchsiaCityVoltorbSignText: PrintText(_FuchsiaCityVoltorbSignText),
-- then DisplayPokedex VOLTORB.
TEXT_FUCHSIACITY_VOLTORB_SIGN = {
{ "show_text", "_FuchsiaCityVoltorbSignText" },
{ "mark_seen", "VOLTORB" },
{ "push_screen", "DexEntryMenu", "VOLTORB" },
},
-- FuchsiaCityKangaskhanSignText: PrintText(_FuchsiaCityKangaskhanSignText),
-- then DisplayPokedex KANGASKHAN.
TEXT_FUCHSIACITY_KANGASKHAN_SIGN = {
{ "show_text", "_FuchsiaCityKangaskhanSignText" },
{ "mark_seen", "KANGASKHAN" },
{ "push_screen", "DexEntryMenu", "KANGASKHAN" },
},
-- FuchsiaCitySlowpokeSignText: PrintText(_FuchsiaCitySlowpokeSignText),
-- then DisplayPokedex SLOWPOKE.
TEXT_FUCHSIACITY_SLOWPOKE_SIGN = {
{ "show_text", "_FuchsiaCitySlowpokeSignText" },
{ "mark_seen", "SLOWPOKE" },
{ "push_screen", "DexEntryMenu", "SLOWPOKE" },
},
-- FuchsiaCityLaprasSignText: PrintText(_FuchsiaCityLaprasSignText),
-- then DisplayPokedex LAPRAS.
TEXT_FUCHSIACITY_LAPRAS_SIGN = {
{ "show_text", "_FuchsiaCityLaprasSignText" },
{ "mark_seen", "LAPRAS" },
{ "push_screen", "DexEntryMenu", "LAPRAS" },
},
-- FuchsiaCityFossilSignText: CheckEvent EVENT_GOT_DOME_FOSSIL /
-- CheckEventReuseA EVENT_GOT_HELIX_FOSSIL pick the text and the
-- displayed entry; with neither set only the undetermined line prints.
TEXT_FUCHSIACITY_FOSSIL_SIGN = {
{ "check_flag", "EVENT_GOT_DOME_FOSSIL" }, -- 1
{ "jump_if_true", 7 }, -- 2
{ "check_flag", "EVENT_GOT_HELIX_FOSSIL" }, -- 3
{ "jump_if_true", 11 }, -- 4
{ "show_text", "_FuchsiaCityFossilSignUndeterminedText" }, -- 5
{ "jump", "end" }, -- 6
{ "show_text", "_FuchsiaCityFossilSignOmanyteText" }, -- 7
{ "mark_seen", "OMANYTE" }, -- 8
{ "push_screen", "DexEntryMenu", "OMANYTE" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "_FuchsiaCityFossilSignKabutoText" }, -- 11
{ "mark_seen", "KABUTO" }, -- 12
{ "push_screen", "DexEntryMenu", "KABUTO" }, -- 13
},
},
},
}
+41
View File
@@ -85,6 +85,47 @@ return {
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
-- Yellow keeps all three giveaways with the same events and amounts
-- but renames the objects and their text labels: FISHING_GURU ->
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
-- keys above never match there, so Yellow needs its own three (#552).
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
event = "EVENT_GOT_10_COINS",
amount = 10,
askLabel = "_GameCornerFishingGuru1WantToPlayText",
askFallback = "Kid, do you want\nto play?",
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
coinCaseFullFallback = "You don't need my\ncoins!",
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
alreadyGotFallback = "Wins seem to come\nand go.",
}),
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
event = "EVENT_GOT_20_COINS_2",
amount = 20,
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
askFallback = "What's up? Want\nsome coins?",
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
coinCaseFullFallback = "You have lots of\ncoins!",
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
}),
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
event = "EVENT_GOT_20_COINS",
amount = 20,
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
coinCaseFullFallback = "You've got your\nown coins!",
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
},
},
}
+2 -2
View File
@@ -8,9 +8,9 @@ return {
TEXT_LAVENDERMART_COOLTRAINER_M = {
{ "face_player" },
{ "check_flag", "EVENT_RESCUED_MR_FUJI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_LavenderMartCooltrainerMReviveText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_LavenderMartCooltrainerMNuggetText" },
},
},
+4 -4
View File
@@ -13,9 +13,9 @@ M.MR_FUJIS_HOUSE = {
TEXT_MRFUJISHOUSE_SUPER_NERD = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6
},
@@ -25,9 +25,9 @@ M.MR_FUJIS_HOUSE = {
TEXT_MRFUJISHOUSE_LITTLE_GIRL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
},
+2 -2
View File
@@ -12,9 +12,9 @@ return {
TEXT_ROUTE16GATE1F_GUARD = {
{ "face_player" },
{ "check_item", "BICYCLE" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" },
},
},
+2 -2
View File
@@ -14,9 +14,9 @@ return {
TEXT_ROUTE18GATE1F_GUARD = {
{ "face_player" },
{ "check_item", "BICYCLE" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_Route18Gate1FGuardYouNeedABicycleText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" },
},
},
+2 -2
View File
@@ -12,9 +12,9 @@ return {
TEXT_SILPHCO10F_SILPH_WORKER_F = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo10FSilphWorkerFImScaredText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" },
},
},
+2 -2
View File
@@ -10,9 +10,9 @@ return {
-- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText
TEXT_SILPHCO3F_SILPH_WORKER_M = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" },
},
},
+2 -2
View File
@@ -7,9 +7,9 @@ return {
TEXT_SILPHCO4F_SILPH_WORKER_M = {
{"face_player"},
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
{"jump_if_true", 5},
{"jump_if_true", 6},
{"show_text", "_SilphCo4FSilphWorkerMImHidingText"},
{"jump", 6},
{"jump", "end"},
{"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"},
},
},
+2 -2
View File
@@ -7,9 +7,9 @@ return {
TEXT_SILPHCO5F_SILPH_WORKER_M = {
{"face_player"},
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
{"jump_if_true", 5},
{"jump_if_true", 6},
{"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"},
{"jump", 6},
{"jump", "end"},
{"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"},
},
},
+10 -10
View File
@@ -11,9 +11,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_M1 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" },
},
@@ -21,9 +21,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_M2 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" },
},
@@ -31,9 +31,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_F1 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" },
},
@@ -41,9 +41,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_F2 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" },
},
@@ -51,9 +51,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_M3 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" },
},
},
+6 -6
View File
@@ -10,9 +10,9 @@ return {
-- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText
TEXT_SILPHCO7F_SILPH_WORKER_M2 = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" },
},
@@ -22,9 +22,9 @@ return {
-- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText
TEXT_SILPHCO7F_SILPH_WORKER_M3 = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" },
},
@@ -34,9 +34,9 @@ return {
-- set: _SilphCo7FSilphWorkerM4SafeAtLastText
TEXT_SILPHCO7F_SILPH_WORKER_M4 = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" },
},
},
+2 -2
View File
@@ -10,9 +10,9 @@ return {
-- set: _SilphCo8FSilphWorkerMThanksForSavingUsText
TEXT_SILPHCO8F_SILPH_WORKER_M = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" },
},
},
@@ -0,0 +1,175 @@
-- 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 Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
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" },
}
-- The headings list is a two-column menu, which src/ui/Menu.lua does not do
-- (it stacks one column), so the layout lives here (#591). .blackboardLoop:
-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box,
-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and
-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT
-- move wTopMenuItemX between those two columns and swap wMenuItemOffset
-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN
-- are not in wMenuWatchedKeys, so they only slide the cursor and loop.
local BOARD_LABELS = {}
for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end
BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT"
local BOARD_COL_X = { 1, 6 }
local BOARD_ROW_Y = 2
local BOARD_ROWS = 3
local StatusBoard = {}
StatusBoard.__index = StatusBoard
function StatusBoard.new(game, onPick, onQuit)
return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS,
onPick = onPick, onQuit = onQuit }, StatusBoard)
end
-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2),
-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6
-- is QUIT
function StatusBoard:selection()
return (self.col - 1) * BOARD_ROWS + self.row
end
function StatusBoard:update()
local input = self.game.input
if input:wasPressed("up") then
-- wMenuWrappingEnabled is never set here, so both ends are hard stops
if self.row > 1 then self.row = self.row - 1 end
elseif input:wasPressed("down") then
if self.row < BOARD_ROWS then self.row = self.row + 1 end
elseif input:wasPressed("left") then
self.col = 1
elseif input:wasPressed("right") then
self.col = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
-- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch,
-- and B and QUIT share .exitBlackboard
require("src.core.Sound").play(self.game.data, "Press_AB")
local sel = self:selection()
if input:wasPressed("b") or sel > #STATUS_LABELS then
self.onQuit()
else
self.onPick(sel)
end
end
end
function StatusBoard:draw()
Font.drawBox(0, 0, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
for i, label in ipairs(BOARD_LABELS) do
local col = i <= BOARD_ROWS and 1 or 2
local row = i - (col - 1) * BOARD_ROWS
Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8)
end
-- wTopMenuItemX equals the column PlaceString started at, so the cursor
-- covers the blank each label leads with
Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8,
(BOARD_ROW_Y + self.row - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
local function blackboard(game)
local text = game.data.text or {}
local openBoard
-- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
-- and nothing inside the loop clears them again: after a status blurb
-- `jp .blackboardLoop` comes back with the cursor still on the row and
-- column the player just picked. One StatusBoard lives for the whole
-- reading and is re-pushed each pass, so only entering the blackboard
-- resets to the left column / top row (#591).
local board
-- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then
-- calls HandleMenuInput, so the prompt is on screen for exactly as long as
-- the headings list is. That text ends in `done`, not `prompt`
-- (data/text/text_2.asm:646), so PrintText returns with the box still up
-- and never waits for a button: TextBox opts.stay holds it open under the
-- list and these callbacks pop the pair together (#591).
local function closeBoard()
game.stack:pop() -- the headings list
game.stack:pop() -- the held "Which heading" box under it
end
local function pick(i)
closeBoard()
game.stack:push(TextBox.new(game,
text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard))
end
function openBoard()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
nil, { stay = { onShown = function()
board = board or StatusBoard.new(game, pick, closeBoard)
game.stack:push(board)
end } }))
end
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
openBoard))
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,
},
}
+2
View File
@@ -15,6 +15,7 @@ local files = {
"data.scripts.flavor.cerulean_trashed_house",
"data.scripts.flavor.copycats_house_1f",
"data.scripts.flavor.copycats_house_2f",
"data.scripts.flavor.fuchsia_city",
"data.scripts.flavor.game_corner",
"data.scripts.flavor.lavender_cubone_house",
"data.scripts.flavor.lavender_mart",
@@ -60,6 +61,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",
}
+2 -1
View File
@@ -48,7 +48,8 @@ end
if GameVersion.isYellow() then
for _, file in ipairs({ "data.scripts.yellow_gifts",
"data.scripts.yellow_jessie_james",
"data.scripts.yellow_beach_house" }) do
"data.scripts.yellow_beach_house",
"data.scripts.yellow_viridian_old_man" }) do
for mapId, mod in pairs(require(file)) do
MapScripts.attachBase(mapId, mod)
end
+67 -18
View File
@@ -6,6 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
@@ -20,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall)
return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 20 }, -- 2
{ "jump_if_true", 22 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
{ "jump_if_false", 25 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -31,30 +33,45 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "jump_if_false", "end" }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- The received text carries sound_get_key_item (OaksLab.asm
-- OaksLabReceivedMonText); the jingle plays as the box opens
-- (same beat as the Yellow port's starter, #668).
{ "play_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
{ "set_flag", choseFlag }, -- 12
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
{ "hide_object", "OAKS_LAB", ownBall }, -- 13
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14
{ "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
or "BULBASAUR" } }, -- 19
{ "jump", "end" }, -- 20
{ "jump", "end" }, -- 21 (spacer)
-- a leftover ball after the player's pick: Oak turns to face the
-- player and reads the last-mon line instead of re-offering the
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
-- out as Pokémon here.
{ "face_object", 5, "down" }, -- 22
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
-- through into the pre-pick line below (#601 remnant, reported on #600)
{ "jump", "end" }, -- 24
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
}
end
@@ -62,10 +79,22 @@ return {
talk = {
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
-- in, full Pokédex speech, rival exit, Route 22 arm).
TEXT_OAKSLAB_OAK1 = {
{ "face_player" },
-- OaksLabOak1Text leads with the dex-rating branch (#600): with
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or
-- 2+ species owned once the Pokédex is in hand, Oak asks how it is
-- coming and rates it (predef DisplayDexRating). Red keeps the
-- GOT_POKEDEX gate that Yellow's copy of this text drops
-- (data/scripts/oaks_lab_yellow.lua).
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
{ "jump_if_true", "dex_rating" },
{ "check_dex_owned", 2 },
{ "jump_if_false", "no_rating" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "dex_rating" },
{ "label", "no_rating" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
@@ -151,6 +180,14 @@ return {
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
{ "jump", "end" },
-- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text
-- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned
-- tally follows with no button wait (engine/events/pokedex_rating.asm)
{ "label", "dex_rating" },
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
{ "dex_rating" },
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -245,7 +282,12 @@ return {
and y >= 6 then
local rival = ow:npcByIndex(1)
if not rival then return false end
-- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
-- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = {
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
}
-- the rival routes to a free cell beside the player
@@ -283,8 +325,15 @@ return {
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player. The fanfare was dropped here (#683) -- the
-- parcel scene above already plays Music_MeetRival on both arrival and
-- departure (lines 144-146), and this exit should match (#596).
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
ow.runner:run(rows, { npc = rival })
return true
end
+25 -11
View File
@@ -71,8 +71,12 @@ return {
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
-- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
-- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
-- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
-- (17,5) never appears in Yellow (#617)
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", RIVAL, 4, 7 },
@@ -143,17 +147,25 @@ return {
-- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" },
}
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
-- the rival cuts across to the ball WHILE the player standing
-- below it is bumped two tiles right (both movements run in the
-- same beat, so the walk overlaps the shove like the original)
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
-- bytes are Yellow's own step-right encoding, decoded by
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
-- PAD_RIGHT x2 shove is NOT queued alongside it:
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
-- as the rival begins the LAST byte, the step onto the tile the
-- player is standing on. Starting both on one row had Red stroll
-- off the Eevee while the rival was still at the top of the table
-- (#559).
if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "right" }, { wait = false } }
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
-- this one runs concurrently with the shove below
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
rows[#rows + 1] = { "face_player_dir", "left" }
rows[#rows + 1] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4)
rows[#rows + 1] = { "wait", 40 }
-- move_player blocks for both tiles, so the rival has already
-- landed on (7,4); this is just the beat before he turns up
rows[#rows + 1] = { "wait", 20 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
@@ -275,10 +287,12 @@ return {
table.insert(rows, { "label", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 3 })
table.insert(rows, { "label", "exit" })
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
-- player, restore the lab theme
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player (#683).
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
+71 -29
View File
@@ -4,9 +4,11 @@
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
-- 30 SAFARI BALLs and starts the 502-step game
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back
-- so you can't slip past. Returning to the gate ends the game and the
-- worker takes the leftover balls back.
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
-- up through the north warp into the zone. Declining walks you back
-- so you can't slip past. Returning to the gate ends the game, the
-- worker takes the leftover balls back and the auto-walk drops you 3
-- cells below the warp you came in by (#540).
--
-- Step/ball bookkeeping lives in src/world/OverworldController.lua
-- (safariStep/safariGameOver, from
@@ -19,7 +21,33 @@ local FEE = 500
local BALLS = 30
local STEPS = 502
local function startGame(game, t, done, balls, introText)
-- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with
-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the
-- player up out of the gate and through the north warp, it is never left to
-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so
-- the two gate steps taken before the warp fires are charged against
-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter
-- reads 500/500 on arrival even though the script wrote 502 (#540). The
-- port's counter only runs on the nine interior maps (FieldDefaults
-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two
-- steps here instead.
local function walkIntoZone(game, ow)
local p = ow.player
-- only from the two trigger cells in front of the worker, which are the
-- columns the north warps sit on; a player who paid after TALKING to him
-- from somewhere else walks in on their own, as they do today
local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil
if not w then return end
ow:scriptMove(p, "up", 2, function()
local st = game.save.safari
if st then st.steps = st.steps - 2 end
-- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision),
-- so take that warp explicitly once the walk lands on it
ow:takeWarp(w.def)
end)
end
local function startGame(game, ow, t, done, balls, introText)
game.save.safari = { balls = balls or BALLS, steps = STEPS }
game.save.safariNags = nil
local TextBox = require("src.render.TextBox")
@@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText)
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function()
if done then done() end
walkIntoZone(game, ow)
end))
end
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
@@ -52,7 +83,7 @@ local function yellowLowCost(game, ow, t, done, back)
or "\fOh, all right, pay\nme what you have.")
.. "\f" .. (t._SafariZoneLowCostText2
or "But, I can't give\nyou all 30 BALLs.")
startGame(game, t, done, balls, intro)
startGame(game, ow, t, done, balls, intro)
return
end
local nag = game.save.safariNags or 0
@@ -62,7 +93,7 @@ local function yellowLowCost(game, ow, t, done, back)
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
.. (t._SafariZoneLowCostText3
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
startGame(game, t, done, 1, intro)
startGame(game, ow, t, done, 1, intro)
return
end
local nags = {
@@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done)
end
else
game.save.money = game.save.money - FEE
startGame(game, t, done)
startGame(game, ow, t, done)
end
end))
end))
@@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
-- no walks you back into the zone
onEnter = function(game, ow)
if not game.save.safari or ow.player.cellY > 1 then return end
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?",
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
-- back into the zone through the entrance warp
local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1]
ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up")
return
end
game.save.safari = nil
game.stack:push(TextBox.new(game,
(t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText
or "Please return any\nSAFARI BALLs you\nhave left.")
.. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText
or "Did you get a\ngood haul?\fCome again!")))
end))
end))
-- QUEUED, never pushed: onEnter runs inside the arriving warp's
-- Transition midpoint, and Transition:finish pops whatever is on top
-- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
-- swallowed, and on a build where it survived it drew over a screen
-- still faded to black (#540). Same contract as M.HALL_OF_FAME in
-- data/scripts/story.lua.
--
-- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
-- return-balls text, faces the player down and runs
-- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
-- counter row 3 cells below the warp you came in by; NO prints
-- "Good Luck!" and walks one step back up through that same warp.
local rightSide = ow.player.cellX ~= 3
local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
ow:queueScript({
{ "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
{ "jump_if_false", "stay" },
-- the port never reaches SafariZoneGateLeavingSafariScript's own
-- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
-- counter), so the sign-off rides on this path
{ "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" },
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" },
-- no value: set_field assigns nil, which is how save.safari is cleared
{ "set_field", "safari" },
-- move_player runs through scriptMove, which skips onStepComplete, so
-- walking back down past (x,2) cannot re-fire the join trigger
{ "move_player", "down", 3 },
{ "jump", "end" },
{ "label", "stay" },
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" },
{ "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" },
})
end,
}
+43 -1
View File
@@ -11,7 +11,7 @@
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
-- B3F's pluggedByHolesOn) plus the generic
-- OverworldState:boulderIntoHole in src/world/OverworldController.lua;
-- no per-map onEnter hook is needed here.
-- no per-map onEnter hook is needed for the boulders.
local M = {}
@@ -23,4 +23,46 @@ M.VERMILION_GYM = {
end,
}
-- The PLAYER falling down those same holes (#599). field.seafoam's holes
-- carry only the BOULDER's object cell on the floor below (landsAt), not
-- the player's landing, so the player's landing is spelled out here: it
-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData,
-- which the importer does not extract. Same shape as MANSION_HOLES in
-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in
-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has
-- to be an onStep, not a collision block.
--
-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6),
-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords
-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor
-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
-- the original: a plugged hole still drops the player. The B3F and B4F
-- landings are water; setMap's CheckForceBikeOrSurf pass
-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
local HOLE_FALLS = {
SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
{ 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } },
SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
{ 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } },
SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
{ 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } },
SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
{ 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } },
}
for mapId, holes in pairs(HOLE_FALLS) do
M[mapId] = M[mapId] or {}
M[mapId].onStep = function(game, ow, x, y)
for _, h in ipairs(holes) do
if x == h[1] and y == h[2] then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
return true
end
end
return false
end
end
return M
+134 -31
View File
@@ -188,7 +188,12 @@ M.BILLS_HOUSE = {
end
if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around
-- (.PokemonWalkAroundPlayerMovement)
-- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs
-- BillsHousePikachuWatchPlayer first on this branch, so a
-- Pikachu that is still following steps clear of Bill's detour
-- and turns to watch the player (#455).
require("src.world.PikachuFollower")
.onBillWalksAroundPlayer(game, ow)
ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
@@ -600,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
}
end
-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run
-- HideObject in the same breath as the battle that sets
-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a
-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a
-- save edited or migrated from a build older than the flag), and it is a
-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax
-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/
-- item_effects.asm), so the sleeper sits in the road forever and Cycling
-- Road is unreachable (#585). Reconcile the toggle from the flag on every
-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the
-- same repair shape the Silph Co. floors use below.
local function hideBeatenSnorlax(mapId, objName, beatFlag)
return function(game, ow)
if not game.save.flags[beatFlag] then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
mapId, objName)
end
end
-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via
-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax;
-- objName/beatFlag let ItemEffects find the NPC and check whether it's
-- already been beaten before allowing the wake.
M.ROUTE_12 = {
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
"EVENT_BEAT_ROUTE12_SNORLAX"),
snorlaxWake = {
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
@@ -614,6 +641,8 @@ M.ROUTE_12 = {
}
M.ROUTE_16 = {
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
"EVENT_BEAT_ROUTE16_SNORLAX"),
snorlaxWake = {
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
@@ -645,11 +674,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
M.WARDENS_HOUSE = {
talk = {
TEXT_WARDENSHOUSE_WARDEN = {
-- Labelled rather than hand-numbered: the branches here have been
-- re-pointed twice now (#535, #645), and every insert used to mean
-- renumbering three jumps that had no way of announcing they were stale.
{ "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, so the warden said nothing on every
-- visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
{ "jump_if_true", "got_hm04" }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "jump_if_false", "no_teeth" }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
{ "take_item", "GOLD_TEETH", 1 }, -- 7
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
@@ -658,9 +694,27 @@ 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)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", "end" }, -- 13 (jp .done)
-- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
-- and the warden answers the same gibberish either way -- Gibberish2
-- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port
-- printed the question and walked off before the answer.
{ "label", "no_teeth" }, -- 14
{ "ask", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump_if_true", "gibberish_yes" }, -- 16
{ "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17
{ "jump", "end" }, -- 18
{ "label", "gibberish_yes" }, -- 19
{ "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20
{ "jump", "end" }, -- 21
-- #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.
{ "label", "got_hm04" }, -- 22
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
},
},
}
@@ -712,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
end
end
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
-- speech never played and every rocket blinked out in front of the player
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
-- fade can hold over it.
local function silphAftermathRows()
local rows = {
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
{ "fade", "out" },
}
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
for _, name in ipairs(floor[2]) do
rows[#rows + 1] = { "hide_object", floor[1], name }
end
end
rows[#rows + 1] = { "wait", 3 } -- Delay3
rows[#rows + 1] = { "fade", "in" }
return rows
end
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -738,11 +814,15 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
-- rocket leaves off-screen (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
-- battle's own callbacks are still unwinding, so queueScript starts
-- it on the first idle overworld frame -- after the end-battle
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow)
ow:queueScript(silphAftermathRows())
end
end)
end)
@@ -888,6 +968,7 @@ M.VICTORY_ROAD_3F = {
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
@@ -926,40 +1007,62 @@ M.VICTORY_ROAD_3F = {
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
-- "end" rather than a row number past the tail: this script grew by a row
-- when the follow-Oak walk landed (#704), which silently turned the old
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
-- playBattle("final") then no-ops on the same song, so the theme stays
-- continuous into the fight
{ "play_music", "Music_FinalBattle" }, -- 5
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
-- losing halts here; the numeric target this replaced pointed at the
-- closing warp, which inducted a player who had just lost the fight (#704)
{ "jump_if_false", "end" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
{ "show_text", "_ChampionsRoomOakText" }, -- 12
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
{ "face_object", 1, "left" }, -- 15
{ "face_object", 2, "down" }, -- 16
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
{ "face_object", 2, "right" }, -- 18
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
{ "face_object", 2, "down" }, -- 20
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip.
{ "move_player", "up", 3 }, -- 24
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
{ "set_field", "pendingHallOfFame", true }, -- 25
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
}
M.CHAMPIONS_ROOM = {
+130 -11
View File
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
},
}
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
-- while the clerk keeps talking in the bottom box: the original never
-- erases it before TextScriptEnd (#568).
local BikeShopWindow = {}
BikeShopWindow.__index = BikeShopWindow
function BikeShopWindow.new(game, footer, onChoose)
local self = setmetatable({}, BikeShopWindow)
self.game = game
self.onChoose = onChoose
self.index = 1
self.active = true
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
-- long as the menu is up. The text box pushed on top types it out and
-- pops itself; this copy of its last page takes over from there, so the
-- bottom box never blanks between the pitch and the answer. Paginated
-- with the themed column budget so the copy breaks where the box did.
local TextBox = require("src.render.TextBox")
local box = require("src.ui.Theme").textBox or {}
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
self.footer = pages[#pages]
return self
end
function BikeShopWindow:update()
-- one answer only: the boxes pushed by onChoose sit on top of this
-- state, but a second A on the same frame must not fire it twice
if not self.active then return end
local input = self.game.input
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
if input:wasPressed("up") then
self.index = 1
elseif input:wasPressed("down") then
self.index = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
require("src.core.Sound").play(self.game.data, "Press_AB")
self.active = false
self.onChoose(not cancelled and self.index == 1)
end
end
function BikeShopWindow:draw()
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
Font.drawBox(0, 0, 17, 6)
love.graphics.setColor(0, 0, 0, 1)
local bike = self.game.data.items.BICYCLE
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
if self.footer then
-- same geometry TextBox resolves against, so a themed box matches
local box = Theme.textBox or {}
local tx, ty = box.tx or 0, box.ty or 12
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.footer) do
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
M.BIKE_SHOP = {
talk = {
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
local Bag = require("src.inventory.Bag")
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox")
if (game.save.inventory.BICYCLE or 0) > 0 then
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
game.save.inventory.BIKE_VOUCHER = nil
game.save.inventory.BICYCLE = 1
game.stack:push(TextBox.new(game,
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
:format(game.save.player.name), done))
else
game.stack:push(TextBox.new(game,
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
local t = game.data.text
local function say(text, after, opts)
game.stack:push(TextBox.new(game, text, after, opts))
end
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
-- setting the event still have the bike in the bag, so either counts.
if (game.save.inventory.BICYCLE or 0) > 0
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
return
end
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
say(t._BikeShopClerkOhThatsAVoucherText, function()
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
-- the BICYCLE is actually in the bag
if not Bag.add(game.save, "BICYCLE", 1) then
say(t._BikeShopBagFullText, done)
return
end
Bag.remove(game.save, "BIKE_VOUCHER", 1)
Flags.set(game.save, "EVENT_GOT_BICYCLE")
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
-- a button once the jingle has played (auto.wait, #247)
say(t._BikeShopExchangedVoucherText, done, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(game.data, "Get_Key_Item")
end },
})
end)
return
end
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
say(t._BikeShopClerkWelcomeText, function()
local pitch = t._BikeShopClerkDoYouLikeItText
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
local function comeAgain()
say(t._BikeShopComeAgainText, function()
game.stack:pop() -- the window, still up under the text
done()
end)
end
if bought then
-- a million is out of anyone's reach: BikeShopCantAffordText
say(t._BikeShopCantAffordText, comeAgain)
else
comeAgain()
end
end))
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
-- HandleMenuInput: the box types out and hands over without
-- waiting, leaving the window's copy of the line on screen
say(pitch, nil, { auto = { delay = 0 } })
end)
end,
},
}
+272 -89
View File
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
-- version of this port turned the player back
-- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
-- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true
if not game.save.inventory.SILPH_SCOPE then
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
-- (core.asm:6698-6700). The scope does not skip the disguise, it
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
-- before the battle proceeds as an ordinary wild one (#492).
if game.save.inventory.SILPH_SCOPE then
battle:makeUnveiledGhost()
else
battle:makeGhost()
end
battle.onFinish = function(result)
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
end))
end,
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
-- never matched there and the LIFT KEY never dropped (#552, a
-- regression of #105). Yellow also moves the drop off the second
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, function()
-- engageTrainer records the win before it calls back, so this
-- is the end-battle text's SetEvent + ShowObject
if ow:trainerDefeated(npc)
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end)
return
end
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FRocketAfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!", done))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
@@ -502,146 +541,290 @@ M.GAME_CORNER = {
end))
end)
end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
-- object const and from every one of his text labels
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
-- identical body, so each line resolves under both spellings and the
-- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
-- window in the top right holding MONEY at (12,2) over the amount on
-- row 3 and COIN at (12,4) over the count on row 5. Both
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
-- so both numbers read plain and right-aligned against the inner edge
-- at column 18. The asm draws the box before the offer and redraws it
-- after the purchase, so it stands for the whole exchange: a draw-only
-- state under the dialogue gets that lifetime, since StateStack draws
-- every state above the last opaque one and updates only the top
-- (src/core/StateStack.lua), and reading save each frame is the
-- redraw (#624).
local coinBox = { draw = function()
Font.drawBox(11, 0, 9, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("MONEY"), 96, 16)
local money = ("¥%d"):format(game.save.money or 0)
Font.draw(money, 152 - Font.width(money), 24)
Font.draw(Strings("COIN"), 96, 32)
local coins = ("%d"):format(game.save.coins or 0)
Font.draw(coins, 152 - Font.width(coins), 40)
love.graphics.setColor(1, 1, 1, 1)
end }
game.stack:push(coinBox)
-- Every branch below finishes here. A TextBox pops itself before its
-- onDone runs, so the coin box is top of the stack again by then and
-- this pop takes it down, never someone else's state.
local function finish()
game.stack:pop()
done()
end
-- YesNoChoice is called with the offer still printed, so the prompt
-- has to ride the open text box (opts.choice) instead of being pushed
-- after it closes, which is what made the question vanish (#624).
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."),
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1PleaseComePlaySometimeText
or "No? Please come\nplay sometime!", done))
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), finish))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1DontHaveCoinCaseText
or "You don't have a\nCOIN CASE!", done))
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), finish))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CoinCaseIsFullText
or "Oops! Your COIN\nCASE is full.", done))
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), finish))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CantAffordTheCoinsText
or "You can't afford\nthe coins!", done))
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), finish))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
-- CoinsText; the new count belongs in the coin box the asm
-- redraws here, not appended to the line (#624)
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1ThanksHereAre50CoinsText
or "Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!"), finish))
end }))
end,
},
}
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
-- one handler answers both ids. Without the alias the Yellow clerk fell
-- through to his extracted offer line with no yes/no box behind it (#552).
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
-- Each counter owns ONE window of three prizes, not the whole catalogue:
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
-- version; the TM window is identical in all three, so it is shared.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
local RED_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
},
{
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
local BLUE_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
},
{
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
},
PRIZE_TMS,
}
-- Yellow keeps the three windows but restocks both mon counters
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
local YELLOW_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
},
{
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
local function prizeWindow(n)
local GameVersion = require("src.core.GameVersion")
local windows = RED_PRIZE_WINDOWS
if GameVersion.isBlue() then
windows = BLUE_PRIZE_WINDOWS
elseif GameVersion.isYellow() then
windows = YELLOW_PRIZE_WINDOWS
end
return windows[n]
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
-- buying again means talking to the counter again (#623).
local function prizeCounter(window)
return function(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(prizeWindow(window)) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
table.insert(items, { label = "NO THANKS" })
local list
-- close the window first: every ending in HandlePrizeChoice leaves
-- the menu for good, and the closing line belongs over the map
local function finish(msg)
list:close()
game.stack:push(TextBox.new(game, msg, done))
end
local function buy(p)
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
return
end
-- HasEnoughCoins passed, so hand the prize over first and only
-- subtract once it landed: the asm rets before .subtractCoins when
-- the bag is full, or when both the party and every box are full
local roomless = t._OopsYouDontHaveEnoughRoomText
or "Oops! You don't\nhave enough room."
if p.kind == "mon" then
-- no runner here, so give_pokemon reports through ctx.lastCheck
-- and skips the AskName prompt (Commands.give_pokemon)
local ctx = { save = game.save, game = game }
Commands.give_pokemon(ctx, p.species, p.level)
if not ctx.lastCheck then
finish(roomless)
return
end
elseif not require("src.inventory.Bag").add(
game.save, p.item, 1, game.data) then
finish(roomless)
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
-- no thank-you line: HereYouGoText is unreferenced in the asm,
-- which just redraws the coin box (PrintPrizePrice) and returns
list:close()
done()
end
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
list:close()
done()
return
end
local name = (p.kind == "mon")
and game.data.pokemon[p.species].name
or game.data.items[p.item].name
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
-- is not one of TextBox's RAM tokens, so fill it in here
local ask = (t._SoYouWantPrizeText
or "So, you want\n{RAM:wNameBuffer}?")
:gsub("{RAM:wNameBuffer}", name)
game.stack:push(TextBox.new(game, ask, nil, {
choice = function(yes)
if not yes then
finish(t._OhFineThenText or "Oh, fine then.")
return
end
buy(p)
end,
}))
end,
onCancel = done,
})
game.stack:push(list)
end))
end
end
M.GAME_CORNER_PRIZE_ROOM = {
talk = { -- the three prize counters are bg events
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
talk = { -- the three prize counters are bg events, one window each
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
},
}
+38 -3
View File
@@ -49,7 +49,7 @@ local function gift(opts)
end
end)
end
if opts.pre then say(opts.pre, "", give) else give() end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end
end
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
},
}
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore, and on Red it sits outside the extractor's symbol
-- set, so the literal from text/ViridianCity.asm rides along as the
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_FISHER = gift({
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
pre = "ViridianCityFisherYouCanHaveThisText",
preFallback = "Yawn!\nI must have dozed\voff in the sun."
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
.. "\vWhat's this?\vWhere did this TM\vcome from?"
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
received = "_ViridianCityFisherReceivedTM42Text",
explain = "_ViridianCityFisherTM42ExplanationText",
noRoom = "_ViridianCityFisherTM42NoRoomText",
@@ -176,6 +186,20 @@ M.ROUTE_18_GATE_2F = {
{ "face_player" },
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC
},
-- Yellow replaces the youngster with a cook trading SPIKE
-- (TANGELA -> PARASECT): pokeyellow/scripts/Route18Gate2F.asm
-- Route18Gate2FCookText runs TRADE_FOR_SPIKE, index 6 in the Yellow
-- TradeMons table that Data:applyVersionedFieldData swaps in. Red
-- maps have no COOK object here and Yellow maps have no YOUNGSTER,
-- so each version only ever fires its own row (#651). Both rows
-- share the Red-flavoured done flag on purpose: a .sav tracks
-- "trade slot 6 completed" in one wCompletedInGameTradeFlags bit
-- either version reads, and the save codec maps that bit to this
-- flag name (src/save_convert/GenSave.lua EXTRA_FLAG_BITS).
TEXT_ROUTE18GATE2F_COOK = {
{ "face_player" },
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- SPIKE (Yellow)
},
},
}
@@ -790,8 +814,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)
+1
View File
@@ -118,6 +118,7 @@ local MANSION_HOLES = {
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
for _, h in ipairs(MANSION_HOLES) do
if x == h[1] and y == h[2] then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
return true
end
+10 -2
View File
@@ -108,8 +108,16 @@ return {
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
-- but he has no def_trainers header on 11F, so engageTrainer finds no
-- header.won to give it -- this chain is the port's stand-in for that loss
-- line (#722). The "Blast it all!" speech, the fade and the rockets
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
-- (data/scripts/story.lua).
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
+151
View File
@@ -0,0 +1,151 @@
-- The Viridian City old-man catch tutorial, Yellow's way
-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
-- scripts/OaksLab.asm). Registered on top of the shared tables by
-- data/scripts/init.lua on a Yellow boot.
--
-- Yellow has TWO gambler objects where Red/Blue have one:
-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
-- toggle stays OFF forever in Yellow, no script ever shows it.
-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
--
-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
-- yes/no script must NOT run against him: Yellow's
-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
-- invented yes/no over it -- YES printed the TimeIsMoney alias
-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
-- talk, forever (#617).
--
-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
-- east of the sleeper's cell -- faces the old man right and the player
-- left, prints the apology, and without any choice runs the demo battle
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
-- three times and breaks open. After it, the same text pointer
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
-- I must be losing my touch."), the old man walks off (down 6 with the
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
local M = {}
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
-- Capture the FUNCTION, not the table: attachBase stores the module
-- table itself, so once this file's onStep is attached the table's slot
-- points back here -- delegating through the table would self-recurse.
-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
-- markers (line -> \n, cont -> \v, para -> \f)
local function text(game)
return {
apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
.. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
.. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
.. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
}
end
-- The row list for the initial-tutorial branch, keyed by where the
-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
-- else walks him right 1 after moving the follower Pikachu aside).
local function oldMan2Rows(game, ow, npc)
local rows = {
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
-- ViridianCityOldManInitialCatchTrainingScript sets
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
-- ItemUseBall's .oldManBattle branch turns that event into anim data
-- $63: three shakes, then the ball breaks open. The losing-my-touch
-- line below only follows a throw that failed (#636).
{ "old_man_demo", "fail" },
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
}
if ow.player and ow.player.cellX == 19 then
rows[#rows + 1] =
{ "walk_npc", npc.def.index,
{ "down", "down", "down", "down", "down", "down" } }
else
-- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
-- steps out of the old man's way before he turns right
local PikachuFollower = require("src.world.PikachuFollower")
local pika = ow and PikachuFollower.current(ow)
if pika then
rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
end
rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
end
rows[#rows + 1] =
{ "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
return rows
end
-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
local function oldMan2Talk(game, ow, npc, done)
if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
return
end
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
end
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
},
-- Re-apply the Pokédex swap for a save that already holds the flag but
-- was never standing here when it fired (converted .sav imports, same
-- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
-- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
-- OLD_MAN a save made by the pre-#617 build left standing.
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
end,
-- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
-- tutorial undone, (19,9) -- the gap east of the old man, the same
-- cell the sleeper used to gate -- faces him right, turns the player
-- left and starts the OLD_MAN2 flow with no choice.
onStep = function(game, ow, x, y)
if baseViridianStep and baseViridianStep(game, ow, x, y) then
return true
end
local flags = game.save.flags
if not flags.EVENT_GOT_POKEDEX then return false end
if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
if x ~= 19 or y ~= 9 then return false end
local man
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
end
if not man then return false end
man.facing = "right"
ow.player.facing = "left"
oldMan2Talk(game, ow, man, nil)
return true
end,
}
return M
+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
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+37 -8
View File
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
matches the open game tab, so a shared `imports/` folder with Red+Yellow
cannot jump Yellow → Red.
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
@@ -150,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
- A mod that declares `github` shows its total GitHub downloads (every
release's summed asset `download_count`, from the same cached release
fetch the update check uses) as a highlighted body line like "12,345
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
(first and latest `published_at`). Old cache entries written before the
counts existed show no line rather than a wrong zero; a manual check
refreshes them.
- The MODS panel sorts its rows by Name, Popularity (downloads),
Release date (first release), or Last updated, chosen by chips under the
header and persisted in `options.modSort`. Mods without release data
(no `github` field, or a stale cache) sink to the bottom of data sorts.
## Import / Export save
@@ -157,9 +171,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
- **Import save** is live once the game's ROM is imported (playable).
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs.
On **NX (Switch)** there is no picker: copy a `.sav` into
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
(one folder per game), then press **Import save** on that games tab to
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
@@ -167,19 +186,29 @@ through `src/import/SaveFileIO.lua`, which sits on top of
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
panel is refreshed with the new slot selected. On **NX**, a successful inbox
import retires the file to `*.sav.imported` and records a content hash in
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
bytes under a new name) does not clone slots; failures leave the original
`.sav`. Only that games folder is scanned.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
root `persistFs` writes slots to: the portable game folder when `portable.txt`
marks the install, otherwise the save directory (`exports/` and
`exports/<version>/` are created as needed; #752). On desktop it returns the
absolute path (`SaveData.portableBaseDir()` when portable, else
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
On **NX**, export success sets a notice with the `exports/<game>/` path and an
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
SD / FTP instead).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
+13
View File
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
normal single-window composite so the mod can decorate around it. `ctx` carries
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
for driving a second physical display. This is what lets a mod lay the two
passes out as two stacked Game Boy screens, or push one onto a second screen,
without the engine knowing the layout.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+177 -16
View File
@@ -1,10 +1,10 @@
# New features (deliberate additions beyond the original)
Intentional enhancements this port adds on top of faithful Pokémon Red
behavior. They have no Game Boy equivalent and are kept by design.
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
Genuine divergences from the original (things still missing, wrong, or
approximated) live in docs/known-differences.md; faithfully-ported
behavior is in docs/behavior-porting-notes.md.
approximated) live in docs/known-differences.md; faithfully-ported behavior is
in docs/behavior-porting-notes.md.
## Survey zoom
@@ -143,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
@@ -157,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
@@ -167,10 +209,15 @@ duration:
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
mods enabled offers to switch them all off and relaunch (mods merge at
boot, so a restart is the only way). The restart is confirmed, not
silent. They stay listed as disabled, ready to switch back on.
- **Online play runs vanilla, except for your language.** Picking ONLINE
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
off and relaunch (mods merge at boot, so a restart is the only way). The
restart is confirmed, not silent. They stay listed as disabled, ready to
switch back on. A mod that declares itself a translation and provably
writes nothing but text stays on: the two games hash the same link
surface, so a Spanish install and an English one can battle and trade,
each reading the game in its own language and naming the other player's
party out of its own text.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
@@ -272,12 +319,29 @@ 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.
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
to hide the overlay permanently (for controllers / emulation handhelds --
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
**Done** to save into `options.lua` as normalized window fractions so a
different screen keeps the relative placement.
Portrait and landscape are edited and saved separately (#633): the editor
follows whichever orientation is on screen, and **Reset** only clears that
one, so a layout that works held upright does not have to double as the
one used sideways. An `options.lua` from before this split keeps its single
layout in both orientations until one of them is edited. In-game, Options →
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Screen orientation lock (Android)
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
allows every orientation but defers to the system: with auto-rotate turned
off in Android's quick settings, the game stays put instead of following
the sensor (#716). Changes apply immediately -- the screen rotates as the
row is stepped -- and persist in `options.lua`. Android only: iOS follows
the app's fixed orientation list, and desktop windows rotate nothing.
## Translation support
@@ -312,6 +376,28 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
A translation can also skip glyph pages entirely: scaffolding with
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
an existing mod) renders text through a bundled TTF covering Latin with
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
`spacing` added to every advance, `yOffset` for vertical alignment
against the 8px cell grid, `bold`, which double-prints at a 1px
offset for fonts whose strokes read too light, and `tiles`, the
characters that keep their ROM tile instead of coming from the TTF.
`tiles` matters for a CJK translation. Sizing the font so a kana fills
the 8px cell leaves Latin narrower than the tile font it replaces, which
pulls the numeric columns out of line: the party menu's `:L12` stops
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
vanilla tiles, so numbers render exactly as they do in English while
kana still come from the font. It takes a string of characters, or a
list when a multi-character charmap sequence is meant.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
@@ -338,8 +424,11 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
with no slot cap, and the eight badges as toggle chips.
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips. The picker, the bag
and PC storage all scroll under the mouse wheel, so the whole catalog is
reachable one-handed without typing a query.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
@@ -390,3 +479,75 @@ own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
A feed author can publish per-mod release stats by adding three optional
fields to an entry -- `downloads` (total across every release), and
`first_release` / `last_release` (ISO days) -- which the listing shows in
the same gold line the MODS tab uses. When a feed does not carry them,
the row fetches the mod's own GitHub releases instead -- the same cached
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
appear for any mod with a `github` field regardless of feed maintenance.
The fields are additive: feeds that carry them stay readable by every
build that predates them, and feeds that do not render exactly as before.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
## Controls rebinding (CONTROLS screen)
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
key and controller button side by side (Z/A). Press A on a row, then press
and release the key or pad button you want; the rebind commits on the
release. If that input already belongs to another row, the two rows swap,
so no button is ever stranded without an input and no input ever serves
two buttons. Holding a second key or pad button while the first is still
down backs out of the capture without touching a keyboard; Escape still
cancels too. SELECT clears one row back to its default, and START resets
every binding after a confirmation.
Controllers a system has no mapping for (common on Linux handhelds and
off-brand pads) report bare button numbers rather than names. Those are
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
controller column. Recognized controllers are read only through their
named buttons, so a rebind on those is never shadowed by the factory
layout underneath it.
## Mod profiles (#593)
The mod manager's PROFILES tab holds named setups. A profile remembers which
mods are on, every mod's own options, and which save slot each game version
plays, so swapping profiles swaps the whole playthrough and not just the mod
list. The setup that existed before profiles shipped becomes PROFILE 1 the
first time the manager opens.
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
save directory; drop a `.g1rmodlist` someone shared into that folder and
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
clash gets a number). Mods the shared profile names but that are not installed
are reported when the profile is applied; installing them is still a manual
trip through the mods list or Find Mods.
## Windows: no console windows on launcher actions
Checking for updates, browsing a mod index, adding a mod repo, installing a
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
process. On Windows those children used to each open their own console
window, so a session could end up buried under half a dozen of them. The
game now claims one console for itself at boot and hides it; the children
inherit that invisible console and nothing pops up. Nothing else changes:
file pickers are ordinary desktop dialogs and still appear normally, and a
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
out.
+8 -7
View File
@@ -1,12 +1,11 @@
# What This Port Requires
The packaged desktop app requires one user-supplied input on first boot: a
canonical 1 MiB US Pokemon Red ROM.
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
Console releases, and Pokemon Blue are rejected rather than decoded with
incorrect addresses.
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
for specific hashes). Other revisions and Virtual Console releases are rejected
rather than decoded with incorrect addresses.
After verification, the app generates its private cache in the LÖVE save
directory. It does not keep a copy of the ROM. Later boots use the cache.
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
## Bundled Metadata
Assembly removes high-level names and some relationships that the Lua port
needs. `tools/rom_manifest.json` therefore contains:
needs. The version-specific files `tools/rom_manifest.json`,
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
contain:
- the 3,268 ROM symbol addresses actually read by the extractor
- the ROM symbol addresses actually read by the extractor
- symbolic IDs and ordering for maps, species, moves, items, and trainers
- source-erased dimensions, image names, and map object integration names
- hand-ported field/script integration tables
+96
View File
@@ -0,0 +1,96 @@
# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
## Status
Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
`mod_world_tests.lua`.
**Regeneration required.** The manifest and sprite sheet update by
re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
checkout, then re-importing the Yellow ROM.
## Motivation
Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
when the party mon that knows SURF is a Pikachu. The recomp misses this
in two places:
1. **Extraction.** `SurfingPikachuSprite` is not in
`SpriteSheetPointerTable` — loaded by its own `ld de,` like
`RedBikeSprite`. The extractor never sees it, and the symbol is not
in the Yellow manifest.
2. **Engine rule.** `field.playerSprites.surf` is one static
(`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
is a Pikachu."
## The decision it extends
No prior D-number. Extends the surf-field-move port in
`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
with the player-sprite swap vanilla runs alongside it.
## The exact API delta
Backward-compatible, additive-only.
### `field.playerSprites.surfPikachu`
New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
lands the ride keeps the Seel — no plain on-water Pikachu.
### `Player.surfPikachuSprite`
`Player.new` builds a second `SpriteRenderer` when the field resolves.
`pose()` picks it when `surfing and surfingPikachu`.
### `Player.surfingPikachu` (runtime)
Runtime-only boolean (not persisted); re-derived so a party change
between save and load is honored.
### `OverworldState:syncSurfingPikachu()`
Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
warpToHealPoint, forced-surf tile, setMap boot-restore.
### Importer — `SPRITE_SURFING_PIKACHU`
`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
each gain a parallel extract mirroring `RedBikeSprite`.
### `PaletteFX.spriteObp`
`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
special case, wearing the player's OBP palette so it colors in GBC mode.
## Migration note for existing mods
**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
only resolves on a Yellow import after regeneration. No manifest or
`mod.save` shape changes. An eligibility hook that swaps a rental
SURF-mon still drives the sprite pick via `partyKnows`.
## Parity tests
- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
`surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
`world & maps v2` suite stays green.
- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
+ `Player:pose` across four party shapes (12/12). The existing
`parity_cinnabar_east_surf.lua` (24/24) stays green.
## Deprecation etiquette
Nothing deprecated. Additive: a new `field.playerSprites` key, a new
runtime flag, a new engine method, a new sprite id.
+204
View File
@@ -0,0 +1,204 @@
# Build the Nintendo Switch NRO — contributor guide
Want to play a release build instead? Download the SD-ready zip and extract it
at your microSD root — see [switch-install.md](switch-install.md).
This guide is for contributors who build Gen1Recomp for Switch from source.
Hardware evidence, MTP operator loops, and deeper notes live in
[switch-development.md](switch-development.md).
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
> evidence: **OLED** (author) and **V1 boot** (community). See
> [switch-development.md](switch-development.md) for known limitations.
---
## Prerequisites by OS
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
WSL — not cmd.exe or PowerShell (AD-008).
### macOS / Linux
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
2. Install Switch tools:
```sh
sudo dkp-pacman -S switch-dev
```
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
builds can fall back to the pinned image when native tools are missing.
### Windows (Git Bash / MSYS2 / WSL)
1. Use a bash environment:
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
packages (preferred for native `nacptool`/`elf2nro`), or
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
WSL if Docker bind-mounts from Git Bash paths misbehave.
2. Install `switch-dev` (or rely on Docker fallback — see below).
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
### What you must install yourself
| You install | Script does **not** install |
| ----------- | --------------------------- |
| bash, git, zip tooling the repo already expects | — |
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
| Docker (optional fused fallback) | Docker Engine |
| A legal `.gb` ROM (to play) | Any ROM or game data |
---
## Mode glossary
`scripts/build_switch.sh` supports three modes (combinable as noted):
| Mode | What it does |
| ---- | ------------ |
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
| `--loose` | Packs `game.love`, copies pinned `love.nro``dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
Rules:
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
### What `--fetch` downloads
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
**not** install:
- devkitPro / `dkp-pacman` / `switch-dev`
- Docker
- ROMs, saves, or mods
---
## Native tools, then Docker
Fused packaging (`scripts/switch/build_fused.sh`):
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
2. Else fall back to Docker using:
- `GEN1_DKP_IMAGE` if set, otherwise
- the image named in `scripts/switch/dkp-docker.image` (default
`devkitpro/devkita64:latest`).
If neither native tools nor Docker work, the script exits non-zero with
macOS / Linux / Windows / Docker hints and a pointer to this doc.
---
## Example commands
From the repo root:
```sh
# Download pinned love-nx only
scripts/build_switch.sh --fetch
# Loose pair for iteration (fetch + assemble)
scripts/build_switch.sh --fetch --loose
# Single fused NRO + SD-ready zip for a release-like artifact
scripts/build_switch.sh --fetch --fused --version 0.2.0
```
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
Offline packaging smoke (no network, no nacptool required):
```sh
bash scripts/switch/selftest_build_switch.sh
bash scripts/switch/verify_payload.sh --self-test
```
---
## CI and release
Switch packaging has three automated surfaces (same policy as AD-010):
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
When a change touches Switch packaging / Switch docs / NX runtime paths
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
`tests/engine/assets_version_fallback_test.lua`,
`tests/engine/nx_generated_guard_test.lua`,
`tests/engine/nx_yellow_boot_test.lua`,
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
or the Switch-related workflow YAML), CI runs:
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
`scripts/switch/selftest_build_switch.sh`,
`scripts/switch/verify_payload.sh --self-test`,
`luajit tests/switch_ci_workflows_test.lua`,
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
`luajit tests/engine/nx_generated_guard_test.lua`,
`luajit tests/engine/nx_yellow_boot_test.lua`).
2. **Fused NRO build** only on the **canonical** repository
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
head is that repo (same-repo push/PR). **Fork repository** CI never runs
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
still runs) so untrusted head code is not executed on the self-hosted Mac;
iOS device build eligibility is unchanged. Fused also waits for a successful
offline selftest before starting on the Mac runner.
3. On successful PR fused builds, a follow-up workflow posts a PR comment
linking the Actions artifact named `gen1recomp-switch-nro`
(comment tag `switch-build-result`; see
`.github/workflows/switch-artifact-comment.yml`).
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
### Release hard-fail (`.github/workflows/release.yml`)
GitHub Releases always build Switch on the same self-hosted Mac runner as the
other platforms — this is a **hard gate** (no `continue-on-error`):
```sh
scripts/build_switch.sh --fetch --fused --version "<release version>"
```
A Switch packaging failure fails the entire release job. The release asset is
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
`dist/switch/` for the packer and for PR CI artifacts.
### Runner provisioning
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
`elf2nro`) **and/or Docker** available. CI and release do not silently run
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
---
## Limitations / non-goals
These scripts and this guide do **not**:
- Push files to the console (no automated MTP / FTP / SD scripting)
- Bundle or download any Pokémon ROM
- Install `dkp-pacman` / `switch-dev` for you
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
- Validate **Applet Mode** — use title override (hold **R**) for full memory
Player install steps: [switch-install.md](switch-install.md).
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
[switch-transfer.md](switch-transfer.md).
Hardware depth and evidence: [switch-development.md](switch-development.md),
[switch-hardware-evidence.md](switch-hardware-evidence.md).
+528
View File
@@ -0,0 +1,528 @@
# Nintendo Switch development (love-nx)
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
> extract/merge at microSD root; title override required. See
> [Known limitations](#known-limitations-read-before-reviewing).
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
- Players → [switch-install.md](switch-install.md)
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
This document covers what landed, known limitations, how hardware was tested,
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
the transfer runbook).
## Acknowledgments
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
## Status
| Area | State |
| ---- | ----- |
| Feature | **Available** — playable fused NRO path (issue #531) |
| Runtime | Pinned love-nx **`11.5-nx1`** |
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
### What landed
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
- Payload gates so ROM / generated cache / saves never enter `game.love`
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
### Known gaps / welcome contributions
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
- Switch Lite and fuller Pro Controller / third-party pad matrices
- Applet Mode remains unsupported by design (title override required)
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
## Design references (Dusklight)
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
| Dusklight lesson | How Gen1Recomp applied it |
| ---------------- | ------------------------- |
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
| Prove the lower layer first | `tools/switch-probe` before full launcher |
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
| Full memory matters | Title override; Applet Mode is not the validation path |
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
| Isolate platform code | Capability module instead of Android flag overload |
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
The packaging goal matches Dusklights **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
## Known limitations (read before reviewing)
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
## How we tested
| Layer | What | Where |
| ----- | ---- | ----- |
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
## love-nx 11.5-nx1 (pinned)
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
**Local layout (not committed):**
```text
.bazinga/love-nx/11.5-nx1/
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
```
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
### Fetch instructions
Preferred (automated checksum verify):
```bash
scripts/build_switch.sh --fetch
```
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
[switch-build.md](switch-build.md) for the full mode glossary.
Manual fallback:
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
3. Move both files into that directory.
4. Confirm checksums match the manifest:
```bash
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
.bazinga/love-nx/11.5-nx1/love.elf
```
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
## Loose-mode dist layout
Development builds place `gen1recomp.nro` and `game.love` side by side:
```text
dist/switch/loose/
├── gen1recomp.nro
└── game.love
```
Assemble with:
```bash
scripts/build_switch.sh --loose
```
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
## Transfer & deploy (current contributor loop)
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
[switch-transfer.md](switch-transfer.md). Summary:
| Layer | Intent |
| ----- | ------ |
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
**Still avoided for routine evidence** (keeps SD handling honest):
- Treating `nxlink` / netloader as the release deploy story (deferred)
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
### Manual deploy checklist (today)
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
## OpenMTP + DBI transfer (loose build, Mac evidence example)
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
The numbered Mac loop below reproduces the OLED evidence path.
### On the Switch
1. Close Gen1Recomp if it is running.
2. Open **DBI** from hbmenu.
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
4. Keep DBI on that screen for the entire transfer.
5. Connect the Switch to the Mac with a USB-C data cable.
### On the Mac
1. Close any other MTP clients.
2. Open **OpenMTP** and select the DBI device.
3. In the remote pane, open **`1: SD Card`**.
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
5. Enter **`1: SD Card/switch/gen1recomp/`**.
6. Drag from the local pane:
```text
dist/switch/loose/gen1recomp.nro
dist/switch/loose/game.love
```
7. Wait for the OpenMTP queue to finish completely.
8. Refresh the remote listing and confirm file sizes match the local files.
9. On the Switch, exit MTP responder normally in DBI before launching the app.
Expected layout on SD:
```text
1: SD Card/
└── switch/
└── gen1recomp/
├── gen1recomp.nro
└── game.love
```
## Round-trip SHA-256 verification
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
1. **Before send** — record local hashes:
```bash
shasum -a 256 dist/switch/loose/gen1recomp.nro \
dist/switch/loose/game.love
```
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
3. **Compare** round-trip hashes:
```bash
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
dist/switch/mtp-roundtrip/game.love
```
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
Repeat whenever a cable glitch or interrupted transfer is suspected.
## Title override launch (full memory)
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
1. Confirm the OpenMTP transfer queue finished.
2. Exit MTP responder in DBI; disconnect USB if desired.
3. Hold **`R`** while launching any legitimately installed title.
4. Keep holding until **hbmenu** appears.
5. Confirm hbmenu does **not** show **Applet Mode**.
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
## Phase 0 hardware checklist
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
| Step | Action | Pass | Evidence / notes |
| ---- | ------ | ---- | ---------------- |
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
| P0-1c | Title override → launcher reaches import screen | yes | |
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
## Phase 0 test report template
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
```markdown
## Switch Phase 0 — hardware report
- Operator:
- Date:
- Console model:
- Atmosphère / HOS version:
- gen1recomp commit:
- love-nx tag: 11.5-nx1
- love.nro SHA-256 (local):
- game.love SHA-256 (local, pre-send):
- MTP round-trip SHA-256 (gen1recomp.nro):
- MTP round-trip SHA-256 (game.love):
- Title override used: yes / no
- Applet Mode observed: yes / no (should be no for P0)
- Probe getOS():
- Probe dimensions:
- Probe save directory shown:
- Gamepad events logged: yes / no
- Touch events logged: yes / no
- Unpatched launcher boot: pass / fail
- Joy-Con launcher navigation: pass / fail / not tested
- Notes:
```
## Fast dev loop (loose mode)
While iterating on Lua/assets:
1. Edit on Mac; run `scripts/test.sh --quick`.
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
3. Close Gen1Recomp on Switch.
4. DBI → `Run MTP responder`.
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
6. Replace **only** `game.love`; wait for queue + refresh listing.
7. Exit MTP responder; launch via title override.
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
```bash
scripts/test.sh --quick
scripts/build.sh mac --no-notarize
scripts/build_switch.sh --loose
shasum -a 256 .bazinga/work/game.love
```
## Controller input mapping (NX)
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
| Path | Control | Mapping |
| ---- | ------- | ------- |
| `gamepadpressed` | D-pad / left stick | move |
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
| `gamepadpressed` | `start` / `back` | Start / Select (+ / ) |
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start ( / +) |
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
## ROM inbox (NX)
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
| Already ready | Same SHA already imported → “No new ROM found.” |
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
## Mod zip inbox (NX)
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/mods/` |
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.zip` only |
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
## Save `.sav` inbox (NX)
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** folder |
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
## Joy-Con display chords (Select + face)
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / ) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
| Chord (Nintendo UX) | Engine key | Stock engine effect |
| ------------------- | ---------- | ------------------- |
| Select + **A** | `2` | COLORS cycle |
| Select + **B** | `3` | TILT / perspective |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
**OPTIONS → PERFORMANCE** clamps the ports own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
## Lua error log (save directory)
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
## Native crash triage (love-nx / Atmosphère)
love-nx native faults land under the consoles `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
```bash
# Example: aarch64-none-elf-addr2line from devkitPro
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
```
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operators hardware notes.
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
## P0 / P1 hardware matrix (ADR §9)
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
| ID | Requirement | Status | Evidence |
| -- | ----------- | ------ | -------- |
| P0-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
| P0-02 | MTP inbox import path shown | **pass** | T12 |
| P0-03 | Rescan imports ROM | **pass** | T12 |
| P0-04 | Canonical hash routes version | **pass** | T12 |
| P0-05 | Source dump retained in inbox | **pass** | T12 |
| P0-06 | Play reaches game after import | **pass** | T12 |
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
| P0-09 | Save survives quit + reopen | **pass** | T19 |
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
| P1-04 | Reboot persistence | **pass** | T19 |
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
## Review guidance
Maintainers may review as one PR or split later. Suggested slices (optional):
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
### Slice 1 — Platform + import (`platform/import`)
- `src/core/Platform.lua`, `conf.lua` NX branch
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
- Docs: inbox/MTP import sections only
### Slice 2 — Input + lifecycle (`input/lifecycle`)
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
- Tests: input/diagnostics suites
- Docs: controller mapping, suspend/audio notes
### Slice 3 — Build + docs (`build/docs`)
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
**Pre-merge checklist:**
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
- [ ] P0 matrix rows marked pass only with linked hardware evidence
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
+187
View File
@@ -0,0 +1,187 @@
# Switch hardware evidence (Phase 0 + import + input)
> **Hardware evidence log.** Author passes below were recorded on **one
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
> separate community row records Switch V1 / Erista boot. These rows do
> **not** claim Lite, docked soak, or automated install. See
> `docs/switch-development.md` for status and limitations.
**love-nx:** `11.5-nx1`
**Author console:** Switch OLED
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
**Date (author rows):** 2026-08-01
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
---
## Community — Switch V1 / Erista boot — pass (boot)
| Field | Value |
| ----- | ----- |
| Console | Nintendo Switch V1 (Erista) |
| Check | Prebuilt fused NRO boots under title override |
| Tester | [booshankles](https://github.com/booshankles) |
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
---
## Phase 0 — probe (T4) — pass
| Field | Value |
| ----- | ----- |
| Commit (import era) | `df7cea4` |
| `getOS()` / `love._os` | `NX` |
| Dimensions | 1280×720 |
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
| Artifact | SHA-256 |
| -------- | ------- |
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
---
## T12 — Red import + Play — pass
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
---
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
### Round 1 @ `7504753` — partial
| Check | Result |
| ----- | ------ |
| Launcher / overworld (Joy-Con only) | **pass** |
| Naming player/rival | **fail** (dual-path a+b; see below) |
| Touch required | **no** |
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
### Round 2 @ `2699c9a` — pass (Nintendo UX)
| Field | Value |
| ----- | ----- |
| Commit tested | `2699c9a` |
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
| Touch required | **no** |
| Check | Result |
| ----- | ------ |
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
| Naming — rival | **pass** (same) |
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
T16 hardware gate: **closed**.
---
## T19 — save / suspend — pass
| Check | Result |
| ----- | ------ |
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
| Full console reboot persistence | **pass** (operator 2026-08-01) |
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
---
## T24 — fused NRO alone + NRO-only update — **pass**
| Field | Value |
| ----- | ----- |
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
| Deploy | isolated folder, no adjacent `game.love` |
| Boot fused | **pass** |
| ROM import | **pass** |
| Play **Red** | **pass** |
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
| NRO-only replace | **pass** — saves retained; app still boots/plays |
| Touch required | no |
T24 hardware gate: **closed**.
---
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
| Field | Value |
| ----- | ----- |
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
| Host | macOS + native switch-tools (or Docker fallback if used) |
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
| love-nx | `11.5-nx1` (manifest checksums match) |
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
| Console | Switch OLED |
| Operator | Andrew |
| Date | 2026-08-01 |
| Check | Result |
| ----- | ------ |
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
| Copy NRO to SD folder per install doc | **pass** (operator) |
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
---
## NXMOD-12 — Community mod zip OLED smoke — **pass**
Closed from existing OLED photo evidence on issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
with launcher MODS + overworld shots). Photos live on the orphan branch
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
of the operator fork — **not** committed to this repo. Do **not** commit
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
this entry only proves the MODS inbox + Play path on OLED.
| Field | Value |
| ----- | ----- |
| Status | **pass** |
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
| love-nx tag | `11.5-nx1` |
| Console | Switch OLED |
| Mod | community release `.zip` (not vendored; not named here) |
| Zip committed to git? | **no** |
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
| Operator | Andrew |
| Date | 2026-08-01 |
### Checklist
| Step | Pass / fail / pending | Notes |
| ---- | --------------------- | ----- |
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
### Evidence notes
```text
Operator: Andrew
Date: 2026-08-01
Commit tested: feat/switch-nx era documented on issue #531
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
```
+164
View File
@@ -0,0 +1,164 @@
# Install Gen1Recomp on Nintendo Switch
Every GitHub Release that includes Switch support ships an SD-ready zip:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
**or** update — same steps), launch with **title override**, then import your
own legal `.gb` ROM.
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
> This project does not help you set that up. Tracks issue
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
> by the community. Lite and other setups welcome more reports.
> See [switch-development.md](switch-development.md) for limitations.
Prefer building from source? See [switch-build.md](switch-build.md).
Port by [andrewqsantos](https://github.com/andrewqsantos). Community testing
help from [booshankles](https://github.com/booshankles).
## 1. Download the zip
1. Open
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Download `gen1recomp-*-switch.zip` for the version you want.
(Optional: verify against `sha256sums.txt` in the same release.)
## 2. Extract onto the microSD
Extract the zip at the **root** of the microSD so you get:
```text
sdmc:/switch/gen1recomp/gen1recomp.nro
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
```
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
### Updating
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
options live under `pokemon-love2d/`**do not delete that folder** when
updating, or you will lose progress.
## 3. Launch with title override
**Applet Mode is not supported** for this game (not enough memory).
1. On the Switch HOME menu, highlight any installed title.
2. Hold **R** and launch that title — this opens hbmenu with full memory
(title override).
3. From hbmenu, open `gen1recomp`.
Do **not** launch from the Album applet path for normal play.
## 4. Import your ROM
This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the
same folder.
2. Use **Scan again** on that games tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only — a Red dump never imports
from the Yellow tab (and vice versa).
## 5. Import / Export a raw `.sav`
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
| Game | Import inbox | Export folder |
| ---- | ------------ | ------------- |
| Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab**
**SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file
from that games **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves.
## Controls
### Gameplay
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move |
| **A** | Confirm |
| **B** | Cancel |
| **+** (Start) | Start |
| **** (Select) | Select |
| **R** (no Select held) | Cycle game speed up |
| **L** (no Select held) | Cycle game speed down |
### Launcher
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move virtual cursor |
| **A** | Click at cursor |
| **L** / **R** | Previous / next tab |
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
### System
| Control | Action |
| ------- | ------ |
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
## Community mods
Mods install from a zip inbox (same transfer methods as ROMs):
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
2. In the launcher, open **MODS****Scan again** → enable the mod →
**Play**.
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
does not document third-party control tables.
### Joy-Con shortcuts (Select + face)
Hold **Select** () and press a face/shoulder button. Without Select, A/B stay
normal gameplay confirm/cancel. These chords are the stock engine display
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| Chord | Same as PC key | Stock engine effect |
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE**
`LOW` or `BALANCED`. Full chord notes for contributors:
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
## Prefer building it yourself?
Building the fused NRO (and SD-ready zip) from source is covered in
[switch-build.md](switch-build.md). Copying artifacts and inbox files
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
+168
View File
@@ -0,0 +1,168 @@
# Switch file transfer (MTP / SD / FTP)
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
Switch. **Any method is valid** if the bytes land in the destinations below.
This is the home runbook for contributors on **macOS, Linux, and Windows**.
Player install (what to download, title override) stays in
[switch-install.md](switch-install.md). Packaging stays in
[switch-build.md](switch-build.md). Hardware evidence lives in
[switch-hardware-evidence.md](switch-hardware-evidence.md).
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
> netloader as the release or ROM/mod install path.
---
## Destinations (shared by every method)
| What | Where on the console |
| ---- | -------------------- |
| SD-ready release zip | Extract at microSD **root**`sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
Saves persist across zip re-extract / NRO replacements as long as
`pokemon-love2d/` is left in place. Never commit ROM dumps, `.sav`
files, or third-party mod zips to git.
---
## Canonical methods
### 1. MTP (DBI responder + host client)
On the Switch: close Gen1Recomp → open **DBI****Run MTP responder** (often
**X** on the main screen) → keep that screen up → USB-C data cable to the host.
On the host: open **one** MTP client, navigate to **`1: SD Card`**, then the
paths above. Wait for the transfer queue; refresh; exit MTP on the Switch
before launching.
#### macOS (example: OpenMTP)
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
hardware evidence — **one contributor example**, not a Mac-only product rule.
1. Quit other MTP clients.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`,
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
fails to open.
#### Linux
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
desktops, or your distros KDE MTP stack).
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
the Switch / DBI device → **`1: SD Card`**.
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
and the save-dir inboxes as above.
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
“device is busy”, close the file managers MTP mount (or the CLI mount)
and retry with a single client.
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
#### Windows
1. With DBI MTP active, open **This PC** / **File Explorer** and look under
**Portable Devices** for the Switch / DBI MTP volume → **`1: SD Card`**.
2. Copy / extract into `switch\gen1recomp\` and the save-dir inboxes.
3. Optional: [OpenMTP](https://github.com/ganeshrvel/openmtp) on Windows if
Explorer is flaky.
4. If Windows does not show an MTP device: Device Manager → find DBI / Switch
→ Update driver → **MTP USB Device** (or Standard MTP Device). Prefer a
data-capable USB-C cable and a direct port.
5. Safely disconnect; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
### 2. Direct SD (Hekate UMS or card reader)
Same destinations; no MTP client required.
- **Hekate UMS** (preferred when available): expose the microSD to the host
while the card stays in the console; mount the volume; copy files; **cleanly
unmount** before leaving UMS.
- **Physical reader**: power off / remove the microSD, copy on the host,
**eject safely**, reinsert, boot CFW, title-override launch.
Do not yank the card or unplug UMS mid-write.
### 3. FTP (any SD-exposing Switch FTP)
Any homebrew FTP server that can write the microSD is fine — for example
**DBIs own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
only; pick what your CFW setup already uses).
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
2. From the host, connect with any FTP client and upload to the same
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
and `exports/<game>/` paths.
3. Stop the FTP server cleanly before launching Gen1Recomp.
If credentials or chroots differ by app, trust the **destination paths**, not
a single vendor tutorial.
---
## After every transfer
1. Exit MTP / unmount SD / stop FTP cleanly.
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
Mode is not supported** (not enough memory).
3. For ROMs: open the matching game tab → **Scan again** if the file was
added after boot (SHA-1 must match that tab; other dumps in `imports/`
stay for their own tabs). For mods: MODS → **Scan again** → enable →
Play. For saves: SAVE FILES → **Import save** (rescans
`imports/saves/<game>/`). Pull exported `.sav` files from
`exports/<game>/`. Joy-Con display chords (stock engine):
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
### Optional NRO integrity check
For the first deploy of a given artifact (or after a flaky cable):
```bash
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
```
Copy the file back from the SD and compare hashes. Round-trip must match.
---
## Failure modes (quick)
| Symptom | What to try |
| ------- | ----------- |
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
| App opens in Applet Mode | Use title override (hold **R**), not Album |
---
## Related
- Players: [switch-install.md](switch-install.md)
- Builders: [switch-build.md](switch-build.md)
- Status / hardware matrix: [switch-development.md](switch-development.md)
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
+382
View File
@@ -0,0 +1,382 @@
# Timing parity with the Game Boy
The port's clock is faithful: `src/core/FixedStep.lua` advances game logic in
whole 1/60 s steps off wall-clock `dt`, the default speed multiplier is 1x
(`src/core/GameSpeed.lua:22`), and audio runs on its own real-time accumulator
so fast-forward cannot pitch it. Almost nothing in `src/` is seconds-based.
What diverges is the **frame budget of composed sequences**. The original
spends a large fraction of its running time inside `DelayFrames` calls that
produce no visible change - the pause after a page break, the beat before a
status move resolves, the drain of an HP bar one point at a time. Those are
invisible in a screenshot and easy to drop when porting behavior rather than
timing. Dropping them is why the port reads as faster and snappier than
hardware even though every individual animation is correct.
This document is the specification: what each sequence costs on hardware, and
where that number comes from.
## Method
`tools/scan_pokered_delays.ps1` walks the disassembly and reports every
frame-consuming wait with its enclosing routine label:
| Kind | Meaning | Frames |
| --- | --- | --- |
| `DelayFrames` | `ld c, N` + `call DelayFrames` (`home/delay.asm:1`) | N |
| `DelayFrame` | one vblank wait (`home/vblank.asm:92`) | 1 |
| `Delay3` | `home/palettes.asm:14`, three frames for a full bg-map update | 3 |
| `Fade` | the `GBFade*` helpers, expanded to their totals below | 24 or 32 |
Current inventory against `pokered-master`: **450 sites** - 181 `DelayFrames`,
164 `Delay3`, 65 `DelayFrame`, 31 `GBFade*`, and 9 `DelayFrames` calls whose
count is computed at runtime.
The four fades all live in `home/fade.asm` and are loops of
`ld c, 8 / call DelayFrames`:
| Routine | Iterations | Frames | Source |
| --- | --- | --- | --- |
| `GBFadeInFromBlack` | 4 | **32** | `home/fade.asm:21` |
| `GBFadeOutToBlack` | 4 | **32** | `home/fade.asm:43` |
| `GBFadeOutToWhite` | 3 | **24** | `home/fade.asm:26` |
| `GBFadeInFromWhite` | 3 | **24** | `home/fade.asm:48` |
## The metric
For each catalog entry, `delta = |port - truth| / truth`; the entry passes at
`delta <= 0.05`. The headline number is the **exposure-weighted** pass rate,
weighting each sequence by how often it occurs in ordinary play - a 30-frame
error on every page of dialogue matters more than a 30-frame error in the Hall
of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are
frequent, T3 are set pieces seen once or twice per playthrough.
## Tier 1 - constant exposure
These recur every few seconds of play and dominate perceived pacing. Both
sides are verified.
### Overworld and transitions
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| Overworld loop iteration | 2 frames (two `DelayFrame`) | `home/overworld.asm:41-44` | 1 step | `src/core/Game.lua:174` | see note |
| Warp / door: fade out | **32** | `home/overworld.asm:703` -> `GBFadeOutToBlack` | 32 | `src/render/Transition.lua` | **fixed** |
| Warp / door: fade in | **0** (map is drawn under blacked palettes, no fade) | `home/overworld.asm:690-703` | 0 | `src/render/Transition.lua` | **fixed** |
| Return to overworld after a battle | **10** hold, then `GBFadeInFromWhite` **24** | `home/overworld.asm:351-352`, `:22`, `:749-753` | 10 + 24 | `Transition.battleReturn` | **fixed** |
| Special warp entry (fly / teleport / dungeon) | `Delay3` + `GBFadeInFromWhite` = **27** | `engine/overworld/player_animations.asm:5-7` | - | - | unmeasured |
| Dungeon-warp arrival hold | **50** | `engine/overworld/player_animations.asm:43` | - | - | unmeasured |
| Player step (walk) | 16 | 8 loop iterations x 2 frames | 16 | `src/world/Player.lua:14` | **ok** |
| Turn in place | 2 | one extra loop pass | 2 | `src/world/Player.lua:18` | **ok** |
Note on the overworld loop: `OverworldLoop` calls `DelayFrame` and then falls
through to `OverworldLoopLessDelay`, which calls it again - so a full pass
costs 2 frames, and input is sampled every other frame. The port steps logic
and samples input every frame. This does not change walking speed (the 2-frame
loop moves 2 px, giving the same 16 frames per tile) but it does halve input
latency versus hardware. Flagged rather than "wrong": matching it exactly would
make the port feel less responsive than the original does on a modern display,
and it is the one place where a deliberate divergence is defensible.
### Text
The typewriter cadence itself is already correct - 1/3/5 frames per character
from `TextSpeedOptionData`, implemented at `src/render/TextBox.lua:267`. The
gaps around it are missing.
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| `<CONT>` line scroll (after the A press) | `ProtectedDelay3` 3 + 2x `ScrollTextUpOneLine` 5 = **13** | `home/text.asm:262-277`, `:283-307` | 13 | `src/render/TextBox.lua` | **fixed** |
| `<PARA>` paragraph break | `ProtectedDelay3` 3 + clear + **20** = **23** | `home/text.asm:230-243` | 23 | `src/render/TextBox.lua` | **fixed** |
| Page break (`PageChar`) | 3 + **20** = **23** | `home/text.asm:245-260` | 23 | `src/render/TextBox.lua` | **fixed** |
| `TextCommand_PAUSE` | **30** | `home/text.asm:500` | - | - | unmeasured |
| `TextCommand_DOTS` | **10** per dot | `home/text.asm:576` | - | - | unmeasured |
The three ProtectedDelay3 frames are a *pre*-input hold: the arrow is already
up and the button is ignored, because `ManualTextScroll` only starts watching
the joypad after the delay returns. Mashing A through a long conversation
therefore cannot go faster than 3 frames per line on hardware, and now cannot
here either. `PromptText` (`home/text.asm:209-217`) has the same shape, so a
finished page holds three frames before it can be dismissed too.
**Battle text is a second, separate engine.** `BattleState` types its own
messages rather than going through `src/render/TextBox.lua`, so none of the
fixes above reached it and it had drifted further than the overworld box:
| Sequence | Hardware | Source | Was | Now |
| --- | --- | --- | --- | --- |
| Per character | 1 glyph per `wOptions & $f` frames (1/3/5, default **3**) | `home/print_text.asm:4-45` | 2 glyphs **per frame**, option ignored | 3 |
| Per character, A or B held | **1** frame | `print_text.asm:27-36` | 2 glyphs per frame | 1 |
| `<CONT>` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
| `<CONT>` scroll after the press | **10** | `home/text.asm:280-305` | 0 | 10 |
| Finished page, pre-input hold | **3** | `home/text.asm:213-217` | 0 | 3 |
At the default text speed the battle typewriter was running **six times**
hardware speed, which is most of why battle text read as a blur, and it
ignored the OPTION text-speed setting entirely.
`ScrollTextUpOneLine` is `ld b, 5` of `DelayFrame` (`home/text.asm:301-305`)
and its own comment notes it is "always called twice in a row", so a CONT
scroll blocks for 10 frames. The port's `scrollPx` slide at
`src/render/TextBox.lua:305-307` is a cosmetic 8 px at 2 px/frame running in
`draw()`, not on the logic step, and it does not gate the typewriter.
### Menus
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| Yes/no answer (either option) | **15** | `engine/menus/text_box.asm:322-323`, `:333-334` | 15 | `src/ui/ChoiceBox.lua` | **fixed** |
| List menu open (bag, PC, party-as-list) | **10** | `home/list_menu.asm:55-56` | 0 | `src/ui/ListMenu.lua` | **-100%, open** |
| List menu redraw per input | `Delay3` = **3** | `home/list_menu.asm:64` | 0 | - | **-100%, open** |
| Field move from the party menu | `Delay3` = **3** | `engine/menus/start_sub_menus.asm:4,27,175-203` | 7 (white flash) | `src/render/Transition.lua:8` | see note |
| Teleport from the party menu | **60** + `Delay3` | `engine/menus/start_sub_menus.asm:224-225` | - | - | unmeasured |
The port's 7-frame `white_flash` models `GBPalWhiteOutWithDelay3` plus the
screen-tile restore, which is a defensible reading of the same sequence; it is
listed here to be reconciled against the exact path rather than treated as a
bug.
### Battle entry
The wipe into a battle and the silhouette slide behind it. Numbers here come
from **pokered-c** (`C:\Users\Anthony\pokered`), whose `battle_transition.c`
derives each wipe from `battle_transitions.asm` and then corrects it against a
live side-by-side with the ROM. Where that project's measured value and a
naive reading of the asm disagree, the measured value wins - see the inward
spiral below.
| Sequence | Hardware | Was | Now |
| --- | --- | --- | --- |
| DoubleCircle wipe (wild, weak) | 10 x 3 = **30** | 40 | 30 |
| Circle wipe (wild, strong) | 20 x 3 = **60** | 40 | 60 |
| Spiral outward (trainer, strong) | 360 fills / 3 per frame = **120** | 40 | 120 |
| Spiral inward (trainer, weak) | 7 tiles per `Delay3` = **~150** | 40 | 156 |
| HStripes (dungeon wild, weak) | 20 x 3 = **60** | 24 | 60 |
| VStripes (dungeon wild, strong) | 18 x 3 = **54** | 24 | 54 |
| Shrink (dungeon trainer, weak) | 9 x 6 = **54** | 24 | 54 |
| Split (dungeon trainer, strong) | 9 x 6 = **54** | 24 | 54 |
| Flash before the circle wipes | 12 x 2 x 3 = **72** | 72 | 72 |
| Black hold before the battle draws | not stated by pokered; ~30 floor, calibrated **60** | 30 | 60 |
| Silhouette slide in | 144 px at 2 px/frame = **72** | 40 (160 px at 4 px/frame) | 72 |
| Trainer intro, before balls + text | `WaitForSoundToFinish` + `DelayFrames 20` | 0 | sfx wait + 20 |
The trainer intro's sound is `SFX_Silph_Scope`, extracted here as
`Trainer_Appeared` (`tools/rom_manifest.json` `audio.sfxHeaders`, bank 8 /
`$42bb`). It was being extracted and never played by anything. It now plays
into a clear window: `PrintBeginningBattleText .trainerBattle` does
`PlaySound` then `WaitForSoundToFinish`, which **blocks**, and only then
`DelayFrames 20` before `DrawAllPokeballs` and the text. `BattleState`'s
message queue grew a `waitSound` row for that, since `WaitForSoundToFinish`
waits on the sound actually stopping rather than on a fixed frame count.
**Scripted battles were skipping the transition entirely.** `BattleTransition`
runs from `DoBattleTransitionAndInitBattleVariables`, which both
`InitBattleCommon` (`core.asm:6680`, trainers) and `InitWildBattle` (`:6699`)
call unconditionally - every battle on hardware enters through a wipe. In the
port only `OverworldState:pushBattle` built one, and `Commands.start_battle`
pushed the `BattleState` straight onto the stack. The trainer-*sight* path
went through `pushBattle`, but every **script-driven** battle did not: gym
leaders, the rival, Giovanni, and every scripted wild encounter cut straight
to the battle screen with no transition at all. The catch tutorial
(`old_man_demo`) had the same gap; `InitWildBattle` has no
`BATTLE_TYPE_OLD_MAN` special case, so it gets a wipe too.
**Beyond 160x144.** Both halves of the transition used to stop at the classic
letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the
surrounding window a generic centre-out square cascade
(`Renderer:drawBattleCascade`) regardless of which of the eight styles was
running - so at any zoom a spiral read as "a spiral in a box, with something
else happening around it".
- The flash is a palette write (`rBGP`), and a palette register tints every
pixel the LCD shows; there is no "outside the screen" for it to miss. It is
now published to the renderer as a screen-space veil and painted over the
finished composite, so it covers the whole surface at any zoom.
- The spiral and circle walks are now generated for whatever grid the window
works out to (`BattleTransition.gridOrder`), and the area outside the
letterbox is filled in that order instead of the square cascade. The
authentic 20x18 builders still own the letterbox itself, overrun and all,
so nothing changes at 1x. The generic builders are deliberately *not* the
ROM's walk - out there the hardware has no behaviour to be faithful to,
only a shape to continue.
- `shrink`, `split` and the two stripe styles are plain geometry rather than
a tile order, so they keep the cascade for now. Extending them is
rectangles, not a walk, and has not been done.
Note that the **flash is wild-only**: `BattleTransition_FlashScreen` is called
from `BattleTransition_Circle` (`:585`) and `BattleTransition_DoubleCircle`
(`:628`) and nowhere else. A trainer battle's transition is the spiral -
inward against a weaker foe, outward against a stronger one
(`wBattleTransitionSpiralDirection`, `:119-126`) - with no flash in front of
it. The port's `flash` mapping was already correct; the transition simply
never ran for those battles.
Two of these deserve their reasoning recorded:
**The inward spiral** writes one tile per iteration and calls
`BattleTransition_TransferDelay3` every seventh tile. That helper is not a
one-frame transfer - it is `ld a,1 / ldh [hAutoBGTransferEnabled] / call
Delay3 / xor a / ldh [...]` (`battle_transitions.asm:619`), so the cadence is
7 tiles per **three** frames. Reading it as one frame runs the whole wipe 3x
too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and
caught it on a live comparison.
**The black hold** is not a number pokered states anywhere; it is incidental
load cost that a modern port does not pay. The derivable floor is ~13 frames
(`LoadHpBarAndStatusTilePatterns` 4, `LoadHudTilePatterns` 2, `ClearScreen`'s
`Delay3` 3, the `DisableLCD` LY wait 1, `Delay3` after `EnableLCD` 3), but the
two sprite decompressors (`UncompressSpriteFromDE` for the 7x7 front pic and
`LoadPlayerBackPic`'s uncompress + `ScaleSpriteByTwo`) are bit-level RLE/delta
decoders that cannot be cycle-counted from the asm at all. So the derivation
bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear
against the real ROM and marked it ~95% right rather than frame-matched. The
credible range is 30-60; it should not be "corrected" down toward the floor on
the strength of the derivation, because the omitted decompressors are exactly
the unbounded part. Pinning it exactly wants a frame-by-frame capture.
### Battle turns
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| Player HP bar drain of D HP over P pixels | **D + 2P + 6** | `engine/gfx/hp_bar.asm:81-135`, `:140-159`, `:234` | D + 2P + 6 | `src/battle/BattleState.lua` `stepHPDrain` | **fixed** |
| Enemy HP bar drain over P pixels | **2P + 5** (no per-HP frame) | same, gated at `:207-209` | 2P + 5 | same | **fixed** |
| Status move / missed move beat | **30** | `engine/battle/core.asm:3145,3158,3185-3186`; enemy `:5588` | 30 | `EffectRegistry` `missBeat`, `BattleState:performMove` | **fixed** |
| Applying anim type 1, vertical shake b=8 | **48** | `animations.asm:500-503` | 48 | `BattleState:applyHitFx` | ok |
| Applying anim type 2, fast horizontal b=8 | **72** | `animations.asm:505-508` | 72 | same | ok |
| Applying anim type 3, slow horizontal 6,2 | **48** | `animations.asm:510-512,526-549` | 48 | same | ok |
| Applying anim type 4, `AnimationBlinkMon` | **60** | `animations.asm:514-516`, `:1360-1376` | 60 | same | **fixed** |
| Applying anim type 5, fast horizontal b=2 | **18** | `animations.asm:518-521` | 18 | same | ok |
| Applying anim type 6, slow horizontal 3,2 | **24** | `animations.asm:523-525` | 24 | same | ok |
| Faint slide-down | **14** (`PIC_HEIGHT` x `DelayFrames 2`) | `engine/battle/core.asm:1186-1222` | 14 | `BattleState:onFaint` | **fixed** |
| Before every move animation | `Delay3` = **3** | `engine/battle/core.asm:6635-6640` | 3 | `BattleState:updateQueue` | **fixed** |
| Battle start, after enemy send-out | **40** | `engine/battle/core.asm:152-156` | 40 | `BattleState:enter` | **fixed** |
| Poison / burn / leech-seed tick | **20** | `engine/battle/core.asm:529-530` | 20 | `src/battle/BattleState.lua:1817` | **ok** |
| Switch player mon | **50** | `engine/battle/core.asm:2421-2422` | 50 | `src/battle/BattleState.lua:1666-1668` | **ok** |
| Post-hit hold (crit text or not) | **20** | `engine/battle/core.asm:3798-3814` | 20 | `EffectRegistry.runDamaging` | **fixed** |
| Fainted mon slide-down | **2** per row | `engine/battle/core.asm:1216-1217` | - | - | unmeasured |
| Trainer pic slide off | **2** per column | `engine/battle/core.asm:1267-1268` | - | - | unmeasured |
| Trainer battle victory | **40** | `engine/battle/core.asm:940-941` | - | - | unmeasured |
| Player blackout | **40** | `engine/battle/core.asm:1143-1144` | - | - | unmeasured |
| No moves left (Struggle) | **60** | `engine/battle/core.asm:2753-2754` | - | - | unmeasured |
| Send-out animation | `Delay3` + **4** + **5** | `engine/battle/core.asm:6814-6830` | - | - | unmeasured |
The HP bar was the single largest battle divergence. `UpdateHPBar` steps **one
HP point per loop iteration**; each iteration pays 1 frame in
`UpdateHPBar_PrintHPNumber` whenever `wHPBarType != 0` - the player's own HUD
and the party menu, but not the enemy HUD - plus 2 frames for each pixel the
bar actually moves. The tail (`.animateHPBarDone`, `:132-135`) prints the
number once more, animates one last pixel and falls into `Delay3`, so it costs
6 frames player-side and 5 enemy-side.
A 150 HP mon losing everything therefore costs 150 + 96 + 6 = **252 frames
(4.2 s)** on the player's HUD, against 96 + 5 = 101 on the enemy's. The port
used a flat `maxHP/96` - the enemy-side rate applied to both sides - and ran
that same drain in 96 frames (1.6 s).
## Tier 2 - frequent
Status-effect failures (`engine/battle/effects.asm:161,1162,1205`) hold **50**
frames each. `SwitchAndTeleportEffect` uses 50 and 20
(`:834-901`). Evolution holds **50** then **40**
(`engine/pokemon/evos_moves.asm:123,155`). The healing machine
(`engine/overworld/healing_machine.asm`), item effects
(`engine/items/item_effects.asm`, 259 frames across 8 sites), and the fishing
animation (**10** then **100**, `engine/overworld/player_animations.asm:380,399`)
are all in this tier.
## Tier 3 - set pieces
Highest total budgets in the scan, all seen rarely:
`engine/movie/hall_of_fame.asm` (529 frames / 7 sites),
`engine/link/cable_club.asm` (507 / 12), `engine/movie/credits.asm` (502 / 7),
`engine/movie/trade.asm` (494 / 19), `engine/movie/intro.asm` (317 / 7),
`engine/menus/main_menu.asm` (271 / 15), `engine/menus/save.asm` (250 / 3),
`engine/battle/end_of_battle.asm` (200 / 1, the link-battle win/lose string).
Several of these already have faithful implementations - see
`src/ui/IntroMovie.lua`, `src/ui/Credits.lua`, `src/ui/HallOfFame.lua`, whose
constants cite their asm sources directly.
## Status
Closed, and locked by `tests/engine/timing_parity.lua`:
1. **Text page and CONT breaks** - were 0 frames where hardware spends 13-23,
on every page of every dialogue in the game. Now exact, including the
three-frame pre-input hold that swallows a mashed A.
2. **Player HP bar drain** - was ~2.5x too fast on a typical mon. Now steps
one HP point at a time at the hardware rate, with the enemy HUD correctly
cheaper than the player's.
3. **Warp fade** - was a symmetric 12/12; now 32 out and no fade in, which is
both the right duration and the right shape.
4. **Yes/no answer** - was 0 frames where hardware spends 15, with the cursor
snapping to NO on B for the duration as `.choseSecondMenuItem` does.
5. **Battle entry** - every wipe ran at a flat 40/24 against budgets of 30-156,
the silhouette slide was 40 frames against 72, the black hold was half
what pokered-c calibrated, and the trainer intro's 20-frame gap before the
balls and text was missing entirely. This was the single most compressed
stretch in the game.
6. **Battle turns** - the type-4 blink ran at 20 frames against 60. That is
the applying animation for every plain damaging move the player uses, so
it was the single most-repeated timing error in the game. The 30-frame
beat that hardware spends on every status move and every miss was missing
entirely. The faint slide, unusually, ran *slower* than hardware (30
against 14).
Two things worth recording about that last batch, because both contradict a
plausible reading:
- **`PrintCriticalOHKOText`'s 20-frame hold is not conditional.** The
"no critical hit" early-out at `core.asm:3799` jumps to `.done`, and
`.done` *is* the `ld c, 20 / jp DelayFrames`. Every landed hit pays it,
which is where the beat before "It's super effective!" comes from.
- **`StartBattle`'s 40-frame hold is not conditional either.** The `call nz`
at `core.asm:154` gates only `EnemySendOutFirstMon`; the `DelayFrames 40`
under it runs for wild battles too, and it lands *between* the enemy's
send-out and `.playerSendOutFirstMon` (`:166`) rather than at the end of
the intro.
Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake
(`SFX_TINK` + `DelayFrames 40` per rock) were already correct -
`BattleState.lua`'s `wait = 64` and `AnimPlayer.lua`'s `emit(40)`. Note that
pokered-c's `BUI_TRAINER_VICTORY_SLIDE` comment calls the scroll-in 14
frames; `_ScrollTrainerPicAfterBattle` is 6 loop passes of `DelayFrames 4`,
so 24 is right and this port already had it.
**Leaving a battle was a cut, not a fade.** `EnterMap` checks
`BIT_BATTLE_OVER_OR_BLACKOUT` and calls `MapEntryAfterBattle`
(`home/overworld.asm:22`), which is `GBFadeInFromWhite` - so the map fades up
from white over 24 frames, behind the 10-frame hold at `:351-352`. The port
popped the battle and the overworld was simply there. `Transition.battleReturn`
supplies both halves, and steps the veil in three palette stages of 8 frames
rather than tweening it, because `GBFadeIncCommon` writes a palette and holds
it with `ld c, 8 / call DelayFrames` (`home/fade.asm:30-41`) three times over.
It is wrapped around `battle.onFinish` in `OverworldState:pushBattle` - the
one funnel every battle goes through - rather than living in `afterBattle`.
That placement matters: a script-driven **win** defers `afterBattle` into
`ctx.afterScript` so an evolution screen cannot be buried under the trainer's
follow-up text (`Commands.start_battle`), and a fade inside `afterBattle`
inherited that deferral. On a rival battle it fired after the post-battle
dialogue *and* the rival's walk-off, rather than when the battle ended.
The rest of `onFinish` runs as the fade's `onDone`, which is also the hardware
order: `MapEntryAfterBattle` fades the map back in and only then does the map
script run. The overworld is frozen meanwhile - `StateStack` updates the top
state only - so nothing moves under the white.
Hardware skips the fade on a dark map (`wMapPalOffset` nonzero takes the
`LoadGBPal` branch at `:754`). This port has no `wMapPalOffset` equivalent -
no map needs FLASH to be lit - so that branch is unreachable here;
`battleReturn` accepts `opts.instant` for it if that ever changes.
Still open, hardware number confirmed but not yet wired:
- List menu open (10) and per-input redraw (`Delay3`).
- Every battle-table row marked "unmeasured" above - the status/miss beat (30)
is the highest-exposure of them, since it is paid on every status move and
every miss.
Entries marked "unmeasured" have confirmed hardware numbers but the port side
has not been traced; they are remaining work, not known-good.
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mike Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
-- modules/Behavior.lua
--
-- Base module for the pluggable behavior system that drives the Behavior &
-- Mode Unification refactor.
--
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
-- hard-coded in Element.lua.
--
-- Element.new iterates a registry of behavior prototypes and auto-attaches
-- whichever return true from `shouldAttach(props)`. Element therefore never
-- needs to know what an individual behavior does — only that it conforms to
-- this interface.
--
-- Design constraints (locked — tasks 02-13 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
-- be mirrored by every concrete behavior.
-- * Immutable instances — behavior tables are produced once and treated as
-- read-only. Per-element runtime state lives on the element (or a subsystem
-- the behavior attaches), NEVER on the behavior instance itself, so a single
-- behavior instance can be shared across many elements.
--
-- Lifecycle hook contract (each receives the owning element as first argument):
-- onAttach(element) — called once when the behavior is attached
-- (element fully constructed). Allocate
-- subsystems / register listeners here.
-- onDetach(element) — called once when the behavior is detached
-- (element destroyed / mode switch). Tear
-- down anything onAttach created.
-- onUpdate(element, dt) — called every frame from Element:update.
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
-- is the draw context (viewport transform,
-- scissor state, theme renderer, ...).
-- saveState(element) -> state — called during Element save-state; returns
-- a serializable snapshot (or nil) so the
-- behavior's runtime state survives the
-- immediate-mode recreation cycle.
-- restoreState(element, state) — called after reconstruction with the
-- snapshot previously returned by saveState.
--
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
-- an element's props table, return true if
-- this behavior should be auto-attached.
-- Defaults to false (opt-in).
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
--- predicate. All hooks are always present (custom override or no-op default).
---@class Behavior
---@field onAttach fun(element:table)
---@field onDetach fun(element:table)
---@field onUpdate fun(element:table, dt:number)
---@field onDraw fun(element:table, ctx:table)
---@field saveState fun(element:table):any
---@field restoreState fun(element:table, state:any)
---@field shouldAttach fun(props:table):boolean
local Behavior = {}
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
Behavior.HOOK_NAMES = {
"onAttach",
"onDetach",
"onUpdate",
"onDraw",
"saveState",
"restoreState",
}
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
-- (drawLayer) may hold any value.
local ALLOWED_KEYS = {
onAttach = true,
onDetach = true,
onUpdate = true,
onDraw = true,
saveState = true,
restoreState = true,
shouldAttach = true,
drawLayer = true,
}
-- Spec keys whose values are NOT required to be functions (passive metadata
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
local NON_FUNCTION_KEYS = {
drawLayer = true,
}
-- Default no-op hook. Behaviors override only the hooks they need; every other
-- hook resolves to this so dispatch sites never have to nil-check.
local function noop() end
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
-- by providing its own predicate. This is the safe default — a behavior with no
-- opinion about which elements it applies to stays inert in the auto-attach
-- pass (it can still be attached explicitly by name in a later task).
local function defaultShouldAttach()
return false
end
-- Module-level default predicate exposed for callers/tests that want to
-- reference the base default directly without constructing an instance.
Behavior.shouldAttach = defaultShouldAttach
--- Factory: create a frozen behavior instance from a spec table.
---
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
--- plus `shouldAttach`; each value (when present) must be a function. The
--- returned table contains every lifecycle hook (custom override OR no-op) and
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
--- sites can call any hook unconditionally without nil-checking.
---
--- Unknown spec keys and non-function values raise an error immediately so
--- mistakes fail fast at construction rather than as silent no-ops later.
---
---@param spec table|nil spec table overriding select hooks / shouldAttach
---@return Behavior
function Behavior.new(spec)
spec = spec or {}
-- Validate spec keys up front so typos surface here, not as silent no-ops.
for key, value in pairs(spec) do
if not ALLOWED_KEYS[key] then
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
end
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
end
end
local instance = {}
-- Populate every lifecycle hook: custom override when provided, no-op default
-- otherwise. Guarantees `instance.hook` is always callable.
for _, hook in ipairs(Behavior.HOOK_NAMES) do
instance[hook] = spec[hook] or noop
end
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
instance.drawLayer = spec.drawLayer
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
-- objects; runtime state belongs on the element, never on the behavior.
-- (Reassigning an existing hook is still possible via direct index write —
-- Lua metatables cannot intercept that — but the freeze communicates intent
-- and catches accidental field additions.)
local mt = {
__newindex = function(_, key)
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
end,
--- Mark the metatable so consumers can detect a Behavior instance.
---@return string
__tostring = function()
return "Behavior"
end,
__metatable = "Behavior",
}
setmetatable(instance, mt)
return instance
end
--- Type guard: returns true if `value` is a Behavior instance produced by
--- `Behavior.new`. Used by Element's attach path to validate registry entries
--- without depending on identity.
---@param value any
---@return boolean
function Behavior.isBehavior(value)
return type(value) == "table" and getmetatable(value) == "Behavior"
end
return Behavior
+686
View File
@@ -0,0 +1,686 @@
-- Lua 5.2+ compatibility for unpack
local unpack = table.unpack or unpack
-- Warning cache to prevent duplicate warnings for the same element
local warningCache = {}
local Cache = {
canvases = {},
quads = {},
blurInstances = {}, -- Cache blur instances by quality
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
MAX_CANVAS_SIZE = 20,
MAX_QUAD_SIZE = 20,
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
}
--- Round canvas size to nearest bucket for better reuse
---@param size number Size to bucket
---@return number bucketSize Bucketed size
local function bucketSize(size)
if size <= 128 then
return math.ceil(size / 32) * 32
elseif size <= 512 then
return math.ceil(size / 64) * 64
elseif size <= 1024 then
return math.ceil(size / 128) * 128
else
return math.ceil(size / 256) * 256
end
end
--- Get or create a canvas from cache
---@param width number Canvas width
---@param height number Canvas height
---@return love.Canvas canvas The cached or new canvas
function Cache.getCanvas(width, height)
-- Use bucketed sizes for better cache reuse
local bucketedWidth = bucketSize(width)
local bucketedHeight = bucketSize(height)
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
if not Cache.canvases[key] then
Cache.canvases[key] = {}
end
local cache = Cache.canvases[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.canvas
end
end
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
table.insert(cache, { canvas = canvas, inUse = true })
if #cache > Cache.MAX_CANVAS_SIZE then
local removed = table.remove(cache, 1)
if removed and removed.canvas then
removed.canvas:release()
end
end
return canvas
end
--- Release a canvas back to the cache
---@param canvas love.Canvas Canvas to release
function Cache.releaseCanvas(canvas)
for _, sizeCache in pairs(Cache.canvases) do
for _, entry in ipairs(sizeCache) do
if entry.canvas == canvas then
entry.inUse = false
return
end
end
end
end
--- Get or create a quad from cache
---@param x number X position
---@param y number Y position
---@param width number Quad width
---@param height number Quad height
---@param sw number Source width
---@param sh number Source height
---@return love.Quad quad The cached or new quad
function Cache.getQuad(x, y, width, height, sw, sh)
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
if not Cache.quads[key] then
Cache.quads[key] = {}
end
local cache = Cache.quads[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.quad
end
end
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
table.insert(cache, { quad = quad, inUse = true })
if #cache > Cache.MAX_QUAD_SIZE then
table.remove(cache, 1)
end
return quad
end
--- Release a quad back to the cache
---@param quad love.Quad Quad to release
function Cache.releaseQuad(quad)
for _, keyCache in pairs(Cache.quads) do
for _, entry in ipairs(keyCache) do
if entry.quad == quad then
entry.inUse = false
return
end
end
end
end
--- Generate cache key for blurred canvas
---@param elementId string Element ID
---@param x number X position
---@param y number Y position
---@param width number Width
---@param height number Height
---@param radius number Blur radius
---@param quality number Blur quality
---@param isBackdrop boolean Whether this is backdrop blur
---@return string key Cache key
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
return string.format(
"%s:%d:%d:%d:%d:%.1f:%d:%s",
elementId,
x,
y,
width,
height,
radius,
quality,
tostring(isBackdrop)
)
end
--- Get cached blurred canvas
---@param key string Cache key
---@return love.Canvas|nil canvas Cached canvas or nil
function Cache.getBlurredCanvas(key)
local entry = Cache.blurredCanvases[key]
if entry then
entry.lastUsed = os.time()
return entry.canvas
end
return nil
end
--- Store blurred canvas in cache
---@param key string Cache key
---@param canvas love.Canvas Canvas to cache
function Cache.setBlurredCanvas(key, canvas)
-- Limit cache size
local count = 0
for _ in pairs(Cache.blurredCanvases) do
count = count + 1
end
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
-- Remove oldest entry
local oldestKey = nil
local oldestTime = math.huge
for k, v in pairs(Cache.blurredCanvases) do
if v.lastUsed < oldestTime then
oldestTime = v.lastUsed
oldestKey = k
end
end
if oldestKey then
if Cache.blurredCanvases[oldestKey].canvas then
Cache.blurredCanvases[oldestKey].canvas:release()
end
Cache.blurredCanvases[oldestKey] = nil
end
end
Cache.blurredCanvases[key] = {
canvas = canvas,
lastUsed = os.time(),
}
end
--- Clear blurred canvas cache for specific element
---@param elementId string Element ID to clear cache for
function Cache.clearBlurredCanvasesForElement(elementId)
for key, entry in pairs(Cache.blurredCanvases) do
if key:match("^" .. elementId .. ":") then
if entry.canvas then
entry.canvas:release()
end
Cache.blurredCanvases[key] = nil
end
end
end
--- Clear all caches
function Cache.clear()
-- Release all blurred canvases
for _, entry in pairs(Cache.blurredCanvases) do
if entry.canvas then
entry.canvas:release()
end
end
Cache.canvases = {}
Cache.quads = {}
Cache.blurInstances = {}
Cache.blurredCanvases = {}
warningCache = {} -- Clear warning cache on cache clear
end
-- ============================================================================
-- SHADER BUILDER
-- ============================================================================
local ShaderBuilder = {}
--- Build Gaussian blur shader with given parameters
---@param taps number Number of samples (must be odd, >= 3)
---@param offset number Offset value
---@param offsetType string "weighted" or "center"
---@param sigma number Sigma value for Gaussian distribution
---@return love.Shader shader The compiled blur shader
function ShaderBuilder.build(taps, offset, offsetType, sigma)
taps = math.floor(taps)
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
sigma = math.max(sigma, 1)
local steps = (taps + 1) / 2
local gOffsets = {}
local gWeights = {}
for i = 1, steps do
gOffsets[i] = offset * (i - 1)
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
end
local offsets = {}
local weights = {}
for i = #gWeights, 2, -2 do
local oA, oB = gOffsets[i], gOffsets[i - 1]
local wA, wB = gWeights[i], gWeights[i - 1]
wB = oB == 0 and wB / 2 or wB
local weight = wA + wB
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
weights[#weights + 1] = weight
end
local code = {
[[
extern vec2 direction;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
}
local norm = 0
if #gWeights % 2 == 0 then
code[#code + 1] = "vec4 c = vec4( 0.0 );"
else
local weight = gWeights[1]
norm = norm + weight
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
end
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
for i = 1, #offsets do
local offset = offsets[i]
local weight = weights[i]
norm = norm + weight * 2
code[#code + 1] = string.format(template, weight, offset, offset)
end
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
local shaderCode = table.concat(code)
return love.graphics.newShader(shaderCode)
end
--- Get or create a blur instance from cache
---@param quality number Quality level (1-10)
---@return table blurData Cached blur data {shader, taps}
function Cache.getBlurInstance(quality)
if not Cache.blurInstances[quality] then
local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps)
if taps % 2 == 0 then
taps = taps + 1
end
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
Cache.blurInstances[quality] = {
shader = shader,
taps = taps,
}
end
return Cache.blurInstances[quality]
end
---@class BlurProps
---@field quality number? Quality level (1-10, default: 5)
---@class Blur
---@field shader love.Shader The blur shader
---@field quality number Quality level (1-10)
---@field taps number Number of shader taps
---@field _ErrorHandler table? Reference to ErrorHandler module
local Blur = {}
Blur.__index = Blur
--- Check if we should warn about large blur area in immediate mode
---@param elementId string|nil Element ID for caching warnings
---@param width number Blur area width
---@param height number Blur area height
---@param blurType string "content" or "backdrop"
local function checkLargeBlurWarning(elementId, width, height, blurType)
-- Skip if no ErrorHandler available
if not Blur._ErrorHandler then
return
end
-- Skip if not in immediate mode
if not Blur._blurOptimizations then
return
end
-- Calculate blur area
local area = width * height
-- Skip if area is below threshold
if area <= Cache.LARGE_BLUR_THRESHOLD then
return
end
-- Generate warning key (use elementId if available, otherwise use dimensions)
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
-- Skip if already warned for this element/area
if warningCache[warningKey] then
return
end
-- Mark as warned
warningCache[warningKey] = true
-- Issue warning
local message =
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
local suggestion =
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
Blur._ErrorHandler:warn("Blur", "PERF_003", {
area = string.format("%.0fx%.0f", width or 0, height or 0),
})
end
--- Create a new blur effect instance
---@param props BlurProps? Blur configuration
---@return Blur blur The new blur instance
function Blur.new(props)
props = props or {}
local quality = props.quality or 5
quality = math.max(1, math.min(10, quality))
-- Get cached blur instance for this quality level
local blurData = Cache.getBlurInstance(quality)
local self = setmetatable({}, Blur)
self.shader = blurData.shader
self.quality = quality
self.taps = blurData.taps
return self
end
--- Apply blur to a region of the screen
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param drawFunc function Function to draw content to be blurred
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
if type(drawFunc) ~= "function" then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_001")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
drawFunc()
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
drawFunc()
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(nil, width, height, "content")
-- Calculate offset multiplier based on radius and quality
-- Higher quality = more samples = smaller steps for same radius
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.push()
love.graphics.origin()
love.graphics.translate(-x, -y)
drawFunc()
love.graphics.pop()
love.graphics.setShader(self.shader)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
end
--- Apply backdrop blur effect (blur content behind a region)
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Get the current quality level
---@return number quality Quality level (1-10)
function Blur:getQuality()
return self.quality
end
--- Get the number of shader taps
---@return number taps Number of shader taps
function Blur:getTaps()
return self.taps
end
--- Clear all caches (call on window resize or memory cleanup)
function Blur.clearCache()
Cache.clear()
end
--- Apply backdrop blur with caching support
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
---@param elementId string|nil Element ID for caching (nil disables caching)
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
-- If caching is disabled or no element ID, fall back to regular apply
if not Blur._blurOptimizations or not elementId then
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
end
-- Generate cache key
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
-- Check cache
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
if cachedCanvas then
-- Draw cached blur
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(cachedCanvas, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
return
end
-- Not cached, render and cache
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(elementId, width, height, "backdrop")
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
-- Cache the result
local cachedResult = love.graphics.newCanvas(width, height)
love.graphics.setCanvas(cachedResult)
love.graphics.clear()
love.graphics.setShader()
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.draw(canvas1, 0, 0)
Cache.setBlurredCanvas(cacheKey, cachedResult)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Clear blur cache for specific element
---@param elementId string Element ID
function Blur.clearElementCache(elementId)
Cache.clearBlurredCanvasesForElement(elementId)
end
--- Initialize Blur module with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
function Blur.init(deps)
if type(deps) == "table" then
Blur._ErrorHandler = deps.ErrorHandler
Blur._blurOptimizations = deps.immediateModeOptimizations or false
end
end
Blur.Cache = Cache
Blur.ShaderBuilder = ShaderBuilder
return Blur
+385
View File
@@ -0,0 +1,385 @@
--- Utility module for parsing and evaluating CSS-like calc() expressions
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
---@class Calc
local Calc = {}
--- Initialize Calc module with dependencies
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
function Calc.init(deps)
Calc._ErrorHandler = deps.ErrorHandler
end
--- Token types for lexical analysis
local TokenType = {
NUMBER = "NUMBER",
UNIT = "UNIT",
PLUS = "PLUS",
MINUS = "MINUS",
MULTIPLY = "MULTIPLY",
DIVIDE = "DIVIDE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
EOF = "EOF",
}
--- Tokenize a calc expression string into tokens
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
---@return CalcToken[]? tokens Array of tokens with type, value, unit
---@return string? error Error message if tokenization fails
local function tokenize(expr)
local tokens = {}
local i = 1
local len = #expr
while i <= len do
local char = expr:sub(i, i)
-- Skip whitespace
if char:match("%s") then
i = i + 1
-- Number (including decimals, but NOT negative - handled separately below)
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
local numStr = ""
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
-- Operators
elseif char == "+" then
table.insert(tokens, { type = TokenType.PLUS })
i = i + 1
elseif char == "-" then
-- Check if this is a negative number or subtraction
-- It's a negative number if previous token is an operator or opening paren
local prevToken = tokens[#tokens]
if
not prevToken
or prevToken.type == TokenType.PLUS
or prevToken.type == TokenType.MINUS
or prevToken.type == TokenType.MULTIPLY
or prevToken.type == TokenType.DIVIDE
or prevToken.type == TokenType.LPAREN
then
-- This is a negative number, continue to number parsing
local numStr = "-"
i = i + 1
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
else
-- This is subtraction operator
table.insert(tokens, { type = TokenType.MINUS })
i = i + 1
end
elseif char == "*" then
table.insert(tokens, { type = TokenType.MULTIPLY })
i = i + 1
elseif char == "/" then
table.insert(tokens, { type = TokenType.DIVIDE })
i = i + 1
elseif char == "(" then
table.insert(tokens, { type = TokenType.LPAREN })
i = i + 1
elseif char == ")" then
table.insert(tokens, { type = TokenType.RPAREN })
i = i + 1
else
return nil, "Unexpected character: " .. char
end
end
table.insert(tokens, { type = TokenType.EOF })
return tokens
end
--- Parser for calc expressions using recursive descent
---@class Parser
---@field tokens CalcToken[] Array of tokens
---@field pos number Current token position
local Parser = {}
Parser.__index = Parser
--- Create a new parser
---@param tokens CalcToken[] Array of tokens
---@return Parser
function Parser.new(tokens)
local self = setmetatable({}, Parser)
self.tokens = tokens
self.pos = 1
return self
end
--- Get current token
---@return CalcToken token Current token
function Parser:current()
return self.tokens[self.pos]
end
--- Advance to next token
function Parser:advance()
self.pos = self.pos + 1
end
--- Parse expression (handles + and -)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseExpression()
local left = self:parseTerm()
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
local op = self:current().type
self:advance()
local right = self:parseTerm()
left = {
type = op == TokenType.PLUS and "add" or "subtract",
left = left,
right = right,
}
end
return left
end
--- Parse term (handles * and /)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseTerm()
local left = self:parseFactor()
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
local op = self:current().type
self:advance()
local right = self:parseFactor()
left = {
type = op == TokenType.MULTIPLY and "multiply" or "divide",
left = left,
right = right,
}
end
return left
end
--- Parse factor (handles numbers and parentheses)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseFactor()
local token = self:current()
if token.type == TokenType.NUMBER then
self:advance()
return {
type = "number",
value = token.value,
unit = token.unit,
}
elseif token.type == TokenType.LPAREN then
self:advance()
local expr = self:parseExpression()
if self:current().type ~= TokenType.RPAREN then
error("Expected closing parenthesis")
end
self:advance()
return expr
else
error("Unexpected token: " .. token.type)
end
end
--- Parse the tokens into an AST
---@return CalcASTNode ast Abstract syntax tree
function Parser:parse()
local ast = self:parseExpression()
if self:current().type ~= TokenType.EOF then
error("Unexpected tokens after expression")
end
return ast
end
--- Create a calc expression object that can be resolved later
--- This is the main API function that users call
---@param expr string The calc expression (e.g., "50% - 10vw")
---@return CalcObject calcObject A calc expression object with AST
function Calc.new(expr)
-- Tokenize
local tokens, err = tokenize(expr)
if not tokens then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = err,
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = err,
}
end
-- Parse
local parser = Parser.new(tokens)
local success, ast = pcall(function()
return parser:parse()
end)
if not success then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = ast, -- ast contains error message on failure
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = ast,
}
end
return {
_isCalc = true,
_expr = expr,
_ast = ast,
}
end
--- Check if a value is a calc expression
---@param value any The value to check
---@return boolean isCalc True if value is a calc expression
function Calc.isCalc(value)
return type(value) == "table" and value._isCalc == true
end
--- Resolve a calc expression to pixel value
---@param calcObj CalcObject The calc expression object
---@param viewportWidth number Viewport width in pixels
---@param viewportHeight number Viewport height in pixels
---@param parentSize number? Parent dimension for percentage units
---@return number resolvedValue Resolved pixel value
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
if not calcObj._ast then
-- Error during parsing, return 0
return 0
end
--- Evaluate AST node recursively
---@param node table AST node
---@return number value Evaluated value in pixels
local function evaluate(node)
if node.type == "number" then
-- Convert unit to pixels
local value = node.value
local unit = node.unit
if unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "LAY_003", {
unit = "%",
issue = "parent dimension not available",
})
end
return 0
end
return (value / 100) * parentSize
elseif unit == "vw" then
return (value / 100) * viewportWidth
elseif unit == "vh" then
return (value / 100) * viewportHeight
else
return 0
end
elseif node.type == "add" then
return evaluate(node.left) + evaluate(node.right)
elseif node.type == "subtract" then
return evaluate(node.left) - evaluate(node.right)
elseif node.type == "multiply" then
return evaluate(node.left) * evaluate(node.right)
elseif node.type == "divide" then
local divisor = evaluate(node.right)
if divisor == 0 then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = calcObj._expr,
error = "Division by zero",
})
end
return 0
end
return evaluate(node.left) / divisor
else
return 0
end
end
return evaluate(calcObj._ast)
end
return Calc
+346
View File
@@ -0,0 +1,346 @@
---@class Color
local Color = {}
Color.__index = Color
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler}
function Color.init(deps)
if type(deps) == "table" then
Color._ErrorHandler = deps.ErrorHandler
end
end
--- Build type-safe color objects with automatic validation and clamping
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
---@param r number? Red component (0-1), defaults to 0
---@param g number? Green component (0-1), defaults to 0
---@param b number? Blue component (0-1), defaults to 0
---@param a number? Alpha component (0-1), defaults to 1
---@return Color color The new color instance
function Color.new(r, g, b, a)
-- Sanitize and clamp color components
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
-- FFI structs don't support metatables/methods without wrapping
-- The wrapping overhead negates the FFI benefits
local self = setmetatable({}, Color)
self.r = sanitizedR or 0
self.g = sanitizedG or 0
self.b = sanitizedB or 0
self.a = sanitizedA or 1
return self
end
--- Extract individual color channels for use with love.graphics.setColor()
--- Use this to pass colors to LÖVE's rendering functions
---@return number r Red component (0-1)
---@return number g Green component (0-1)
---@return number b Blue component (0-1)
---@return number a Alpha component (0-1)
function Color:toRGBA()
return self.r, self.g, self.b, self.a
end
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
--- Use this to work with colors from design tools that export hex values
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
---@return Color color The parsed color (returns white on error with warning)
function Color.fromHex(hexWithTag)
-- Validate input type
if type(hexWithTag) ~= "string" then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = tostring(hexWithTag),
issue = "not a string",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1)
end
local hex = hexWithTag:gsub("#", "")
if #hex == 6 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
if not r or not g or not b then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, 1)
elseif #hex == 8 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
local a = tonumber("0x" .. hex:sub(7, 8))
if not r or not g or not b or not a then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, a / 255)
else
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
expected = "#RRGGBB or #RRGGBBAA",
hexLength = #hex,
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
end
--- Verify and sanitize individual color components to prevent rendering errors
--- Use this to safely process user input or external color data
---@param value any Value to validate
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
---@return boolean valid True if valid
---@return number? clamped Clamped value in 0-1 range, nil if invalid
function Color.validateColorChannel(value, max)
max = max or 1
if type(value) ~= "number" then
return false, nil
end
-- Check for NaN
if value ~= value then
return false, nil
end
-- Check for Infinity
if value == math.huge or value == -math.huge then
return false, nil
end
-- Normalize to 0-1 range
local normalized = value
if max == 255 then
normalized = value / 255
end
-- Clamp to valid range
normalized = math.max(0, math.min(1, normalized))
return true, normalized
end
--- Validate hex color format
---@param hex string Hex color string (with or without #)
---@return boolean valid True if valid format
---@return string? error Error message if invalid, nil if valid
function Color.validateHexColor(hex)
if type(hex) ~= "string" then
return false, "Hex color must be a string"
end
-- Remove # prefix
local cleanHex = hex:gsub("^#", "")
-- Check length (3, 6, or 8 characters)
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
end
-- Check for valid hex characters
if not cleanHex:match("^[0-9A-Fa-f]+$") then
return false, "Invalid hex characters. Use only 0-9, A-F"
end
return true, nil
end
--- Validate RGB/RGBA color values
---@param r number Red component
---@param g number Green component
---@param b number Blue component
---@param a number? Alpha component (optional, defaults to max)
---@param max number? Maximum value (255 or 1), defaults to 1
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateRGBColor(r, g, b, a, max)
max = max or 1
a = a or max
local rValid = Color.validateColorChannel(r, max)
local gValid = Color.validateColorChannel(g, max)
local bValid = Color.validateColorChannel(b, max)
local aValid = Color.validateColorChannel(a, max)
if not rValid then
return false, string.format("Invalid red channel: %s", tostring(r))
end
if not gValid then
return false, string.format("Invalid green channel: %s", tostring(g))
end
if not bValid then
return false, string.format("Invalid blue channel: %s", tostring(b))
end
if not aValid then
return false, string.format("Invalid alpha channel: %s", tostring(a))
end
return true, nil
end
--- Check if a value is a valid color format
---@param value any Value to check
---@return string? format Format type ("hex", "named", "table"), nil if invalid
function Color.isValidColorFormat(value)
local valueType = type(value)
-- Check for hex string
if valueType == "string" then
if value:match("^#?[0-9A-Fa-f]+$") then
local valid = Color.validateHexColor(value)
if valid then
return "hex"
end
end
return nil
end
-- Check for table format
if valueType == "table" then
-- Check for Color instance
if getmetatable(value) == Color then
return "table"
end
-- Check for array format {r, g, b, a}
if value[1] and value[2] and value[3] then
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
if valid then
return "table"
end
end
-- Check for named format {r=, g=, b=, a=}
if value.r and value.g and value.b then
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
if valid then
return "table"
end
end
return nil
end
return nil
end
--- Convert any color format to a valid Color object with graceful fallbacks
--- Use this to robustly handle colors from any source without crashes
---@param value any Color value to sanitize (hex, named, table, or Color instance)
---@param default Color? Default color if invalid (defaults to black)
---@return Color color Sanitized color instance (guaranteed non-nil)
function Color.sanitizeColor(value, default)
default = default or Color.new(0, 0, 0, 1)
local format = Color.isValidColorFormat(value)
if not format then
return default
end
-- Handle hex format
if format == "hex" then
local cleanHex = value:gsub("^#", "")
-- Expand 3-digit hex to 6-digit
if #cleanHex == 3 then
cleanHex = cleanHex:gsub("(.)", "%1%1")
end
-- Try to parse
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
if success then
return result
else
return default
end
end
if format == "table" then
-- Color instance
if getmetatable(value) == Color then
return value
end
-- Array format
if value[1] then
local _, r = Color.validateColorChannel(value[1], 1)
local _, g = Color.validateColorChannel(value[2], 1)
local _, b = Color.validateColorChannel(value[3], 1)
local _, a = Color.validateColorChannel(value[4] or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
-- Named format
if value.r then
local _, r = Color.validateColorChannel(value.r, 1)
local _, g = Color.validateColorChannel(value.g, 1)
local _, b = Color.validateColorChannel(value.b, 1)
local _, a = Color.validateColorChannel(value.a or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
end
return default
end
--- Universally convert any color format (hex, named, table) into a Color object
--- Use this as your main color input handler to accept flexible color specifications
---@param value any Color value (hex string, named color, table, or Color instance)
---@return Color color Parsed color instance (defaults to black on error)
function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end
--- Smoothly transition between two colors for animations and gradients
--- Use this to create color-based animations without manual channel calculations
---@param colorA Color Starting color
---@param colorB Color Ending color
---@param t number Interpolation factor (0-1)
---@return Color color Interpolated color
function Color.lerp(colorA, colorB, t)
-- Sanitize inputs
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
colorA = Color.new(0, 0, 0, 1)
end
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
colorB = Color.new(0, 0, 0, 1)
end
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
t = 0
end
-- Clamp t to 0-1 range
t = math.max(0, math.min(1, t))
-- Linear interpolation for each channel
local oneMinusT = 1 - t
local r = colorA.r * oneMinusT + colorB.r * t
local g = colorA.g * oneMinusT + colorB.g * t
local b = colorA.b * oneMinusT + colorB.b * t
local a = colorA.a * oneMinusT + colorB.a * t
return Color.new(r, g, b, a)
end
return Color
+596
View File
@@ -0,0 +1,596 @@
---@class Context
local modulePath = (...):match("(.-)[^%.]+$")
local ZIndex = require(modulePath .. "ZIndex")
local Element = require(modulePath .. "Element")
local Context = {
topElements = {},
-- Base scale configuration
baseScale = nil, -- {width: number, height: number}
-- Current scale factors
scaleFactors = { x = 1.0, y = 1.0 },
defaultTheme = nil,
_focusedElement = nil,
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
_activeEventElement = nil,
_cachedViewport = { width = 0, height = 0 },
-- Immediate mode state
_immediateMode = false,
_frameNumber = 0,
_currentFrameElements = {},
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
_frameStarted = false,
_autoBeganFrame = false,
-- Z-index ordered element tracking for immediate mode
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
-- Focus management guard
_settingFocus = false,
-- Hook called whenever focus changes: function(element) or nil
_onFocusChanged = nil,
-- Navigation state
_navigationContext = {
lastFocusedElement = nil, -- For returning from modals
navigationMode = "sequential", -- "sequential" or "directional"
containerElement = nil, -- Current navigation container
},
initialized = false,
-- Expose internal hit-testing helpers for unit testing only.
-- These are populated below after their local definitions. They are NOT part
-- of the public API and must not be relied on by callers; they exist so the
-- shared hit-test core (the single place display:none guarding lives) can be
-- exercised directly by the test suite. Subsequent unified-event-routing
-- tasks consume these locals through the mode-agnostic query functions.
_test = {
pointHitsElement = nil,
elementHasScrollableOverflow = nil,
},
-- Debug draw overlay
_debugDraw = false,
_debugDrawKey = nil,
-- Initialization state tracking
---@type "uninitialized"|"initializing"|"ready"
_initState = "uninitialized",
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
_initQueue = {},
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
-- + realloc + sort for every interactive element sharing the same cursor.
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
-- each flexlove.update (both modes) and in clearFrameElements (immediate
-- mid-frame rebuild). It also self-invalidates when the topElements table
-- reference changes (tests replace it per-case; immediate-mode beginFrame
-- reassigns it each frame), so direct callers that never go through
-- flexlove.update still see fresh results across tree swaps.
_interactiveLookupCache = {
valid = false,
x = nil,
y = nil,
result = nil,
topElementsRef = nil,
frameNumber = -1,
},
}
--- Check if a point hits an element, accounting for scroll offsets and display:none.
--- All mode-agnostic query functions use this as their single hit-test entry point,
--- ensuring fixes like display:none guarding apply everywhere.
---
--- This is the single canonical place where `element.display == false` short-
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
--- the caller's responsibility: callers walk the parent chain (using
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
--- accumulated scroll offset in here. Keeping the parent walk outside this core
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
--- z-index list) callers share the exact same primitive bounds/display logic.
---@param element Element
---@param mx number Screen X coordinate
---@param my number Screen Y coordinate
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
---@return boolean hits
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
-- Skip display:none elements entirely
if element.display == false then
return false
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
local adjustedX = mx + scrollOffsetX
local adjustedY = my + scrollOffsetY
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
end
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
--- overflow values that clip/translate descendant content and therefore require
--- scroll-offset compensation when hit testing descendants.
---@param element Element
---@return boolean
local function elementHasScrollableOverflow(element)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
return overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
end
-- Expose the two core helpers for unit testing only (see Context._test above).
Context._test.pointHitsElement = pointHitsElement
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
-- Public exposure of the canonical hit-test primitive so other modules
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
-- tree walks) can share the single implementation of bounds + display:none
-- guarding instead of duplicating the `display == false` check inline.
-- This keeps "display == false" in exactly one place for hit-testing.
Context.pointHitsElement = pointHitsElement
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
--- Find the first scrollable element at a screen position, regardless of mode.
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
--- that previously lived inline in `flexlove.wheelmoved`:
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
--- * retained mode — recursed through `Context.topElements` with a private
--- `findScrollableAtPosition(elements, x, y)` helper.
--- Both paths now collapse into this single function, which routes every
--- hit test through `pointHitsElement` (the single place `display == false`
--- is guarded) and every scroll-offset decision through
--- `elementHasScrollableOverflow`. As a result display:none elements are never
--- returned in either mode, fixing the latent bug where the immediate-mode
--- path's `isPointInElement` did not skip display:none elements.
---
--- The retained-mode branch intentionally mirrors the original
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
--- children checked before self) but is upgraded to thread accumulated scroll
--- offsets through `pointHitsElement` so nested scrolled containers are tested
--- against their visible position. The original helper is removed once
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The scrollable element, or nil
function Context.findScrollableAtPosition(x, y)
if Context.isImmediateMode() then
-- Immediate mode: iterate the z-index ordered list (reverse order =
-- topmost first). pointHitsElement supplies the bounds + display guard.
for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i]
if pointHitsElement(element, x, y) then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
else
-- Retained mode: recursive tree walk from topElements. Children are
-- checked before self (deepest scrollable wins); accumulated scroll
-- offsets are threaded through pointHitsElement so descendants of
-- scrolled containers are hit-tested against their translated position.
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
for i = #elements, 1, -1 do
local element = elements[i]
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
if #element.children > 0 then
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
if childResult then
return childResult
end
end
-- No descendant was scrollable — check self.
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
end
return findInTree(Context.topElements)
end
end
--- Check whether immediate mode is active.
--- This is the single canonical accessor for the mode flag consumed throughout
--- the framework. Mode-aware branches elsewhere call this instead of reading
--- `Context._immediateMode` directly, so the literal mode flag only appears
--- here (its definition) and in StateManager (its mirrored storage) — never
--- scattered across Element / behaviors / managers (behavior-mode-unification
--- task 11).
---@return boolean
function Context.isImmediateMode()
return Context._immediateMode
end
---@return number, number -- scaleX, scaleY
function Context.getScaleFactors()
return Context.scaleFactors.x, Context.scaleFactors.y
end
--- Register an element in the z-index ordered tree (for immediate mode)
---@param element Element The element to register
function Context.registerElement(element)
if not Context.isImmediateMode() then
return
end
table.insert(Context._zIndexOrderedElements, element)
end
function Context.clearFrameElements()
Context._zIndexOrderedElements = {}
Context.clearInteractiveCache()
end
--- Compute the composite z-index key for an element.
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
---
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
--- remaining 3 digits without interfering with the depth component.
---
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
---
--- This is the SINGLE canonical z-index ordering function, used by both
--- sortElementsByZIndex (the immediate-mode flat list sort) and
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
--- on the same key ensures the interactive topmost element matches the visual
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
--- z=0 BottomBar even when both buttons default to own z=0.
local function getEffectiveZIndex(elem)
local ownZ = elem.z or 0
local rootZ = ownZ
local depth = 0
local current = elem.parent
while current do
rootZ = current.z or 0
depth = depth + 1
current = current.parent
end
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
end
-- Public exposure so FlexLove.getElementAtPosition shares the single
-- implementation instead of duplicating the parent-chain walk as a closure.
Context.getEffectiveZIndex = getEffectiveZIndex
--- Sort elements by z-index (called after all elements are registered)
function Context.sortElementsByZIndex()
-- Precompute the composite key ONCE per element so the sort comparator is a
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
-- O(N log N) comparison. This function runs every frame in immediate mode.
local elements = Context._zIndexOrderedElements
local zIndices = {}
for i = 1, #elements do
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
end
table.sort(elements, function(a, b)
return zIndices[a] < zIndices[b]
end)
end
--- Find the topmost interactive element at a screen position, regardless of mode.
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
--- mechanism — both are now funneled through this single entry point.
---
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
--- in immediate mode). In retained mode this provides the same role as the
--- _activeEventElement set by flexlove.getElementAtPosition().
---
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The topmost interactive element, or nil
function Context.findInteractiveAtPosition(x, y)
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
-- element under the same cursor, but the result for a given (x,y) is
-- identical across all of them within a single update pass. Returning a
-- cached element restores the old 1x/frame cost of the _activeEventElement
-- mechanism that task 05 replaced. Cache auto-invalidates when the
-- topElements table reference changes (so tests and mid-frame rebuilds get
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
local cache = Context._interactiveLookupCache
if
cache.valid
and cache.x == x
and cache.y == y
and cache.topElementsRef == Context.topElements
and cache.frameNumber == Context._frameNumber
then
return cache.result
end
local interactiveCandidates = {}
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
return
end
-- Check if this element is interactive
if element.onEvent or element.themeComponent or element.editable then
table.insert(interactiveCandidates, element)
end
-- Recurse into children with accumulated scroll offset
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
for _, child in ipairs(element.children) do
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
end
end
-- Always traverse the tree (works in both modes — topElements exists always)
for _, element in ipairs(Context.topElements) do
collectInteractive(element)
end
-- Sort by composite z-index descending — topmost wins. The composite key
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
-- topmost element matches the visual draw order. This is critical for the
-- game's multi-window layout: a button inside a z=50 MainMenu window must
-- occlude a button inside a z=0 BottomBar even when both buttons default to
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
-- distinguish them, so the wrong window's button could win, leaving the
-- visible button's isActiveElement=false and clicks/hover dead.
local zIndices = {}
for _, el in ipairs(interactiveCandidates) do
zIndices[el] = getEffectiveZIndex(el)
end
table.sort(interactiveCandidates, function(a, b)
return zIndices[a] > zIndices[b]
end)
local result = interactiveCandidates[1]
cache.x = x
cache.y = y
cache.result = result
cache.topElementsRef = Context.topElements
cache.frameNumber = Context._frameNumber
cache.valid = true
return result
end
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
--- rebuild). After invalidation the next lookup recomputes fresh.
function Context.clearInteractiveCache()
local cache = Context._interactiveLookupCache
cache.valid = false
cache.x = nil
cache.y = nil
cache.result = nil
cache.topElementsRef = nil
cache.frameNumber = -1
end
--- Set the focused element (centralizes focus management)
--- Automatically blurs the previously focused element if different
---@param element Element|nil The element to focus (nil to clear focus)
function Context.setFocused(element)
if Context._focusedElement == element then
return -- Already focused
end
-- Prevent re-entry during focus change
if Context._settingFocus then
return
end
Context._settingFocus = true
-- Save reference to previously focused element before updating
local oldFocusedElement = Context._focusedElement
-- Blur previously focused element
if oldFocusedElement and oldFocusedElement ~= element then
if oldFocusedElement._textEditor then
oldFocusedElement._textEditor:blur(oldFocusedElement)
end
end
-- Set new focused element and persist its id for immediate-mode rehydration
Context._focusedElement = element
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
-- Notify any registered focus change hook (e.g. FocusIndicator)
if Context._onFocusChanged then
Context._onFocusChanged(element)
end
-- Focus the new element's text editor if it has one
if element and element._textEditor then
element._textEditor._focused = true
end
Context._settingFocus = false
end
--- Recursively search for an element by id in an element tree
---@param root Element The root element to start searching from
---@param targetId string The id to search for
---@return Element|nil The element with the matching id, or nil if not found
local function findElementById(root, targetId)
if root.id == targetId then
return root
end
for _, child in ipairs(root.children or {}) do
local found = findElementById(child, targetId)
if found then
return found
end
end
return nil
end
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
--- Called at the start of getFocused() in immediate mode so stale references
--- are always replaced with the current-frame object before use.
function Context._rehydrateFocus()
if not Context._focusedElementId then
Context._focusedElement = nil
return
end
-- First, try a fast linear search through all registered elements
for _, elem in ipairs(Context._zIndexOrderedElements) do
if elem.id == Context._focusedElementId then
Context._focusedElement = elem
return
end
end
-- If not found, recursively search from top-level elements
-- This handles cases where elements may not be in _zIndexOrderedElements
for _, topLevel in ipairs(Context.topElements or {}) do
local found = findElementById(topLevel, Context._focusedElementId)
if found then
Context._focusedElement = found
return
end
end
-- Element with that id is not present this frame (e.g. screen changed)
Context._focusedElement = nil
end
--- Get the currently focused element
---@return Element|nil The focused element, or nil if none
function Context.getFocused()
if Context.isImmediateMode() then
Context._rehydrateFocus()
end
return Context._focusedElement
end
--- Clear focus from any element
function Context.clearFocus()
Context._focusedElementId = nil
Context.setFocused(nil)
end
--- Get all focusable elements in tab order, regardless of mode.
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
--- In retained mode it walks the element tree (DOM order).
--- In both modes, display:none elements are excluded.
---@return table<Element> List of focusable elements in tab order
function Context.getFocusableElements()
local focusable = {}
local function isFocusable(elem)
if elem.display == false then
return false
end
-- Use Element:isFocusable() for consistent behavior
return Element.isFocusable(elem)
end
local function collectFromTree(elements)
for _, elem in ipairs(elements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
if #elem.children > 0 then
collectFromTree(elem.children)
end
end
end
if Context._immediateMode then
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
-- which approximates tab order for most UIs.
for _, elem in ipairs(Context._zIndexOrderedElements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
end
else
-- Retained mode: walk the top element trees in DOM order
collectFromTree(Context.topElements)
end
return focusable
end
-- ====================
-- Navigation Context
-- ====================
--- Push current focus onto stack (for modals/dialogs)
---@param element Element?
function Context.pushFocusStack(element)
Context._navigationContext.lastFocusedElement = Context._focusedElement
if element then
Context.setFocused(element)
end
end
--- Pop focus from stack (return from modal)
---@return Element?
function Context.popFocusStack()
local previous = Context._navigationContext.lastFocusedElement
Context._navigationContext.lastFocusedElement = nil
Context.setFocused(previous)
return previous
end
--- Set navigation container (scope for tab navigation)
---@param element Element?
function Context.setNavigationContainer(element)
Context._navigationContext.containerElement = element
end
--- Get navigation container
---@return Element?
function Context.getNavigationContainer()
return Context._navigationContext.containerElement
end
return Context
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
-- Extracted from utils so utils stays under its LOC budget; re-exported as
-- `utils.enums` for backward compatibility.
local enums = {
---@enum TextAlign
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
---@enum TextAlignVertical
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
---@enum Positioning
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
---@enum FlexDirection
FlexDirection = {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
ROW = "row",
COLUMN = "column",
HORIZONTAL_REVERSE = "horizontal-reverse",
VERTICAL_REVERSE = "vertical-reverse",
ROW_REVERSE = "row-reverse",
COLUMN_REVERSE = "column-reverse",
},
---@enum JustifyContent
JustifyContent = {
FLEX_START = "flex-start",
CENTER = "center",
SPACE_AROUND = "space-around",
FLEX_END = "flex-end",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum JustifySelf
JustifySelf = {
AUTO = "auto",
FLEX_START = "flex-start",
CENTER = "center",
FLEX_END = "flex-end",
SPACE_AROUND = "space-around",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum AlignItems
AlignItems = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignSelf
AlignSelf = {
AUTO = "auto",
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignContent
AlignContent = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
SPACE_BETWEEN = "space-between",
SPACE_AROUND = "space-around",
},
---@enum FlexWrap
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
---@enum TextSize
TextSize = {
XXS = "xxs",
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
XXL = "xxl",
XL3 = "3xl",
XL4 = "4xl",
},
---@enum ImageRepeat
ImageRepeat = {
NO_REPEAT = "no-repeat",
REPEAT = "repeat",
REPEAT_X = "repeat-x",
REPEAT_Y = "repeat-y",
SPACE = "space",
ROUND = "round",
},
---@enum ARIA Role (accessibility roles for screen readers)
ARIA = {
-- Widget roles
BUTTON = "button",
CHECKBOX = "checkbox",
LINK = "link",
MENUITEM = "menuitem",
MENUITEMCHECKBOX = "menuitemcheckbox",
MENUITEMRADIO = "menuitemradio",
PROGRESSBAR = "progressbar",
RADIO = "radio",
SCROLLBAR = "scrollbar",
SLIDER = "slider",
SPINBUTTON = "spinbutton",
SWITCH = "switch",
TAB = "tab",
TABLIST = "tablist",
TABPANEL = "tabpanel",
TEXTBOX = "textbox",
TOOLTIP = "tooltip",
TREEITEM = "treeitem",
COMBOBOX = "combobox",
GRID = "grid",
GRIDCELL = "gridcell",
LISTBOX = "listbox",
LISTITEM = "listitem",
MENU = "menu",
MENUBAR = "menubar",
TREE = "tree",
TREEGRID = "treegrid",
WINDOW = "window",
DIALOG = "dialog",
ALERTDIALOG = "alertdialog",
-- Landmark roles
BANNER = "banner",
COMPLEMENTARY = "complementary",
CONTENTINFO = "contentinfo",
FORM = "form",
MAIN = "main",
NAVIGATION = "navigation",
REGION = "region",
SEARCH = "search",
-- Live region roles
ALERT = "alert",
LOG = "log",
MARQUEE = "marquee",
STATUS = "status",
TIMERTIME = "timer",
-- Document structure roles
ARTICLE = "article",
BLOCKQUOTEBLOCKQUOTE = "blockquote",
CAPTION = "caption",
CODE = "code",
DEFINITION = "definition",
DELETED = "deletion",
DIRECTORY = "directory",
DIVISION = "division",
EMphasis = "emphasis",
HEADING = "heading",
INSERTED = "insertion",
LIST = "list",
MARK = "mark",
MATH = "math",
NONE = "none",
PARAGRAPH = "paragraph",
PRESENTATION = "presentation",
SEPARATOR = "separator",
STRONG = "strong",
SUBSCRIPT = "subscript",
SUPERSCRIPT = "superscript",
TERM = "term",
TIME = "time",
VARIABLE = "variable",
},
}
return { enums = enums }
File diff suppressed because it is too large Load Diff
+843
View File
@@ -0,0 +1,843 @@
---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean?
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
---@field _pressed table<number, boolean>
---@field _lastClickTime number?
---@field _lastClickButton number?
---@field _clickCount number
---@field _dragStartX table<number, number>
---@field _dragStartY table<number, number>
---@field _lastMouseX table<number, number>
---@field _lastMouseY table<number, number>
---@field _touches table<string, table> -- Multi-touch state per touch ID
---@field _touchStartPositions table<string, table> -- Touch start positions
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean
---@field _scrollbarPressHandled boolean
---@field _InputEvent table
---@field _utils table
---@field _Performance Performance? Performance module dependency
---@field _ErrorHandler ErrorHandler
local EventHandler = {}
EventHandler.__index = EventHandler
--- Initialize module with shared dependencies
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
function EventHandler.init(deps)
EventHandler._Performance = deps.Performance
EventHandler._ErrorHandler = deps.ErrorHandler
EventHandler._InputEvent = deps.InputEvent
EventHandler._utils = deps.utils
EventHandler._Context = deps.Context
end
---@param config table Configuration options
---@return EventHandler
function EventHandler.new(config)
config = config or {}
local self = setmetatable({}, EventHandler)
self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred
self.onTouchEvent = config.onTouchEvent
self.onTouchEventDeferred = config.onTouchEventDeferred or false
self.onGesture = config.onGesture
self.onGestureDeferred = config.onGestureDeferred or false
self.touchEnabled = config.touchEnabled ~= false -- Default true
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
self._pressed = config._pressed or {}
self._lastClickTime = config._lastClickTime
self._lastClickButton = config._lastClickButton
self._clickCount = config._clickCount or 0
-- FocusIndicator reference (set after initialization)
self._FocusIndicator = nil
self._dragStartX = config._dragStartX or {}
self._dragStartY = config._dragStartY or {}
self._lastMouseX = config._lastMouseX or {}
self._lastMouseY = config._lastMouseY or {}
-- Multi-touch tracking
self._touches = config._touches or {}
self._touchStartPositions = config._touchStartPositions or {}
self._lastTouchPositions = config._lastTouchPositions or {}
self._touchHistory = config._touchHistory or {}
self._hovered = config._hovered or false
self._scrollbarPressHandled = false
return self
end
--- Get state for persistence (for immediate mode)
---@return table State data
function EventHandler:getState()
return {
_pressed = self._pressed,
_lastClickTime = self._lastClickTime,
_lastClickButton = self._lastClickButton,
_clickCount = self._clickCount,
_dragStartX = self._dragStartX,
_dragStartY = self._dragStartY,
_lastMouseX = self._lastMouseX,
_lastMouseY = self._lastMouseY,
_touches = self._touches,
_touchStartPositions = self._touchStartPositions,
_lastTouchPositions = self._lastTouchPositions,
_touchHistory = self._touchHistory,
_hovered = self._hovered,
}
end
--- Restore state from persistence (for immediate mode)
---@param state table State data
function EventHandler:setState(state)
if not state then
return
end
self._pressed = state._pressed or {}
self._lastClickTime = state._lastClickTime
self._lastClickButton = state._lastClickButton
self._clickCount = state._clickCount or 0
self._dragStartX = state._dragStartX or {}
self._dragStartY = state._dragStartY or {}
self._lastMouseX = state._lastMouseX or {}
self._lastMouseY = state._lastMouseY or {}
self._touches = state._touches or {}
self._touchStartPositions = state._touchStartPositions or {}
self._lastTouchPositions = state._lastTouchPositions or {}
self._touchHistory = state._touchHistory or {}
self._hovered = state._hovered or false
end
--- Process mouse button events in the update cycle
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param isHovering boolean Whether mouse is over element
---@param isActiveElement boolean Whether this is the top element at mouse position
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_mouse")
end
-- Check if currently dragging (allows drag continuation even if occluded)
local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and love.mouse.isDown(button) then
isDragging = true
break
end
end
-- Check if any button is currently pressed (tracked state)
local hasTrackedPress = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] then
hasTrackedPress = true
break
end
end
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.editable
or element._selectState
or element.selectOption
)
and element.visibility ~= "hidden"
and not element.disabled
and (isActiveElement or isDragging or hasTrackedPress)
if not canProcessEvents then
-- If not hovering and no buttons are physically pressed, reset all pressed states
-- This ensures the pressed state is cleared when mouse leaves without button held
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Track hover state changes even when events can't be processed
-- Fire synthetic unhover when element becomes disabled while hovered
if element.disabled and self._hovered then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
elseif self._hovered and not isHovering then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
end
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
return
end
-- Track hover state changes and fire hover/unhover events BEFORE button processing
-- This ensures hover fires before press when mouse first enters element
local wasHovered = self._hovered
local isHoveringAndActive = isHovering and isActiveElement
if isHoveringAndActive and not wasHovered then
-- Just started hovering - fire hover event
self._hovered = true
local modifiers = EventHandler._utils.getModifiers()
local hoverEvent = EventHandler._InputEvent.new({
type = "hover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, hoverEvent)
elseif not isHoveringAndActive and wasHovered then
-- Just stopped hovering - fire unhover event
self._hovered = false
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
-- Process all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle
for _, button in ipairs(buttons) do
-- Check if this button was tracked as pressed
local wasPressed = self._pressed[button]
local isPhysicallyPressed = love.mouse.isDown(button)
if isHovering or isDragging or wasPressed then
if isPhysicallyPressed then
-- Button is pressed down
if not wasPressed then
-- Just pressed - fire press event (only if hovering)
if isHovering then
self:_handleMousePress(element, mx, my, button)
end
else
-- Button is still pressed - check for drag
self:_handleMouseDrag(element, mx, my, button, isHovering)
end
elseif wasPressed then
-- Button was just released
-- Only fire click and release events if mouse is still hovering AND element is active
-- (not occluded by another element)
if isHovering and isActiveElement then
self:_handleMouseRelease(element, mx, my, button)
else
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
end
-- After processing events, reset pressed states for buttons that are no longer held
-- This handles the case where mouse leaves while button is held, then released
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
end
--- Handle mouse button press
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(element, mx, my, button)
-- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
if element:_handleScrollbarPress(mx, my, button) then
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
self._pressed[button] = true
self._scrollbarPressHandled = true
return
end
end
-- Fire press event
local modifiers = EventHandler._utils.getModifiers()
local pressEvent = EventHandler._InputEvent.new({
type = "press",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, pressEvent)
self._pressed[button] = true
-- On left click, set keyboard focus to any focusable element (not just editable).
-- Clear the focus indicator since mouse navigation doesn't use it.
local isFocusable
if type(element.isFocusable) == "function" then
isFocusable = element:isFocusable()
else
isFocusable = (element.editable == true)
or (type(element.onEvent) == "function")
or element._selectState ~= nil
or element.selectOption ~= nil
end
if button == 1 and EventHandler._Context and isFocusable then
EventHandler._Context.setFocused(element)
-- Hide focus indicator - it's only for keyboard navigation
if EventHandler._FocusIndicator then
EventHandler._FocusIndicator.setFocused(nil)
end
end
-- Set mouse down position for text selection on left click
if button == 1 and element._textEditor then
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
element._textDragOccurred = false -- Reset drag flag on press
end
-- Record drag start position per button
self._dragStartX[button] = mx
self._dragStartY[button] = my
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
--- Handle mouse drag (while button is pressed and mouse moves)
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then
-- Handle scrollbar drag if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
element:_handleScrollbarDrag(mx, my)
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
return -- Don't process other drag events while dragging scrollbar
end
-- Mouse has moved - fire drag event only if still hovering
if isHovering then
local modifiers = EventHandler._utils.getModifiers()
local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button]
local dragEvent = EventHandler._InputEvent.new({
type = "drag",
button = button,
x = mx,
y = my,
dx = dx,
dy = dy,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, dragEvent)
end
-- Handle text selection drag for editable elements
if button == 1 and element.editable and element._focused and element._handleTextDrag then
element:_handleTextDrag(mx, my)
end
-- Update last known position for this button
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
end
--- Handle mouse button release
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
function EventHandler:_handleMouseRelease(element, mx, my, button)
local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers()
-- Handle scrollbar release if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
element:_handleScrollbarRelease(button)
self._scrollbarPressHandled = false -- Reset flag
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
return -- Don't process click events for scrollbar release
end
-- Determine click count (double-click detection)
local clickCount
local doubleClickThreshold = 0.3 -- 300ms for double-click
if
self._lastClickTime
and self._lastClickButton == button
and (currentTime - self._lastClickTime) < doubleClickThreshold
then
clickCount = self._clickCount + 1
else
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
-- Fire click event
local clickEvent = EventHandler._InputEvent.new({
type = eventType,
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, clickEvent)
self._pressed[button] = false
-- Clean up drag tracking
self._dragStartX[button] = nil
self._dragStartY[button] = nil
-- Clean up text selection drag tracking
if button == 1 then
element._mouseDownPosition = nil
end
-- Focus editable elements on left click
if button == 1 and element.editable then
-- Only focus if not already focused (to avoid moving cursor to end)
local wasFocused = element:isFocused()
if not wasFocused then
element:focus()
end
-- Handle text click for cursor positioning and word selection
-- Only process click if no text drag occurred (to preserve drag selection)
if element._handleTextClick and not element._textDragOccurred then
element:_handleTextClick(mx, my, clickCount)
end
-- Reset drag flag after release
element._textDragOccurred = false
end
-- Fire release event
local releaseEvent = EventHandler._InputEvent.new({
type = "release",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, releaseEvent)
if button == 1 and element._handleSelectRelease then
element:_handleSelectRelease()
end
end
--- Process touch events in the update cycle
---@param element Element The parent element
function EventHandler:processTouchEvents(element)
-- Start performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_touch")
end
-- Check if element can process events
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.onTouchEvent
or self.onTouchEvent
or element.editable
)
and not element.disabled
and self.touchEnabled
if not canProcessEvents then
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
return
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Get current active touches from LÖVE
local activeTouches = {}
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
activeTouches[tostring(id)] = true
end
-- Count active tracked touches for multi-touch filtering
local trackedTouchCount = 0
for _ in pairs(self._touches) do
trackedTouchCount = trackedTouchCount + 1
end
-- Process active touches
for _, id in ipairs(touches) do
local touchId = tostring(id)
local tx, ty = love.touch.getPosition(id)
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
-- Check if touch is within element bounds
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
if isInside then
if not self._touches[touchId] then
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
-- and we already have an active touch
if self.multiTouchEnabled or trackedTouchCount == 0 then
-- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
trackedTouchCount = trackedTouchCount + 1
end
else
-- Touch moved
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
end
elseif self._touches[touchId] then
-- Touch moved outside or ended
if activeTouches[touchId] then
-- Still active but outside - fire moved event
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
else
-- Touch ended
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
end
end
end
-- Check for ended touches (touches that were tracked but are no longer active)
for touchId, _ in pairs(self._touches) do
if not activeTouches[touchId] then
-- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId]
if lastPos then
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
else
-- Cleanup orphaned touch
self:_cleanupTouch(touchId)
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
end
--- Handle touch began event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
-- Create touch state
self._touches[touchId] = {
x = x,
y = y,
pressure = pressure,
timestamp = love.timer.getTime(),
phase = "began",
}
-- Record start position
self._touchStartPositions[touchId] = { x = x, y = y }
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Initialize touch history
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
-- Create and fire touch press event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
touchEvent.type = "touchpress"
touchEvent.dx = 0
touchEvent.dy = 0
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
--- Handle touch moved event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local lastPos = self._lastTouchPositions[touchId]
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
-- Touch position changed
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Update touch state
touchState.x = x
touchState.y = y
touchState.pressure = pressure
touchState.phase = "moved"
-- Update last position
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Add to touch history (keep last 5 positions)
local history = self._touchHistory[touchId] or {}
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
if #history > 5 then
table.remove(history, 1)
end
self._touchHistory[touchId] = history
-- Create and fire touch move event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
touchEvent.type = "touchmove"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
end
--- Handle touch ended event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Create and fire touch release event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
touchEvent.type = "touchrelease"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
-- Cleanup touch state
self:_cleanupTouch(touchId)
end
--- Cleanup touch state
---@param touchId string Touch ID
function EventHandler:_cleanupTouch(touchId)
self._touches[touchId] = nil
self._touchStartPositions[touchId] = nil
self._lastTouchPositions[touchId] = nil
self._touchHistory[touchId] = nil
end
--- Get active touches on this element
---@return table<string, table> Active touches
function EventHandler:getActiveTouches()
return self._touches
end
--- Reset scrollbar press flag (called each frame)
function EventHandler:resetScrollbarPressFlag()
self._scrollbarPressHandled = false
end
--- Check if any mouse button is pressed
---@return boolean True if any button is pressed
function EventHandler:isAnyButtonPressed()
for _, pressed in pairs(self._pressed) do
if pressed then
return true
end
end
return false
end
--- Check if a specific button is pressed
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if button is pressed
function EventHandler:isButtonPressed(button)
return self._pressed[button] == true
end
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
---@param element Element The element that triggered the event
---@param event InputEvent The event data
function EventHandler:_invokeCallback(element, event)
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onEvent or self.onEvent
if not callback then
return
end
if self.onEventDeferred then
-- Get FlexLove module to defer the callback
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onTouchEvent callback, optionally deferring it
---@param element Element The element that triggered the event
---@param event InputEvent The touch event data
function EventHandler:_invokeTouchCallback(element, event)
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onTouchEvent or self.onTouchEvent
if not callback then
return
end
if self.onTouchEventDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onGesture callback, optionally deferring it
---@param element Element The element that triggered the event
---@param gesture table The gesture data from GestureRecognizer
function EventHandler:_invokeGestureCallback(element, gesture)
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onGesture or self.onGesture
if not callback then
return
end
if self.onGestureDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, gesture)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
gestureType = gesture.type,
})
end
else
callback(element, gesture)
end
end
return EventHandler
+232
View File
@@ -0,0 +1,232 @@
local packageName = ... or "FocusIndicator"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local FocusIndicator = {}
--- Configuration
---@type KeyboardNavigationFocusIndicatorConfig
FocusIndicator.config = {
enabled = true,
--- Custom draw function to override default rendering
---@type function|nil
--- Called with: element, bounds, style - return true to skip default drawing
draw = nil,
-- Appearance
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
lineWidth = 2,
inset = -3, -- Negative value extends beyond element
borderRadius = 4,
-- Animation
animationDuration = 0.15, -- Seconds for focus animation
pulseEnabled = false, -- Enable pulsing animation
pulseDuration = 1.0, -- Seconds per pulse cycle
pulseScaleMin = 0.95, -- Minimum scale during pulse
pulseScaleMax = 1.05, -- Maximum scale during pulse
}
--- State
FocusIndicator._focusedElement = nil
FocusIndicator._animationProgress = 0
FocusIndicator._pulsePhase = 0
FocusIndicator._hidden = true
FocusIndicator._deps = nil
--- Initialize FocusIndicator module
---@param deps table Dependencies table containing Context and Color modules
---@field deps.Context table Context module for getting focused element
---@field deps.Color table Color module for color manipulation
function FocusIndicator.init(deps)
FocusIndicator._deps = deps
FocusIndicator._Context = deps.Context
FocusIndicator._Color = deps.Color
end
--- Update animation state for entrance and pulse effects
---@param dt number Delta time in seconds since last frame
function FocusIndicator:update(dt)
if not FocusIndicator.config.enabled then
return
end
-- Update focus entrance animation
if FocusIndicator._animationProgress < 1 then
FocusIndicator._animationProgress =
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
end
-- Update pulse animation
if FocusIndicator.config.pulseEnabled then
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
end
end
--- Set the focused element to render indicator around
---@param element Element? The element to show focus indicator around, or nil to hide
function FocusIndicator.setFocused(element)
FocusIndicator._focusedElement = element
FocusIndicator._hidden = element == nil
-- Reset animation when focus changes
if element then
FocusIndicator._animationProgress = 0
end
end
--- Get the current scale factor for animations
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
---@return number Scale factor (typically 0.8-1.05 range)
function FocusIndicator:getScale()
local scale = 1
-- Apply entrance animation (scale up from 0.8)
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
scale = scale * entranceScale
-- Apply pulse animation
if FocusIndicator.config.pulseEnabled then
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
-- Smooth sine wave pulse
local pulseScale = FocusIndicator.config.pulseScaleMin
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
scale = scale * pulseScale
end
return scale
end
--- Get the current opacity for the indicator
--- Applies entrance animation fade-in to the configured alpha
---@return number Alpha value (0-1 range)
function FocusIndicator:getOpacity()
-- Fade in on focus
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
end
--- Draw the focus indicator around the focused element
--- Renders a rounded rectangle border, or calls custom draw function if configured
--- Should be called from within love.draw() after all elements are drawn
function FocusIndicator:draw()
if not FocusIndicator.config.enabled then
return
end
if FocusIndicator._hidden then
return
end
-- In immediate mode the stored element reference is stale (recreated every frame).
-- Always resolve through Context so we get the live object with up-to-date positions.
local element
if FocusIndicator._Context then
element = FocusIndicator._Context.getFocused()
else
element = FocusIndicator._focusedElement
end
if not element then
return
end
-- Get element dimensions (use border-box size which includes padding)
local x = element.x or 0
local y = element.y or 0
local w = element._borderBoxWidth
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
local h = element._borderBoxHeight
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
if w == 0 or h == 0 then
return
end
-- Calculate indicator dimensions with inset and scale
local inset = FocusIndicator.config.inset
local scale = self:getScale()
local indicatorX = x + inset
local indicatorY = y + inset
local indicatorW = w - 2 * inset
local indicatorH = h - 2 * inset
-- Center the scale around the element
local offsetX = (indicatorW * (1 - scale)) / 2
local offsetY = (indicatorH * (1 - scale)) / 2
indicatorX = indicatorX + offsetX
indicatorY = indicatorY + offsetY
indicatorW = indicatorW * scale
indicatorH = indicatorH * scale
-- Get color with animated opacity
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
local a = self:getOpacity()
-- Build style table for custom draw callback
local bounds = {
x = indicatorX,
y = indicatorY,
width = indicatorW,
height = indicatorH,
}
local style = {
color = { r = r, g = g, b = b, a = a },
lineWidth = FocusIndicator.config.lineWidth,
borderRadius = FocusIndicator.config.borderRadius,
scale = scale,
opacity = a,
}
-- Check for custom draw callback
if FocusIndicator.config.draw then
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
if skipDefault then
return
end
end
-- Save current love.graphics state
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local prevLineWidth = love.graphics.getLineWidth()
-- Set blend mode for transparency
love.graphics.setBlendMode("alpha")
-- Draw rounded rectangle border
love.graphics.setColor(r, g, b, a)
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
-- Draw the rounded rectangle border
local borderRadius = FocusIndicator.config.borderRadius
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
-- Restore love.graphics state
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
love.graphics.setColor(prevR, prevG, prevB, prevA)
love.graphics.setLineWidth(prevLineWidth)
end
--- Set the indicator color
---@param r number Red component (0-1 range)
---@param g number Green component (0-1 range)
---@param b number Blue component (0-1 range)
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
function FocusIndicator.setColor(r, g, b, a)
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
end
--- Set the stroke width for the indicator border
---@param width number Line width in pixels
function FocusIndicator.setLineWidth(width)
FocusIndicator.config.lineWidth = width
end
return FocusIndicator
+274
View File
@@ -0,0 +1,274 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Font cache with LRU eviction, font resolution, and cache management.
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
-- a cross-import into utils (utils re-exports the cache via aliases).
-- Font cache with LRU eviction
local FONT_CACHE = {}
local FONT_CACHE_MAX_SIZE = 50
local FONT_CACHE_STATS = {
hits = 0,
misses = 0,
evictions = 0,
size = 0,
}
local ErrorHandler = nil
local resolveImagePath = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
resolveImagePath = deps.resolveImagePath
end
end
-- LRU tracking: each entry has {font, lastUsed, accessCount}
local function updateCacheAccess(cacheKey)
local entry = FONT_CACHE[cacheKey]
if entry then
entry.lastUsed = love.timer.getTime()
entry.accessCount = entry.accessCount + 1
end
end
local function evictLRU()
local oldestKey = nil
local oldestTime = math.huge
for key, entry in pairs(FONT_CACHE) do
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
if type(entry) == "table" and entry.lastUsed then
if entry.lastUsed < oldestTime then
oldestTime = entry.lastUsed
oldestKey = key
end
end
end
if oldestKey then
FONT_CACHE[oldestKey] = nil
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
end
end
--- Create or get a font from cache
---@param size number
---@param fontPath string?
---@return love.Font
function FONT_CACHE.get(size, fontPath)
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
-- Small sizes (< 20): round to nearest 2
-- Medium sizes (20-40): round to nearest 4
-- Large sizes (> 40): round to nearest 8
if size < 20 then
size = math.floor((size + 1) / 2) * 2
elseif size < 40 then
size = math.floor((size + 2) / 4) * 4
else
size = math.floor((size + 4) / 8) * 8
end
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if FONT_CACHE[cacheKey] then
-- Cache hit
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
updateCacheAccess(cacheKey)
return FONT_CACHE[cacheKey].font
end
-- Cache miss
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
if ErrorHandler then
ErrorHandler:warn("utils", "RES_004", {
resourceType = "font",
path = fontPath,
})
end
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
-- Per-glyph fallback so a non-Latin UI string is not drawn as tofu.
-- pcall'd require: FlexLove is vendored and must still load standalone.
local okUi, UiFont = pcall(require, "src.render.UiFont")
if okUi and UiFont then UiFont.attach(font, size) end
-- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
return font
end
--- Get font for text size (cached)
---@param textSize number?
---@param fontPath string?
---@return love.Font
function FONT_CACHE.getFont(textSize, fontPath)
if textSize then
return FONT_CACHE.get(textSize, fontPath)
else
return love.graphics.getFont()
end
end
-- Font resolution utilities
--- Resolve font path from fontFamily and theme
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return string? Resolved font path or nil
local function resolveFontPath(fontFamily, themeComponent, themeManager)
if fontFamily then
-- Check if fontFamily is a theme font name
local themeToUse = themeManager and themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
return themeToUse.fonts[fontFamily]
else
-- Treat as direct path to font file
return fontFamily
end
elseif themeComponent and themeManager then
-- If using themeComponent but no fontFamily specified, check for default font in theme
return themeManager:getDefaultFontFamily()
end
return nil
end
--- Get font for element (resolves from theme or fontFamily)
---@param textSize number? Text size in pixels
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return love.Font
local function getFont(textSize, fontFamily, themeComponent, themeManager)
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
return FONT_CACHE.getFont(textSize, fontPath)
end
-- Font cache management
--- Get font cache statistics
---@return table stats {hits, misses, evictions, size, hitRate}
local function getFontCacheStats()
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
return {
hits = FONT_CACHE_STATS.hits,
misses = FONT_CACHE_STATS.misses,
evictions = FONT_CACHE_STATS.evictions,
size = FONT_CACHE_STATS.size,
hitRate = hitRate,
}
end
--- Set maximum font cache size
---@param maxSize number Maximum number of fonts to cache
local function setFontCacheSize(maxSize)
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
-- Evict entries if cache is now over limit
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
evictLRU()
end
end
--- Clear font cache
local function clearFontCache()
-- Clear cache entries but preserve methods (get, getFont)
for key, entry in pairs(FONT_CACHE) do
if type(entry) == "table" and entry.lastUsed then
FONT_CACHE[key] = nil
end
end
FONT_CACHE_STATS.size = 0
FONT_CACHE_STATS.evictions = 0
end
--- Preload font at multiple sizes
---@param fontPath string? Path to font file (nil for default font)
---@param sizes table Array of font sizes to preload
local function preloadFont(fontPath, sizes)
for _, size in ipairs(sizes) do
-- Round size to reduce cache entries
size = math.floor(size + 0.5)
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if not FONT_CACHE[cacheKey] then
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
end
end
end
--- Reset font cache statistics
local function resetFontCacheStats()
FONT_CACHE_STATS.hits = 0
FONT_CACHE_STATS.misses = 0
FONT_CACHE_STATS.evictions = 0
end
return {
FONT_CACHE = FONT_CACHE,
init = init,
resolveFontPath = resolveFontPath,
getFont = getFont,
getFontCacheStats = getFontCacheStats,
setFontCacheSize = setFontCacheSize,
clearFontCache = clearFontCache,
preloadFont = preloadFont,
resetFontCacheStats = resetFontCacheStats,
}
+583
View File
@@ -0,0 +1,583 @@
---@class GestureRecognizer
---@field _touches table<string, table> -- Current touch states
---@field _gestureStates table -- Active gesture states
---@field _config table -- Gesture configuration (thresholds, etc.)
---@field _InputEvent table
---@field _utils table
local GestureRecognizer = {}
GestureRecognizer.__index = GestureRecognizer
-- Gesture types enum
local GestureType = {
TAP = "tap",
DOUBLE_TAP = "double_tap",
LONG_PRESS = "long_press",
SWIPE = "swipe",
PAN = "pan",
PINCH = "pinch",
ROTATE = "rotate",
}
-- Gesture states
local GestureState = {
POSSIBLE = "possible",
BEGAN = "began",
CHANGED = "changed",
ENDED = "ended",
CANCELLED = "cancelled",
FAILED = "failed",
}
-- Default configuration
local defaultConfig = {
-- Tap gesture
tapMaxDuration = 0.3, -- seconds
tapMaxMovement = 10, -- pixels
-- Double-tap gesture
doubleTapInterval = 0.3, -- seconds between taps
-- Long-press gesture
longPressMinDuration = 0.5, -- seconds
longPressMaxMovement = 10, -- pixels
-- Swipe gesture
swipeMinDistance = 50, -- pixels
swipeMaxDuration = 0.2, -- seconds
swipeMinVelocity = 200, -- pixels per second
-- Pan gesture
panMinMovement = 5, -- pixels to start pan
-- Pinch gesture
pinchMinScaleChange = 0.1, -- 10% scale change
-- Rotate gesture
rotateMinAngleChange = 5, -- degrees
}
--- Create a new GestureRecognizer instance
---@param config table? Optional configuration options
---@param deps table Dependencies {InputEvent, utils}
---@return GestureRecognizer
function GestureRecognizer.new(config, deps)
config = config or {}
local self = setmetatable({}, GestureRecognizer)
self._InputEvent = deps.InputEvent
self._utils = deps.utils
-- Merge configuration with defaults
self._config = {}
for key, value in pairs(defaultConfig) do
self._config[key] = config[key] or value
end
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = {},
rotate = {},
}
return self
end
--- Update gesture recognizer with touch event
---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event)
if not event.touchId then
return nil
end
local touchId = event.touchId
local gestures = {}
-- Update touch state
if event.type == "touchpress" then
self._touches[touchId] = {
startX = event.x,
startY = event.y,
x = event.x,
y = event.y,
startTime = event.timestamp,
lastTime = event.timestamp,
phase = "began",
}
-- Initialize gesture detection
self:_detectTapBegan(touchId, event)
self:_detectLongPressBegan(touchId, event)
elseif event.type == "touchmove" then
local touch = self._touches[touchId]
if touch then
touch.x = event.x
touch.y = event.y
touch.lastTime = event.timestamp
touch.phase = "moved"
-- Update gesture detection
local panGesture = self:_detectPan(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
local swipeGesture = self:_detectSwipe(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
-- Multi-touch gestures
if self:_getTouchCount() >= 2 then
local pinchGesture = self:_detectPinch(event)
if pinchGesture then
table.insert(gestures, pinchGesture)
end
local rotateGesture = self:_detectRotate(event)
if rotateGesture then
table.insert(gestures, rotateGesture)
end
end
end
elseif event.type == "touchrelease" then
local touch = self._touches[touchId]
if touch then
touch.phase = "ended"
-- Finalize gesture detection
local tapGesture = self:_detectTapEnded(touchId, event)
if tapGesture then
table.insert(gestures, tapGesture)
end
local swipeGesture = self:_detectSwipeEnded(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
local panGesture = self:_detectPanEnded(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
-- Cleanup touch
self._touches[touchId] = nil
end
elseif event.type == "touchcancel" then
-- Cancel all active gestures for this touch
self._touches[touchId] = nil
self:_cancelAllGestures()
end
return #gestures > 0 and gestures or nil
end
--- Get number of active touches
---@return number
function GestureRecognizer:_getTouchCount()
local count = 0
for _ in pairs(self._touches) do
count = count + 1
end
return count
end
--- Detect tap gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapBegan(touchId, event)
-- Tap detection happens on touch end
-- Just record the touch for now
end
--- Detect tap gesture ended
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid tap
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
local currentTime = event.timestamp
local doubleTapState = self._gestureStates.doubleTap
-- Check for double-tap
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
doubleTapState.tapCount = doubleTapState.tapCount + 1
if doubleTapState.tapCount >= 2 then
-- Fire double-tap gesture
return {
type = GestureType.DOUBLE_TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
else
doubleTapState.tapCount = 1
end
doubleTapState.lastTapTime = currentTime
-- Fire tap gesture
return {
type = GestureType.TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
end
--- Detect long-press gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectLongPressBegan(touchId, event)
-- Long-press detection happens continuously during touch
self._gestureStates.longPress[touchId] = {
startX = event.x,
startY = event.y,
startTime = event.timestamp,
triggered = false,
}
end
--- Detect pan gesture
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPan(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
local panState = self._gestureStates.pan[touchId]
if not panState then
-- Check if pan should begin
if distance >= self._config.panMinMovement then
self._gestureStates.pan[touchId] = {
active = true,
lastX = touch.startX,
lastY = touch.startY,
}
panState = self._gestureStates.pan[touchId]
return {
type = GestureType.PAN,
state = GestureState.BEGAN,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
else
-- Pan is active, fire changed event
local panDx = event.x - panState.lastX
local panDy = event.y - panState.lastY
panState.lastX = event.x
panState.lastY = event.y
return {
type = GestureType.PAN,
state = GestureState.CHANGED,
x = event.x,
y = event.y,
dx = panDx,
dy = panDy,
totalDx = dx,
totalDy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect pan ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPanEnded(touchId, event)
local panState = self._gestureStates.pan[touchId]
if panState and panState.active then
self._gestureStates.pan[touchId] = nil
local touch = self._touches[touchId]
local dx = event.x - touch.startX
local dy = event.y - touch.startY
return {
type = GestureType.PAN,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect swipe gesture
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectSwipe(touchId, event)
-- Swipe detection happens on touch end
end
--- Detect swipe ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectSwipeEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid swipe
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
local velocity = distance / duration
if velocity >= self._config.swipeMinVelocity then
-- Determine swipe direction
local angle = math.atan2(dy, dx)
local direction = "right"
if angle >= -math.pi / 4 and angle < math.pi / 4 then
direction = "right"
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
direction = "down"
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
direction = "up"
else
direction = "left"
end
return {
type = GestureType.SWIPE,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
direction = direction,
velocity = velocity,
timestamp = event.timestamp,
}
end
end
return nil
end
--- Detect pinch gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPinch(event)
-- Get two touches for pinch
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current distance
local currentDx = t2.x - t1.x
local currentDy = t2.y - t1.y
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
-- Calculate initial distance
local initialDx = t2.startX - t1.startX
local initialDy = t2.startY - t1.startY
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
if initialDistance == 0 then
return nil
end
-- Calculate scale
local scale = currentDistance / initialDistance
local pinchState = self._gestureStates.pinch
if not pinchState.active then
-- Check if pinch should begin
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
pinchState.active = true
pinchState.initialScale = scale
pinchState.lastScale = scale
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.PINCH,
state = GestureState.BEGAN,
scale = scale,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Pinch is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local scaleChange = scale - pinchState.lastScale
pinchState.lastScale = scale
return {
type = GestureType.PINCH,
state = GestureState.CHANGED,
scale = scale,
scaleChange = scaleChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect rotate gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectRotate(event)
-- Get two touches for rotation
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current angle
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
-- Calculate initial angle
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
-- Calculate rotation (in degrees)
local rotation = (currentAngle - initialAngle) * 180 / math.pi
local rotateState = self._gestureStates.rotate
if not rotateState.active then
-- Check if rotation should begin
if math.abs(rotation) >= self._config.rotateMinAngleChange then
rotateState.active = true
rotateState.initialRotation = rotation
rotateState.lastRotation = rotation
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.ROTATE,
state = GestureState.BEGAN,
rotation = rotation,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Rotation is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local rotationChange = rotation - rotateState.lastRotation
rotateState.lastRotation = rotation
return {
type = GestureType.ROTATE,
state = GestureState.CHANGED,
rotation = rotation,
rotationChange = rotationChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Cancel all active gestures
function GestureRecognizer:_cancelAllGestures()
for gestureType, state in pairs(self._gestureStates) do
if type(state) == "table" and state.active then
state.active = false
end
end
end
--- Reset gesture recognizer state
function GestureRecognizer:reset()
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = { active = false },
rotate = { active = false },
}
end
-- Export gesture types and states
GestureRecognizer.GestureType = GestureType
GestureRecognizer.GestureState = GestureState
return GestureRecognizer
+336
View File
@@ -0,0 +1,336 @@
local modulePath = (...):match("(.-)[^%.]+$")
local utils = require(modulePath .. "utils")
local enums = utils.enums
local Units = require(modulePath .. "Units")
local Positioning = enums.Positioning
local AlignItems = enums.AlignItems
--- Grid layout with variable column widths / row heights
--- Supports px, %, fr, auto, vw, vh, and calc track sizes
local Grid = {}
--- Parse a single track spec into {type, value}
--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc).
--- Grid-specific types (fr, auto) are handled directly.
---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto")
---@param availableSize number Container size for % resolution
---@param viewportWidth number Viewport width for vw resolution
---@param viewportHeight number Viewport height for vh resolution
---@return table {type: "px"|"fr"|"auto", value: number}
function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight)
-- Handle calc objects (tables with _isCalc flag from FlexLove.calc())
if type(spec) == "table" then
local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize)
return { type = "px", value = resolved }
end
if type(spec) == "number" then
return { type = "px", value = spec }
end
if type(spec) == "string" then
if spec == "auto" then
return { type = "auto", value = 0 }
end
-- Check for fr unit (grid-specific, not in Units pipeline)
local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$")
if numStr and unit == "fr" then
local num = tonumber(numStr)
if num then
return { type = "fr", value = num }
end
end
-- Delegate all other units to the Units pipeline (px, %, vw, vh, calc)
local parsedVal, parsedUnit = Units.parse(spec)
local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize)
return { type = "px", value = resolved }
end
-- Default: 1fr
return { type = "fr", value = 1 }
end
--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks
---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"})
---@param availableSize number Container size for % resolution
---@param viewportWidth number Viewport width for vw resolution
---@param viewportHeight number Viewport height for vh resolution
---@return table Array of {type, value} track descriptors
function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight)
if type(spec) == "table" and #spec > 0 then
local tracks = {}
for i, s in ipairs(spec) do
tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight)
end
return tracks
end
-- Fallback: equal 1fr tracks
local count = (type(spec) == "number" and spec > 0) and spec or 1
local tracks = {}
for i = 1, count do
tracks[i] = { type = "fr", value = 1 }
end
return tracks
end
--- Measure intrinsic content sizes for auto tracks
--- Maps children to their tracks and computes each child's max-content contribution.
--- For children with explicit dimensions (units unit ~= "auto"), uses the original
--- explicit size. For auto-sized children, uses calculated content size.
--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size
--- to the max-content contribution of their grid items.
---@param tracks table Array of {type, value} track descriptors
---@param children table Array of grid child elements
---@param axis "width"|"height" Dimension axis to measure
function Grid._measureAutoTracks(tracks, children, axis)
local trackSizes = {}
local numTracks = #tracks
for i, child in ipairs(children) do
local index = i - 1
local trackIdx = (index % numTracks) + 1
local intrinsicSize
if axis == "width" then
local unit = child.units and child.units.width and child.units.width.unit
if unit and unit ~= "auto" then
-- Explicit width: use original value + padding (not stretched border-box)
intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right
else
-- Auto-sized: use calculated content size
intrinsicSize = child:calculateAutoWidth()
end
else
local unit = child.units and child.units.height and child.units.height.unit
if unit and unit ~= "auto" then
intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom
else
intrinsicSize = child:calculateAutoHeight()
end
end
if intrinsicSize > 0 then
trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize)
end
end
-- Apply measured sizes to auto tracks
for i, track in ipairs(tracks) do
if track.type == "auto" and trackSizes[i] then
track.value = trackSizes[i]
end
end
end
--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining)
--- CSS Grid algorithm:
--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks
--- 2. px tracks consume their fixed size
--- 3. fr tracks consume remaining free space proportionally
--- 4. If no fr tracks exist, auto tracks share remaining space equally
--- Mutates tracks in-place, converting all to {type="px", value=number}
---@param tracks table Array of {type, value} track descriptors
---@param availableSize number Total space available for tracks
---@param gap number Gap between tracks
function Grid._resolveTracks(tracks, availableSize, gap)
local count = #tracks
local totalGaps = (count > 1 and (count - 1) * gap) or 0
local remaining = math.max(0, availableSize - totalGaps)
-- Pass 1: Treat auto tracks as fixed (content-measured) and subtract
for _, track in ipairs(tracks) do
if track.type == "px" then
remaining = remaining - track.value
elseif track.type == "auto" then
remaining = remaining - math.max(0, track.value)
end
end
remaining = math.max(0, remaining)
-- Pass 2: Count fr shares
local totalFr = 0
local autoCount = 0
for _, track in ipairs(tracks) do
if track.type == "fr" then
totalFr = totalFr + track.value
elseif track.type == "auto" then
autoCount = autoCount + 1
end
end
-- Pass 3: Distribute remaining space
if totalFr > 0 then
-- fr tracks consume all remaining free space
local frUnit = remaining / totalFr
for _, track in ipairs(tracks) do
if track.type == "fr" then
track.value = frUnit * track.value
track.type = "px"
end
end
elseif autoCount > 0 then
-- No fr tracks: auto tracks share remaining space equally (grow beyond content)
local extraPerAuto = math.max(0, remaining) / autoCount
for _, track in ipairs(tracks) do
if track.type == "auto" then
track.value = track.value + extraPerAuto
track.type = "px"
end
end
end
end
--- Layout grid items within a grid container
--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs)
--- Falls back to equal-sized 1fr tracks when nil
---@param element Element -- Grid container element
function Grid.layoutGridItems(element)
-- Calculate space reserved by absolutely positioned siblings
local reservedLeft = 0
local reservedRight = 0
local reservedTop = 0
local reservedBottom = 0
for _, child in ipairs(element.children) do
-- Only consider absolutely positioned children with explicit positioning and display != false
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
if child.left then
reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth)
end
if child.right then
reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth)
end
if child.top then
reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight)
end
if child.bottom then
reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight)
end
end
end
-- Calculate available space (accounting for padding and reserved space)
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions
local availableWidth = math.max(0, element.width - reservedLeft - reservedRight)
local availableHeight = math.max(0, element.height - reservedTop - reservedBottom)
-- Get gaps
local columnGap = element.columnGap or 0
local rowGap = element.rowGap or 0
-- Collect grid children (exclude explicitly absolute and display=false)
local gridChildren = {}
for _, child in ipairs(element.children) do
if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then
table.insert(gridChildren, child)
end
end
-- Get viewport dimensions for unit resolution (vw, vh, %)
local vpw, vph = Units.getViewport()
-- Build tracks, measure auto tracks by content, then resolve sizes
local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph)
local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph)
Grid._measureAutoTracks(colTracks, gridChildren, "width")
Grid._measureAutoTracks(rowTracks, gridChildren, "height")
Grid._resolveTracks(colTracks, availableWidth, columnGap)
Grid._resolveTracks(rowTracks, availableHeight, rowGap)
-- Compute column start positions (for positioning)
local colStarts = {}
local currentX = element.x + element.padding.left + reservedLeft
for col = 1, #colTracks do
colStarts[col] = currentX
currentX = currentX + colTracks[col].value + columnGap
end
local rowStarts = {}
local currentY = element.y + element.padding.top + reservedTop
for row = 1, #rowTracks do
rowStarts[row] = currentY
currentY = currentY + rowTracks[row].value + rowGap
end
local effectiveAlignItems = element.alignItems or AlignItems.STRETCH
for i, child in ipairs(gridChildren) do
-- Calculate row and column (0-indexed for calculation)
local index = i - 1
local col = index % #colTracks
local row = math.floor(index / #colTracks)
if row >= #rowTracks then
break
end
-- Get resolved cell position and size
local colIdx = col + 1
local rowIdx = row + 1
local cellX = colStarts[colIdx]
local cellY = rowStarts[rowIdx]
local cellWidth = colTracks[colIdx].value
local cellHeight = rowTracks[rowIdx].value
-- Apply alignment within grid cell (default to stretch)
-- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically
if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then
child.x = cellX
child.y = cellY
child._borderBoxWidth = cellWidth
child._borderBoxHeight = cellHeight
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
-- Disable auto-sizing when stretched by grid
child.autosizing.width = false
child.autosizing.height = false
elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
child.x = cellX + (cellWidth - childBorderBoxWidth) / 2
child.y = cellY + (cellHeight - childBorderBoxHeight) / 2
elseif
effectiveAlignItems == AlignItems.FLEX_START
or effectiveAlignItems == "flex-start"
or effectiveAlignItems == "start"
then
child.x = cellX
child.y = cellY
elseif
effectiveAlignItems == AlignItems.FLEX_END
or effectiveAlignItems == "flex-end"
or effectiveAlignItems == "end"
then
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
child.x = cellX + cellWidth - childBorderBoxWidth
child.y = cellY + cellHeight - childBorderBoxHeight
else
child.x = cellX
child.y = cellY
child._borderBoxWidth = cellWidth
child._borderBoxHeight = cellHeight
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
-- Disable auto-sizing when stretched by grid
child.autosizing.width = false
child.autosizing.height = false
end
if #child.children > 0 then
child:layoutChildren()
end
end
end
return Grid
+160
View File
@@ -0,0 +1,160 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local utils = req("utils")
-- ErrorHandler will be injected via init
local ErrorHandler = nil
---@class ImageCache
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
local ImageCache = {}
ImageCache._cache = {}
--- Initialize ImageCache with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageCache.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
--- Load an image from file path with caching
--- Returns cached image if already loaded, otherwise loads and caches it
---@param imagePath string -- Path to image file
---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false)
---@return love.Image|nil -- Image object or nil on error
---@return string|nil -- Error message if loading failed
function ImageCache.load(imagePath, loadImageData)
if not imagePath or type(imagePath) ~= "string" or imagePath == "" then
return nil, "Invalid image path: path must be a non-empty string"
end
local normalizedPath = utils.normalizePath(imagePath)
if ImageCache._cache[normalizedPath] then
return ImageCache._cache[normalizedPath].image, nil
end
local success, imageOrError = pcall(love.graphics.newImage, normalizedPath)
if not success then
if ErrorHandler then
ErrorHandler:warn("ImageCache", "RES_004", {
resourceType = "image",
path = imagePath,
error = tostring(imageOrError),
})
end
return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError))
end
local image = imageOrError
local imgData = nil
if loadImageData then
local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath)
if dataSuccess then
imgData = dataOrError
elseif ErrorHandler then
ErrorHandler:warn("ImageCache", "RES_004", {
resourceType = "image data",
path = imagePath,
error = tostring(dataOrError),
})
end
end
ImageCache._cache[normalizedPath] = {
image = image,
imageData = imgData,
}
return image, nil
end
--- Get a cached image without loading
---@param imagePath string -- Path to image file
---@return love.Image|nil -- Cached image or nil if not found
function ImageCache.get(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return nil
end
local normalizedPath = utils.normalizePath(imagePath)
local cached = ImageCache._cache[normalizedPath]
return cached and cached.image or nil
end
--- Get cached ImageData for an image
---@param imagePath string -- Path to image file
---@return love.ImageData|nil -- Cached ImageData or nil if not found
function ImageCache.getImageData(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return nil
end
local normalizedPath = utils.normalizePath(imagePath)
local cached = ImageCache._cache[normalizedPath]
return cached and cached.imageData or nil
end
--- Remove a specific image from cache
---@param imagePath string -- Path to image file to remove
---@return boolean -- True if image was removed, false if not found
function ImageCache.remove(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return false
end
local normalizedPath = utils.normalizePath(imagePath)
if ImageCache._cache[normalizedPath] then
local cached = ImageCache._cache[normalizedPath]
if cached.image then
cached.image:release()
end
if cached.imageData then
cached.imageData:release()
end
ImageCache._cache[normalizedPath] = nil
return true
end
return false
end
--- Clear all cached images
function ImageCache.clear()
for path, cached in pairs(ImageCache._cache) do
if cached.image then
cached.image:release()
end
if cached.imageData then
cached.imageData:release()
end
end
ImageCache._cache = {}
end
--- Get cache statistics
---@return {count: number, memoryEstimate: number} -- Cache stats
function ImageCache.getStats()
local count = 0
local memoryEstimate = 0
for path, cached in pairs(ImageCache._cache) do
count = count + 1
if cached.image then
local w, h = cached.image:getDimensions()
-- Estimate: 4 bytes per pixel (RGBA)
memoryEstimate = memoryEstimate + (w * h * 4)
end
end
return {
count = count,
memoryEstimate = memoryEstimate,
}
end
return ImageCache
+380
View File
@@ -0,0 +1,380 @@
---@class ImageRenderer
local ImageRenderer = {}
-- ErrorHandler and utils will be injected via init
local ErrorHandler = nil
local utils = nil
--- Initialize ImageRenderer with dependencies
---@param deps table Dependencies table with ErrorHandler and utils
function ImageRenderer.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
if deps and deps.utils then
utils = deps.utils
end
end
--- Calculate rendering parameters for object-fit modes
--- Returns source and destination rectangles for rendering
---@param imageWidth number -- Natural width of the image
---@param imageHeight number -- Natural height of the image
---@param boundsWidth number -- Width of the bounds to fit within
---@param boundsHeight number -- Height of the bounds to fit within
---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill")
---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center")
---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number}
function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition)
fitMode = fitMode or "fill"
objectPosition = objectPosition or "center center"
if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then
ErrorHandler:error("ImageRenderer", "VAL_002", {
imageWidth = imageWidth,
imageHeight = imageHeight,
boundsWidth = boundsWidth,
boundsHeight = boundsHeight,
})
end
local result = {
sx = 0, -- Source X
sy = 0, -- Source Y
sw = imageWidth, -- Source width
sh = imageHeight, -- Source height
dx = 0, -- Destination X
dy = 0, -- Destination Y
dw = boundsWidth, -- Destination width
dh = boundsHeight, -- Destination height
scaleX = 1, -- Scale factor X
scaleY = 1, -- Scale factor Y
}
if fitMode == "fill" then
-- Stretch to fill bounds (may distort)
result.scaleX = boundsWidth / imageWidth
result.scaleY = boundsHeight / imageHeight
result.dw = boundsWidth
result.dh = boundsHeight
elseif fitMode == "contain" then
-- Scale to fit within bounds (preserves aspect ratio)
local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight)
result.scaleX = scale
result.scaleY = scale
result.dw = imageWidth * scale
result.dh = imageHeight * scale
-- Apply object-position for letterbox alignment
local posX, posY = ImageRenderer._parsePosition(objectPosition)
result.dx = (boundsWidth - result.dw) * posX
result.dy = (boundsHeight - result.dh) * posY
elseif fitMode == "cover" then
-- Scale to cover bounds (preserves aspect ratio, may crop)
local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight)
result.scaleX = scale
result.scaleY = scale
local scaledWidth = imageWidth * scale
local scaledHeight = imageHeight * scale
-- Apply object-position for crop alignment
local posX, posY = ImageRenderer._parsePosition(objectPosition)
-- Calculate which part of the scaled image to show
local cropX = (scaledWidth - boundsWidth) * posX
local cropY = (scaledHeight - boundsHeight) * posY
-- Convert back to source coordinates
result.sx = cropX / scale
result.sy = cropY / scale
result.sw = boundsWidth / scale
result.sh = boundsHeight / scale
result.dx = 0
result.dy = 0
result.dw = boundsWidth
result.dh = boundsHeight
elseif fitMode == "none" then
-- Use natural size (no scaling)
result.scaleX = 1
result.scaleY = 1
result.dw = imageWidth
result.dh = imageHeight
-- Apply object-position
local posX, posY = ImageRenderer._parsePosition(objectPosition)
result.dx = (boundsWidth - imageWidth) * posX
result.dy = (boundsHeight - imageHeight) * posY
elseif fitMode == "scale-down" then
-- Use none or contain, whichever is smaller
if imageWidth <= boundsWidth and imageHeight <= boundsHeight then
-- Image fits naturally, use "none"
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition)
else
-- Image too large, use "contain"
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition)
end
else
ErrorHandler:warn("ImageRenderer", "VAL_007", {
fitMode = fitMode,
fallback = "fill",
})
-- Use 'fill' as fallback
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition)
end
return result
end
--- Parse object-position string into normalized coordinates (0-1)
--- Supports keywords (center, top, bottom, left, right) and percentages
---@param position string -- Position string like "center center", "top left", "50% 50%"
---@return number, number -- Normalized X and Y positions (0-1)
function ImageRenderer._parsePosition(position)
if not position or type(position) ~= "string" then
return 0.5, 0.5 -- Default to center
end
-- Split into X and Y components
local parts = {}
for part in position:gmatch("%S+") do
table.insert(parts, part:lower())
end
-- If only one value, use it for both axes (with special handling)
if #parts == 1 then
local val = parts[1]
if val == "left" or val == "right" then
parts = { val, "center" }
elseif val == "top" or val == "bottom" then
parts = { "center", val }
else
parts = { val, val }
end
elseif #parts == 0 then
return 0.5, 0.5 -- Default to center
end
local function parseValue(val)
-- Handle keywords
if val == "center" then
return 0.5
elseif val == "left" or val == "top" then
return 0
elseif val == "right" or val == "bottom" then
return 1
end
-- Handle percentages
local percent = val:match("^([%d%.]+)%%$")
if percent then
return tonumber(percent) / 100
end
-- Handle plain numbers (treat as percentage)
local num = tonumber(val)
if num then
return num / 100
end
-- Invalid value, default to center
return 0.5
end
local x = parseValue(parts[1])
local y = parseValue(parts[2] or parts[1])
-- Clamp to 0-1 range
x = math.max(0, math.min(1, x))
y = math.max(0, math.min(1, y))
return x, y
end
--- Draw an image with specified object-fit mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param fitMode string? -- Object-fit mode (default: "fill")
---@param objectPosition string? -- Object-position (default: "center center")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
fitMode = fitMode or "fill"
objectPosition = objectPosition or "center center"
local imgWidth, imgHeight = image:getDimensions()
local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition)
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
-- Draw image
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
-- Need to use a quad for cropping
local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight)
love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh)
else
-- Simple draw with scaling
love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
--- Draw an image with tiling/repeat mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
repeatMode = repeatMode or "no-repeat"
local imgWidth, imgHeight = image:getDimensions()
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
if repeatMode == "no-repeat" then
-- Just draw once, no tiling
love.graphics.draw(image, x, y)
elseif repeatMode == "repeat" then
-- Tile in both directions
local tilesX = math.ceil(width / imgWidth)
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawY = y + (tileY * imgHeight)
-- Calculate how much of the tile to draw (for partial tiles at edges)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawWidth < imgWidth or drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, drawY)
else
-- Draw full tile
love.graphics.draw(image, drawX, drawY)
end
end
end
elseif repeatMode == "repeat-x" then
-- Tile horizontally only
local tilesX = math.ceil(width / imgWidth)
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
if drawWidth < imgWidth then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, y)
else
-- Draw full tile
love.graphics.draw(image, drawX, y)
end
end
elseif repeatMode == "repeat-y" then
-- Tile vertically only
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
local drawY = y + (tileY * imgHeight)
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, x, drawY)
else
-- Draw full tile
love.graphics.draw(image, x, drawY)
end
end
elseif repeatMode == "space" then
-- Distribute tiles with even spacing
local tilesX = math.floor(width / imgWidth)
local tilesY = math.floor(height / imgHeight)
if tilesX < 1 then
tilesX = 1
end
if tilesY < 1 then
tilesY = 1
end
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * (imgWidth + spaceX))
local drawY = y + (tileY * (imgHeight + spaceY))
love.graphics.draw(image, drawX, drawY)
end
end
elseif repeatMode == "round" then
-- Scale tiles to fit bounds exactly
local tilesX = math.max(1, utils.round(width / imgWidth))
local tilesY = math.max(1, utils.round(height / imgHeight))
local scaleX = width / (tilesX * imgWidth)
local scaleY = height / (tilesY * imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth * scaleX)
local drawY = y + (tileY * imgHeight * scaleY)
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
end
end
else
ErrorHandler:warn("ImageRenderer", "VAL_007", {
repeatMode = repeatMode,
fallback = "no-repeat",
})
love.graphics.draw(image, x, y)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
return ImageRenderer
+174
View File
@@ -0,0 +1,174 @@
-- ====================
-- ImageScaler
-- ====================
local ImageScaler = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize ImageScaler with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageScaler.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
--- Scale an ImageData region using nearest-neighbor sampling
--- Produces sharp, pixelated scaling - ideal for pixel art
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
ErrorHandler:error("ImageScaler", "VAL_001", {
parameter = "sourceImageData",
})
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
ErrorHandler:warn("ImageScaler", "VAL_002", {
srcW = srcW,
srcH = srcH,
destW = destW,
destH = destH,
fallback = "1x1 transparent image",
})
-- Return a minimal 1x1 transparent image as fallback
local fallbackImageData = love.image.newImageData(1, 1)
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
return fallbackImageData
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios (cached outside loops for performance)
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Nearest-neighbor sampling
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate source pixel coordinates using floor (nearest-neighbor)
local srcPixelX = math.floor(destX * scaleX) + srcX
local srcPixelY = math.floor(destY * scaleY) + srcY
-- Clamp to source bounds (safety check)
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
-- Sample source pixel
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
--- Linear interpolation helper
--- Blends between two values based on interpolation factor
---@param a number -- Start value
---@param b number -- End value
---@param t number -- Interpolation factor [0, 1]
---@return number -- Interpolated value
local function lerp(a, b, t)
return a + (b - a) * t
end
--- Scale an ImageData region using bilinear interpolation
--- Produces smooth, filtered scaling - ideal for high-quality upscaling
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
ErrorHandler:error("ImageScaler", "VAL_001", {
parameter = "sourceImageData",
})
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
ErrorHandler:warn("ImageScaler", "VAL_002", {
srcW = srcW,
srcH = srcH,
destW = destW,
destH = destH,
fallback = "1x1 transparent image",
})
-- Return a minimal 1x1 transparent image as fallback
local fallbackImageData = love.image.newImageData(1, 1)
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
return fallbackImageData
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Bilinear interpolation
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate fractional source position
local srcXf = destX * scaleX
local srcYf = destY * scaleY
-- Get integer coordinates for 2x2 sampling grid
local x0 = math.floor(srcXf)
local y0 = math.floor(srcYf)
local x1 = math.min(x0 + 1, srcW - 1)
local y1 = math.min(y0 + 1, srcH - 1)
-- Get fractional parts for interpolation
local fx = srcXf - x0
local fy = srcYf - y0
-- Sample 4 neighboring pixels (with source offset)
local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0)
local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0)
local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1)
local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1)
-- Interpolate horizontally (top and bottom rows)
local rTop = lerp(r00, r10, fx)
local gTop = lerp(g00, g10, fx)
local bTop = lerp(b00, b10, fx)
local aTop = lerp(a00, a10, fx)
local rBottom = lerp(r01, r11, fx)
local gBottom = lerp(g01, g11, fx)
local bBottom = lerp(b01, b11, fx)
local aBottom = lerp(a01, a11, fx)
-- Interpolate vertically (final result)
local r = lerp(rTop, rBottom, fy)
local g = lerp(gTop, gBottom, fy)
local b = lerp(bTop, bBottom, fy)
local a = lerp(aTop, aBottom, fy)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
return ImageScaler
+88
View File
@@ -0,0 +1,88 @@
---@class InputEvent
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
---@field x number -- Mouse/Touch X position
---@field y number -- Mouse/Touch Y position
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number -- Number of clicks (for double/triple click detection)
---@field timestamp number -- Time when event occurred
---@field touchId string? -- Touch identifier (for multi-touch)
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
local InputEvent = {}
InputEvent.__index = InputEvent
---@class InputEventProps
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number
---@field x number
---@field y number
---@field dx number?
---@field dy number?
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number?
---@field timestamp number?
---@field touchId string?
---@field pressure number?
---@field phase string?
--- Create a new input event
---@param props InputEventProps
---@return InputEvent
function InputEvent.new(props)
local self = setmetatable({}, InputEvent)
self.type = props.type
self.button = props.button
self.x = props.x
self.y = props.y
self.dx = props.dx
self.dy = props.dy
self.modifiers = props.modifiers
self.clickCount = props.clickCount or 1
self.timestamp = props.timestamp or love.timer.getTime()
-- Touch-specific properties
self.touchId = props.touchId
self.pressure = props.pressure or 1.0
self.phase = props.phase
return self
end
--- Create an InputEvent from LÖVE touch data
---@param id userdata Touch ID from LÖVE
---@param x number Touch X position
---@param y number Touch Y position
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
---@return InputEvent
function InputEvent.fromTouch(id, x, y, phase, pressure)
local touchIdStr = tostring(id)
local eventType = "touchpress"
if phase == "moved" then
eventType = "touchmove"
elseif phase == "ended" then
eventType = "touchrelease"
elseif phase == "cancelled" then
eventType = "touchcancel"
end
return InputEvent.new({
type = eventType,
button = 1, -- Treat touch as left button
x = x,
y = y,
dx = 0,
dy = 0,
modifiers = { shift = false, ctrl = false, alt = false, super = false },
clickCount = 1,
timestamp = love.timer.getTime(),
touchId = touchIdStr,
pressure = pressure or 1.0,
phase = phase,
})
end
return InputEvent
@@ -0,0 +1,748 @@
local packageName = ... or "KeyboardNavigation"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
---@class KeyboardNavigation
---@field config KeyboardNavigationConfig
local KeyboardNavigation = {
config = {
-- Global settings
enabled = true,
debugMode = false,
-- Key bindings
keys = {
next = "tab",
previous = "shifttab",
up = "up",
down = "down",
left = "left",
right = "right",
activate = { "return", "space" },
dismiss = "escape",
toggleDebug = "f12",
inspect = "i",
},
-- Navigation behavior
wrapAround = true,
directionalNavigation = true,
focusVisible = true,
autofocusOnCreate = false,
--- Drop focus after pressing Enter/Space to activate an element
--- When false, focus remains on the element after activation
dropFocusOnSelection = true,
-- Developer tools
developerTools = {
enabled = true,
showProperties = true,
highlightColor = { 1, 0.8, 0, 0.5 },
},
-- Focus indicator style
focusIndicator = {
color = { 0.2, 0.6, 1.0, 0.8 },
lineWidth = 2,
inset = -3,
borderRadius = 4,
animationDuration = 0.15,
},
},
-- State
_navigationStack = {},
_lastNavigationTime = 0,
_inspectMode = false,
_deps = nil,
-- Spatial index for directional navigation (performance optimization)
_spatialIndex = {
enabled = false,
cellSize = 100, -- Grid cell size in pixels
grid = {}, -- Grid storing element references
elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}}
lastUpdateFrame = 0,
},
}
--- Initialize KeyboardNavigation module
---@param deps table {Context, Element, ErrorHandler, utils, InputEvent}
function KeyboardNavigation.init(deps)
-- Validate required dependencies
local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true }
for depName, _ in pairs(required) do
if not deps[depName] then
error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName))
end
end
KeyboardNavigation._deps = deps
KeyboardNavigation._ErrorHandler = deps.ErrorHandler
KeyboardNavigation._InputEvent = deps.InputEvent
KeyboardNavigation._Context = deps.Context
KeyboardNavigation._Element = deps.Element
KeyboardNavigation._utils = deps.utils
end
--- Handle keyboard press for navigation
---@param key string
---@param scancode string
---@param isrepeat boolean
---@return boolean handled
function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat)
if not KeyboardNavigation._Context then
return false
end
-- Debug logging
if KeyboardNavigation.config.debugMode then
print(
string.format(
"[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)",
key,
scancode,
tostring(isrepeat)
)
)
print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled)))
end
local config = KeyboardNavigation.config
local keys = config.keys
-- Check for activation keys
for _, activateKey in ipairs(keys.activate) do
if key == activateKey then
return self:activateElement()
end
end
-- Check for dismiss key
if key == keys.dismiss then
return self:dismissElement()
end
-- Check for next/previous navigation
-- Tab with shift held = previous; Tab without shift = next
if key == keys.next then
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
return self:previousFocusable()
end
return self:nextFocusable()
end
if key == keys.previous then
return self:previousFocusable()
end
-- Check for directional navigation
if config.directionalNavigation then
if key == keys.up then
return self:navigateDirectional("up")
elseif key == keys.down then
return self:navigateDirectional("down")
elseif key == keys.left then
return self:navigateDirectional("left")
elseif key == keys.right then
return self:navigateDirectional("right")
end
end
return false
end
--- Find next focusable element in the focusable list
---@param focusableList table<Element> List of focusable elements in tab order
---@param current Element? Currently focused element
---@return Element?
function KeyboardNavigation:_findNextInList(focusableList, current)
local currentIndex = 0
if current then
for i, elem in ipairs(focusableList) do
if elem.id == current.id then
currentIndex = i
break
end
end
end
-- Search forward
if currentIndex < #focusableList then
return focusableList[currentIndex + 1]
end
-- Wrap around if enabled
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
return focusableList[1]
end
return nil
end
--- Get the focusable element list scoped to the navigation container
---@return Element[]
function KeyboardNavigation:_getScopedFocusableList()
local Context = KeyboardNavigation._Context
local container = Context.getNavigationContainer()
if container then
return container:getFocusableChildren()
end
return Context.getFocusableElements()
end
--- Navigate to next focusable element (Tab)
---@return boolean success
function KeyboardNavigation:nextFocusable()
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
if KeyboardNavigation.config.debugMode then
print(
string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil"))
)
end
local focusableList = self:_getScopedFocusableList()
local nextElem = self:_findNextInList(focusableList, current)
if nextElem then
self:_focusElement(nextElem)
return true
end
return false
end
--- Find previous focusable element in the focusable list
---@param focusableList table<Element> List of focusable elements in tab order
---@param current Element? Currently focused element
---@return Element?
function KeyboardNavigation:_findPreviousInList(focusableList, current)
local currentIndex = #focusableList + 1
if current then
for i, elem in ipairs(focusableList) do
if elem.id == current.id then
currentIndex = i
break
end
end
end
-- Search backward
if currentIndex - 1 >= 1 then
return focusableList[currentIndex - 1]
end
-- Wrap around if enabled
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
return focusableList[#focusableList]
end
return nil
end
--- Navigate to previous focusable element (Shift+Tab)
---@return boolean success
function KeyboardNavigation:previousFocusable()
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
local focusableList = self:_getScopedFocusableList()
local prevElem = self:_findPreviousInList(focusableList, current)
if prevElem then
self:_focusElement(prevElem)
return true
end
return false
end
--- Navigate using arrow keys
---@param direction "up"|"down"|"left"|"right"
---@return boolean success
function KeyboardNavigation:navigateDirectional(direction)
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
if not current then
return false
end
local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction)
if nextElem then
self:_focusElement(nextElem)
return true
end
return false
end
--- Find closest focusable element in the given direction
---@param current Element
---@param direction "up"|"down"|"left"|"right"
---@return Element?
function KeyboardNavigation:_findDirectionalNeighbor(current, direction)
-- Try spatial index first if enabled
if KeyboardNavigation._spatialIndex.enabled then
local spatialResult = self:_findDirectionalNeighborSpatial(current, direction)
if spatialResult then
return spatialResult
end
end
-- Collect all focusable elements visible this frame
local Context = KeyboardNavigation._Context
local focusable = {}
local function collectFocusable(elem)
if elem:isFocusable() and elem ~= current then
table.insert(focusable, elem)
end
for _, child in ipairs(elem.children) do
collectFocusable(child)
end
end
-- Mode-agnostic: collect from Context's focusable list
local allFocusable = Context.getFocusableElements()
for _, elem in ipairs(allFocusable) do
if elem ~= current then
table.insert(focusable, elem)
end
end
if #focusable == 0 then
return nil
end
local currentRect = {
x = current.x,
y = current.y,
width = current.width or 0,
height = current.height or 0,
}
local closest = nil
local closestDistance = math.huge
for _, elem in ipairs(focusable) do
local elemRect = {
x = elem.x,
y = elem.y,
width = elem.width or 0,
height = elem.height or 0,
}
local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction)
if isInDirection and distance < closestDistance then
closest = elem
closestDistance = distance
end
end
-- If no element found in exact direction, try with looser criteria
if not closest then
closest = self:_findClosestInDirection(current, focusable, direction)
end
return closest
end
--- Calculate distance and direction between elements
---@param from table {x, y, width, height}
---@param to table {x, y, width, height}
---@param direction string
---@return number distance, boolean isInDirection
function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction)
-- Calculate bounding box edges
local fromLeft = from.x
local fromRight = from.x + from.width
local fromTop = from.y
local fromBottom = from.y + from.height
local toLeft = to.x
local toRight = to.x + to.width
local toTop = to.y
local toBottom = to.y + to.height
local distance = math.huge
local isInDirection = false
if direction == "up" then
if toBottom < fromTop then
isInDirection = true
distance = fromTop - toBottom
end
elseif direction == "down" then
if toTop > fromBottom then
isInDirection = true
distance = toTop - fromBottom
end
elseif direction == "left" then
if toRight < fromLeft then
isInDirection = true
distance = fromLeft - toRight
end
elseif direction == "right" then
if toLeft > fromRight then
isInDirection = true
distance = toLeft - fromRight
end
end
return distance, isInDirection
end
--- Find closest element in direction using center-to-center distance
---@param current Element
---@param focusable Element[]
---@param direction string
---@return Element?
function KeyboardNavigation:_findClosestInDirection(current, focusable, direction)
local currentCenterX = current.x + (current.width or 0) / 2
local currentCenterY = current.y + (current.height or 0) / 2
local closest = nil
local closestDistance = math.huge
for _, elem in ipairs(focusable) do
if elem ~= current then
local elemCenterX = elem.x + (elem.width or 0) / 2
local elemCenterY = elem.y + (elem.height or 0) / 2
local dx = elemCenterX - currentCenterX
local dy = elemCenterY - currentCenterY
-- Check if element is generally in the right direction
local isInDirection = false
if direction == "up" and dy < 0 then
isInDirection = true
elseif direction == "down" and dy > 0 then
isInDirection = true
elseif direction == "left" and dx < 0 then
isInDirection = true
elseif direction == "right" and dx > 0 then
isInDirection = true
end
if isInDirection then
local distance = math.sqrt(dx * dx + dy * dy)
if distance < closestDistance then
closest = elem
closestDistance = distance
end
end
end
end
return closest
end
--- Focus an element
---@param element Element
function KeyboardNavigation:_focusElement(element)
local Context = KeyboardNavigation._Context
if element and element:isFocusable() then
if KeyboardNavigation.config.debugMode then
print(
string.format(
"[KeyboardNavigation] Focusing element: %s (id: %s)",
element.themeComponent or "unknown",
tostring(element.id)
)
)
end
Context.setFocused(element)
-- Update focus indicator
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator.setFocused(element)
end
-- Call onFocus callback if it exists
if element.onFocus then
local success, err = pcall(function()
if element.onFocusDeferred then
table.insert(Context._deferredCallbacks or {}, function()
element:onFocus(element)
end)
else
element:onFocus(element)
end
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", {
elementId = element.id or "unknown",
error = tostring(err),
})
end
end
end
end
---@param element Element
---@return boolean
function KeyboardNavigation:_shouldDropFocusOnSelection(element)
if element and element.dropFocusOnSelection ~= nil then
return element.dropFocusOnSelection == true
end
return KeyboardNavigation.config.dropFocusOnSelection == true
end
--- Activate currently focused element
---@return boolean success
function KeyboardNavigation:activateElement()
local Context = KeyboardNavigation._Context
local focused = Context.getFocused()
if not focused then
return false
end
if focused.disabled then
return false
end
-- Fire press and release events
if focused.onEvent then
local modifiers = KeyboardNavigation._utils.getModifiers()
local pressEvent = KeyboardNavigation._InputEvent.new({
type = "press",
button = 1,
x = focused.x,
y = focused.y,
modifiers = modifiers,
clickCount = 1,
})
local releaseEvent = KeyboardNavigation._InputEvent.new({
type = "release",
button = 1,
x = focused.x,
y = focused.y,
modifiers = modifiers,
clickCount = 1,
})
local success, err = pcall(function()
focused.onEvent(focused, pressEvent)
focused.onEvent(focused, releaseEvent)
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", {
elementId = focused.id or "unknown",
error = tostring(err),
})
end
-- Drop focus after selection based on per-element override or global config.
if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then
Context.clearFocus()
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator.setFocused(nil)
end
end
return true
end
return false
end
--- Dismiss currently focused element
---@return boolean success
function KeyboardNavigation:dismissElement()
local Context = KeyboardNavigation._Context
local focused = Context.getFocused()
if not focused then
return false
end
-- Check if element has a dismiss handler
if focused.onDismiss then
local success, err = pcall(function()
if focused.onDismissDeferred then
table.insert(Context._deferredCallbacks or {}, function()
focused:onDismiss(focused)
end)
else
focused:onDismiss(focused)
end
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", {
elementId = focused.id or "unknown",
error = tostring(err),
})
end
return true -- Handler took care of dismissal
end
-- Default behavior: blur the element (only if no onDismiss handler)
Context.clearFocus()
return true
end
--- Update keyboard navigation (for animations, etc.)
---@param dt number
function KeyboardNavigation:update(dt)
-- Update focus indicator if it exists
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator:update(dt)
end
end
--- Push current focus onto stack (for modals/dialogs)
--- Saves current focus and sets new focus to the given element
---@param element Element? The element to focus (e.g., modal dialog)
function KeyboardNavigation:pushFocus(element)
local Context = KeyboardNavigation._Context
table.insert(KeyboardNavigation._navigationStack, Context.getFocused())
Context.pushFocusStack(element)
end
--- Pop focus from stack (return from modal)
--- Restores previously focused element from the stack
---@return Element? The previously focused element, or nil if stack was empty
function KeyboardNavigation:popFocus()
local Context = KeyboardNavigation._Context
local previous = Context.popFocusStack()
if #KeyboardNavigation._navigationStack > 0 then
previous = table.remove(KeyboardNavigation._navigationStack)
end
return previous
end
-- ====================
-- Spatial Index (Performance Optimization)
-- ====================
--- Enable spatial index for faster directional navigation
---@param enabled boolean
function KeyboardNavigation.enableSpatialIndex(enabled)
KeyboardNavigation._spatialIndex.enabled = enabled
if not enabled then
KeyboardNavigation:_clearSpatialIndex()
end
end
--- Clear spatial index
function KeyboardNavigation:_clearSpatialIndex()
KeyboardNavigation._spatialIndex.grid = {}
KeyboardNavigation._spatialIndex.elementPositions = {}
end
--- Find directional neighbor using spatial index
---@param current Element
---@param direction "up"|"down"|"left"|"right"
---@return Element?
function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction)
local index = KeyboardNavigation._spatialIndex
local cellSize = index.cellSize
-- Get current element's grid position
local currentPos = index.elementPositions[current]
if not currentPos then
return nil
end
local centerX = currentPos.x + currentPos.w / 2
local centerY = currentPos.y + currentPos.h / 2
local currentCellX = math.floor(centerX / cellSize)
local currentCellY = math.floor(centerY / cellSize)
-- Search in direction, expanding outward
local maxSearchRadius = 20 -- Maximum cells to search
local visited = {}
for radius = 1, maxSearchRadius do
local candidates = {}
-- Get cells in the search ring
if direction == "up" then
table.insert(candidates, { currentCellX, currentCellY - radius })
if radius > 1 then
table.insert(candidates, { currentCellX - 1, currentCellY - radius })
table.insert(candidates, { currentCellX + 1, currentCellY - radius })
end
elseif direction == "down" then
table.insert(candidates, { currentCellX, currentCellY + radius })
if radius > 1 then
table.insert(candidates, { currentCellX - 1, currentCellY + radius })
table.insert(candidates, { currentCellX + 1, currentCellY + radius })
end
elseif direction == "left" then
table.insert(candidates, { currentCellX - radius, currentCellY })
if radius > 1 then
table.insert(candidates, { currentCellX - radius, currentCellY - 1 })
table.insert(candidates, { currentCellX - radius, currentCellY + 1 })
end
elseif direction == "right" then
table.insert(candidates, { currentCellX + radius, currentCellY })
if radius > 1 then
table.insert(candidates, { currentCellX + radius, currentCellY - 1 })
table.insert(candidates, { currentCellX + radius, currentCellY + 1 })
end
end
-- Check each candidate cell
for _, cell in ipairs(candidates) do
local cellKey = string.format("%d,%d", cell[1], cell[2])
local cellElements = index.grid[cellKey]
if cellElements then
for _, elem in ipairs(cellElements) do
if elem ~= current and not visited[elem] then
visited[elem] = true
local elemPos = index.elementPositions[elem]
if elemPos then
local elemCenterX = elemPos.x + elemPos.w / 2
local elemCenterY = elemPos.y + elemPos.h / 2
-- Check if element is in the correct direction
local isInDirection = false
if direction == "up" and elemCenterY < centerY then
isInDirection = true
elseif direction == "down" and elemCenterY > centerY then
isInDirection = true
elseif direction == "left" and elemCenterX < centerX then
isInDirection = true
elseif direction == "right" and elemCenterX > centerX then
isInDirection = true
end
if isInDirection then
return elem
end
end
end
end
end
end
end
return nil
end
return KeyboardNavigation
File diff suppressed because it is too large Load Diff
+697
View File
@@ -0,0 +1,697 @@
---@class MemoryScanner
---@field _StateManager table
---@field _Context table
---@field _ImageCache table
---@field _ErrorHandler table
local MemoryScanner = {}
---Initialize MemoryScanner with dependencies
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
function MemoryScanner.init(deps)
MemoryScanner._StateManager = deps.StateManager
MemoryScanner._Context = deps.Context
MemoryScanner._ImageCache = deps.ImageCache
MemoryScanner._ErrorHandler = deps.ErrorHandler
end
---Count items in a table
---@param tbl table
---@return number
local function countTable(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
---Calculate memory size estimate for a table (recursive)
---@param tbl table
---@param visited table? Tracking table to prevent circular references
---@param depth number? Current recursion depth
---@return number bytes Estimated memory usage in bytes
local function estimateTableSize(tbl, visited, depth)
if type(tbl) ~= "table" then
return 0
end
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent stack overflow
if depth > 10 then
return 0
end
-- Check for circular references
if visited[tbl] then
return 0
end
visited[tbl] = true
local size = 40 -- Base table overhead (approximate)
for k, v in pairs(tbl) do
-- Key size
if type(k) == "string" then
size = size + #k + 24 -- String overhead
elseif type(k) == "number" then
size = size + 8
else
size = size + 8 -- Reference
end
-- Value size
if type(v) == "string" then
size = size + #v + 24
elseif type(v) == "number" then
size = size + 8
elseif type(v) == "boolean" then
size = size + 4
elseif type(v) == "table" then
size = size + estimateTableSize(v, visited, depth + 1)
elseif type(v) == "function" then
size = size + 16 -- Function reference
else
size = size + 8 -- Other references
end
end
return size
end
---Scan StateManager for memory issues
---@return table report Detailed report of StateManager memory usage
function MemoryScanner.scanStateManager()
local report = {
stateCount = 0,
stateStoreSize = 0,
metadataSize = 0,
callSiteCounterSize = 0,
orphanedStates = {},
staleStates = {},
largeStates = {},
issues = {},
}
if not MemoryScanner._StateManager then
table.insert(report.issues, {
severity = "error",
message = "StateManager not initialized",
})
return report
end
local internal = MemoryScanner._StateManager._getInternalState()
local stateStore = internal.stateStore
local stateMetadata = internal.stateMetadata
local callSiteCounters = internal.callSiteCounters
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
-- Count states
report.stateCount = countTable(stateStore)
-- Estimate sizes
report.stateStoreSize = estimateTableSize(stateStore)
report.metadataSize = estimateTableSize(stateMetadata)
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
-- Check for orphaned states (metadata without state)
for id, _ in pairs(stateMetadata) do
if not stateStore[id] then
table.insert(report.orphanedStates, id)
end
end
-- Check for stale states (not accessed in many frames)
local staleThreshold = 120 -- 2 seconds at 60fps
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = currentFrame - meta.lastFrame
if framesSinceAccess > staleThreshold then
table.insert(report.staleStates, {
id = id,
framesSinceAccess = framesSinceAccess,
createdFrame = meta.createdFrame,
accessCount = meta.accessCount,
})
end
end
-- Check for large states (may indicate memory bloat)
for id, state in pairs(stateStore) do
local stateSize = estimateTableSize(state)
if stateSize > 1024 then -- More than 1KB
table.insert(report.largeStates, {
id = id,
size = stateSize,
keyCount = countTable(state),
})
end
end
-- Check callSiteCounters (should be near 0 after frame cleanup)
local callSiteCount = countTable(callSiteCounters)
if callSiteCount > 100 then
table.insert(report.issues, {
severity = "warning",
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
})
end
-- Check for excessive state count
if report.stateCount > 500 then
table.insert(report.issues, {
severity = "warning",
message = string.format("High state count: %d states", report.stateCount),
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
})
end
-- Check for orphaned states
if #report.orphanedStates > 0 then
table.insert(report.issues, {
severity = "error",
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
})
end
-- Check for stale states
if #report.staleStates > 10 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
})
end
return report
end
---Scan Context for memory issues
---@return table report Detailed report of Context memory usage
function MemoryScanner.scanContext()
local report = {
topElementCount = 0,
zIndexElementCount = 0,
frameElementCount = 0,
issues = {},
}
if not MemoryScanner._Context then
table.insert(report.issues, {
severity = "error",
message = "Context not initialized",
})
return report
end
-- Count elements
report.topElementCount = #MemoryScanner._Context.topElements
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
-- Check for stale z-index elements (should be cleared each frame)
if MemoryScanner._Context.isImmediateMode() then
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
-- If it has elements outside of frame rendering, that's a leak
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
})
end
end
-- Check for excessive element count
if report.topElementCount > 100 then
table.insert(report.issues, {
severity = "info",
message = string.format("High top-level element count: %d", report.topElementCount),
suggestion = "Consider consolidating elements or using fewer top-level containers",
})
end
return report
end
---Scan ImageCache for memory issues
---@return table report Detailed report of ImageCache memory usage
function MemoryScanner.scanImageCache()
local report = {
imageCount = 0,
estimatedMemory = 0,
issues = {},
}
if not MemoryScanner._ImageCache then
table.insert(report.issues, {
severity = "error",
message = "ImageCache not initialized",
})
return report
end
local stats = MemoryScanner._ImageCache.getStats()
report.imageCount = stats.count
report.estimatedMemory = stats.memoryEstimate
-- Check for excessive memory usage (>100MB)
if report.estimatedMemory > 100 * 1024 * 1024 then
table.insert(report.issues, {
severity = "warning",
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
suggestion = "Consider implementing cache eviction or clearing unused images",
})
end
-- Check for excessive image count
if report.imageCount > 50 then
table.insert(report.issues, {
severity = "info",
message = string.format("ImageCache has %d images", report.imageCount),
suggestion = "Review if all cached images are necessary",
})
end
return report
end
---Check if a circular reference is intentional (parent-child, module, or metatable)
---@param path string The current path where circular ref was detected
---@param originalPath string The original path where the table was first seen
---@return boolean True if this is an intentional circular reference
local function isIntentionalCircularReference(path, originalPath)
-- Pattern 1: child.parent points back to parent
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
if path:match("%.parent$") then
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
if parentPath == originalPath then
return true
end
end
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
if originalPath:match("%.parent$") then
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
if childParentPath == path then
return true
end
end
-- Pattern 3: Check for nested parent-child cycles
-- child.children[n].parent -> child
local segments = {}
for segment in path:gmatch("[^.]+") do
table.insert(segments, segment)
end
-- Look for .children.N.parent pattern
for i = 1, #segments - 2 do
if segments[i] == "children" and segments[i + 2] == "parent" then
-- Reconstruct path without the .children.N.parent suffix
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
if reconstructedPath == originalPath then
return true
end
end
end
-- Pattern 4: Metatable __index self-references (modules)
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
if path:match("%.__index$") then
local basePath = path:match("^(.+)%.__index$")
if basePath == originalPath then
return true
end
end
-- Pattern 5: Shared module references (elements sharing same module instances)
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
-- These start with _ and are typically modules
local pathModuleName = path:match("%.(_[%w]+)%.")
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
if pathModuleName and originalModuleName then
-- If both paths reference the same internal module (starting with _), it's intentional
if pathModuleName == originalModuleName then
return true
end
end
-- Pattern 6: Shared Color/Transform objects between elements
-- These are value objects that can be safely shared
if path:match("Color") and originalPath:match("Color") then
return true
end
if path:match("Transform") and originalPath:match("Transform") then
return true
end
-- Pattern 7: LayoutEngine holding reference to its element
-- Example: "element._layoutEngine.element" -> "element"
if path:match("%._layoutEngine%.element$") then
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
if elementPath == originalPath then
return true
end
end
-- Pattern 8: Renderer holding references to element properties
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
if path:match("%._renderer%.") then
local rendererBasePath = path:match("^(.+)%._renderer%.")
local originalBasePath = originalPath:match("^(.+)%.")
if rendererBasePath == originalBasePath then
return true
end
end
-- Pattern 9: Context reference from layout engine (shared singleton)
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
return true
end
return false
end
---Detect circular references in a table
---@param tbl table Table to check
---@param path string? Current path (for reporting)
---@param visited table? Tracking table
---@return table[] circularRefs Array of circular reference paths
---@return table[] intentionalRefs Array of intentional parent-child refs
local function detectCircularReferences(tbl, path, visited)
if type(tbl) ~= "table" then
return {}, {}
end
path = path or "root"
visited = visited or {}
local circularRefs = {}
local intentionalRefs = {}
-- Check if we've seen this table before
if visited[tbl] then
local ref = {
path = path,
originalPath = visited[tbl],
}
-- Determine if this is an intentional circular reference
if isIntentionalCircularReference(path, visited[tbl]) then
table.insert(intentionalRefs, ref)
else
table.insert(circularRefs, ref)
end
return circularRefs, intentionalRefs
end
-- Mark as visited
visited[tbl] = path
-- Recursively check children
for k, v in pairs(tbl) do
if type(v) == "table" then
local childPath = path .. "." .. tostring(k)
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
for _, ref in ipairs(childRefs) do
table.insert(circularRefs, ref)
end
for _, ref in ipairs(childIntentionalRefs) do
table.insert(intentionalRefs, ref)
end
end
end
return circularRefs, intentionalRefs
end
---Scan for circular references in immediate mode
---@return table report Detailed report of circular references
function MemoryScanner.scanCircularReferences()
local report = {
stateStoreCircularRefs = {},
stateStoreIntentionalRefs = {},
contextCircularRefs = {},
contextIntentionalRefs = {},
issues = {},
}
if MemoryScanner._StateManager then
local internal = MemoryScanner._StateManager._getInternalState()
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs =
detectCircularReferences(internal.stateStore, "stateStore")
end
if MemoryScanner._Context then
report.contextCircularRefs, report.contextIntentionalRefs =
detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
end
-- Report issues only for cross-module circular references
if #report.stateStoreCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format(
"Found %d cross-module circular references in StateManager",
#report.stateStoreCircularRefs
),
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
})
end
if #report.contextCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
})
end
return report
end
---Run comprehensive memory scan
---@return table report Complete memory analysis report
function MemoryScanner.scan()
local startMemory = collectgarbage("count")
local report = {
timestamp = os.time(),
startMemory = startMemory / 1024, -- MB
stateManager = MemoryScanner.scanStateManager(),
context = MemoryScanner.scanContext(),
imageCache = MemoryScanner.scanImageCache(),
circularRefs = MemoryScanner.scanCircularReferences(),
summary = {
totalIssues = 0,
criticalIssues = 0,
warnings = 0,
info = 0,
},
}
-- Count issues by severity
local function countIssues(subReport)
for _, issue in ipairs(subReport.issues or {}) do
report.summary.totalIssues = report.summary.totalIssues + 1
if issue.severity == "error" then
report.summary.criticalIssues = report.summary.criticalIssues + 1
elseif issue.severity == "warning" then
report.summary.warnings = report.summary.warnings + 1
elseif issue.severity == "info" then
report.summary.info = report.summary.info + 1
end
end
end
countIssues(report.stateManager)
countIssues(report.context)
countIssues(report.imageCache)
countIssues(report.circularRefs)
-- Force GC and measure freed memory
local beforeGC = collectgarbage("count")
collectgarbage("collect")
collectgarbage("collect")
local afterGC = collectgarbage("count")
report.gcAnalysis = {
beforeGC = beforeGC / 1024, -- MB
afterGC = afterGC / 1024, -- MB
freed = (beforeGC - afterGC) / 1024, -- MB
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
}
-- Analyze GC effectiveness
if report.gcAnalysis.freedPercent < 5 then
table.insert(report.stateManager.issues, {
severity = "info",
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Most memory is still referenced - this is normal if UI is active",
})
elseif report.gcAnalysis.freedPercent > 30 then
table.insert(report.stateManager.issues, {
severity = "warning",
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
})
end
return report
end
---Format report as human-readable string
---@param report table Memory scan report
---@return string formatted Formatted report
function MemoryScanner.formatReport(report)
local lines = {}
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
table.insert(lines, "")
-- Summary
table.insert(lines, "--- Summary ---")
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
table.insert(lines, string.format(" Info: %d", report.summary.info))
table.insert(lines, "")
-- StateManager
table.insert(lines, "--- StateManager ---")
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
if #report.stateManager.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.stateManager.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Context
table.insert(lines, "--- Context ---")
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
if #report.context.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.context.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- ImageCache
table.insert(lines, "--- ImageCache ---")
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
if #report.imageCache.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.imageCache.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Circular References
table.insert(lines, "--- Circular References ---")
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
table.insert(
lines,
string.format(
"StateStore (Intentional - parent-child, modules, metatables): %d",
#report.circularRefs.stateStoreIntentionalRefs
)
)
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
table.insert(
lines,
string.format(
"Context (Intentional - parent-child, modules, metatables): %d",
#report.circularRefs.contextIntentionalRefs
)
)
if #report.circularRefs.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.circularRefs.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
else
table.insert(lines, " ✓ No unexpected circular references detected")
end
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
table.insert(lines, "")
-- GC Analysis
table.insert(lines, "--- Garbage Collection Analysis ---")
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
table.insert(lines, "")
table.insert(lines, "=== End Report ===")
return table.concat(lines, "\n")
end
---Save report to file
---@param report table Memory scan report
---@param filename string? Output filename (default: memory_report.txt)
function MemoryScanner.saveReport(report, filename)
filename = filename or "memory_report.txt"
local formatted = MemoryScanner.formatReport(report)
local file = io.open(filename, "w")
if file then
file:write(formatted)
file:close()
if MemoryScanner._ErrorHandler then
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
resourceType = "report",
path = filename,
status = "saved",
})
end
else
if MemoryScanner._ErrorHandler then
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
resourceType = "report",
path = filename,
status = "failed to save",
})
end
end
end
return MemoryScanner
+202
View File
@@ -0,0 +1,202 @@
---@class ModuleLoader
local ModuleLoader = {}
-- Module registry to track loaded vs. stub modules
ModuleLoader._registry = {}
ModuleLoader._ErrorHandler = nil
--- Initialize ModuleLoader with dependencies
---@param deps table
function ModuleLoader.init(deps)
ModuleLoader._ErrorHandler = deps.ErrorHandler
end
--- Create a null-object stub for a missing optional module
--- Provides safe defaults that won't cause runtime errors
---@param moduleName string
---@return table
local function createNullObject(moduleName)
local stub = {
_isStub = true,
_moduleName = moduleName,
}
-- Common method stubs that return safe defaults
local metatable = {
__index = function(_, key)
-- Common initialization method
if key == "init" then
return function()
return stub
end
end
-- Common constructor method
if key == "new" then
return function()
return stub
end
end
-- Common draw method
if key == "draw" then
return function() end
end
-- Common update method
if key == "update" then
return function() end
end
-- Common render method
if key == "render" then
return function() end
end
-- Common cleanup method
if key == "destroy" then
return function() end
end
-- Common cleanup method
if key == "cleanup" then
return function() end
end
-- Common clear method
if key == "clear" then
return function() end
end
-- Common reset method
if key == "reset" then
return function() end
end
-- Common get method
if key == "get" then
return function()
return nil
end
end
-- Common set method
if key == "set" then
return function() end
end
-- Common load method
if key == "load" then
return function()
return stub
end
end
-- Common cache-related methods
if key == "cache" or key == "getCache" or key == "clearCache" then
return function()
return {}
end
end
-- For any unknown method, return a no-op function that accepts any arguments
-- This allows safe method calls on stub objects (e.g., Performance:startFrame())
return function()
return stub
end
end,
-- Make function calls safe (in case the stub itself is called)
__call = function()
return stub
end,
}
setmetatable(stub, metatable)
return stub
end
--- Safely require a module with graceful fallback for optional modules
--- Returns the module if it exists, or a null-object stub if it's optional and missing
--- Throws an error if a required module is missing
---@param modulePath string Full path to the module (e.g., "modules.Performance")
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
---@return table module The loaded module or a null-object stub
function ModuleLoader.safeRequire(modulePath, isOptional)
-- Check if already loaded
if ModuleLoader._registry[modulePath] then
return ModuleLoader._registry[modulePath]
end
-- Attempt to load the module
local success, result = pcall(require, modulePath)
if success then
-- Module loaded successfully
ModuleLoader._registry[modulePath] = result
return result
else
-- Module failed to load
if isOptional then
-- Create null-object stub for optional module
local stub = createNullObject(modulePath)
ModuleLoader._registry[modulePath] = stub
-- Log warning about missing optional module
if ModuleLoader._ErrorHandler then
ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", {
modulePath = modulePath,
})
end
return stub
else
-- Required module is missing - throw error
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
end
end
end
--- Check if a module is actually loaded (not a stub)
---@param modulePath string Full path to the module
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
function ModuleLoader.isModuleLoaded(modulePath)
local module = ModuleLoader._registry[modulePath]
if not module then
return false
end
-- Check if it's a stub
return not module._isStub
end
--- Get list of all loaded modules
---@return table modules List of module paths that are actually loaded (not stubs)
function ModuleLoader.getLoadedModules()
local loaded = {}
for path, module in pairs(ModuleLoader._registry) do
if not module._isStub then
table.insert(loaded, path)
end
end
return loaded
end
--- Get list of all stub modules
---@return table stubs List of module paths that are stubs
function ModuleLoader.getStubModules()
local stubs = {}
for path, module in pairs(ModuleLoader._registry) do
if module._isStub then
table.insert(stubs, path)
end
end
return stubs
end
--- Clear the module registry (useful for testing)
function ModuleLoader._clearRegistry()
ModuleLoader._registry = {}
end
return ModuleLoader
+217
View File
@@ -0,0 +1,217 @@
local modulePath = (...):match("(.-)[^%.]+$")
local ImageScaler = require(modulePath .. "ImageScaler")
local NinePatch = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize NinePatch with dependencies
---@param deps table Dependencies table with ErrorHandler
function NinePatch.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
-- Also initialize ImageScaler since it's a dependency
if ImageScaler.init then
ImageScaler.init(deps)
end
end
--- Draw a 9-patch component using Android-style rendering
--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
---@param component ThemeComponent
---@param atlas love.Image
---@param x number -- X position (top-left corner)
---@param y number -- Y position (top-left corner)
---@param width number -- Total width (border-box)
---@param height number -- Total height (border-box)
---@param opacity number?
---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
if not component or not atlas then
return
end
opacity = opacity or 1
love.graphics.setColor(1, 1, 1, opacity)
local regions = component.regions
-- Extract border dimensions from regions (in pixels)
local left = regions.topLeft.w
local right = regions.topRight.w
local top = regions.topLeft.h
local bottom = regions.bottomLeft.h
local centerW = regions.middleCenter.w
local centerH = regions.middleCenter.h
-- Calculate content area (space remaining after borders)
local contentWidth = width - left - right
local contentHeight = height - top - bottom
-- Clamp to prevent negative dimensions
contentWidth = math.max(0, contentWidth)
contentHeight = math.max(0, contentHeight)
-- Calculate stretch scales for edges and center
local scaleX = contentWidth / centerW
local scaleY = contentHeight / centerH
-- Create quads for each region
local atlasWidth, atlasHeight = atlas:getDimensions()
local function makeQuad(region)
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
end
-- Get corner scale multiplier
-- Priority: element-level override > component setting > default (nil = no scaling)
local scaleCorners = elementScaleCorners
if scaleCorners == nil then
scaleCorners = component.scaleCorners
end
-- Priority: element-level override > component setting > default ("bilinear")
local scalingAlgorithm = elementScalingAlgorithm
if scalingAlgorithm == nil then
scalingAlgorithm = component.scalingAlgorithm or "bilinear"
end
if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
-- Initialize cache if needed
if not component._scaledRegionCache then
component._scaledRegionCache = {}
end
-- Use the numeric scale multiplier directly
local scaleFactor = scaleCorners
-- Helper to get or create scaled region
local function getScaledRegion(regionName, region, targetWidth, targetHeight)
local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm)
if component._scaledRegionCache[cacheKey] then
return component._scaledRegionCache[cacheKey]
end
-- Get ImageData from component (stored during theme loading)
local atlasData = component._loadedAtlasData
if not atlasData then
ErrorHandler.error(
"NinePatch",
"REN_007",
"No ImageData available for atlas. Image must be loaded with safeLoadImage.",
{
componentType = component.type,
}
)
end
local scaledData
if scalingAlgorithm == "nearest" then
scaledData =
ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
else
scaledData =
ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
end
-- Convert to image and cache
local scaledImage = love.graphics.newImage(scaledData)
component._scaledRegionCache[cacheKey] = scaledImage
return scaledImage
end
-- Calculate scaled dimensions for corners
local scaledLeft = math.floor(left * scaleFactor + 0.5)
local scaledRight = math.floor(right * scaleFactor + 0.5)
local scaledTop = math.floor(top * scaleFactor + 0.5)
local scaledBottom = math.floor(bottom * scaleFactor + 0.5)
-- CORNERS (scaled using algorithm)
local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop)
local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop)
local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom)
local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom)
love.graphics.draw(topLeftScaled, x, y)
love.graphics.draw(topRightScaled, x + width - scaledRight, y)
love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom)
love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom)
-- Update content dimensions to account for scaled borders
local adjustedContentWidth = width - scaledLeft - scaledRight
local adjustedContentHeight = height - scaledTop - scaledBottom
adjustedContentWidth = math.max(0, adjustedContentWidth)
adjustedContentHeight = math.max(0, adjustedContentHeight)
-- Recalculate stretch scales
local adjustedScaleX = adjustedContentWidth / centerW
local adjustedScaleY = adjustedContentHeight / centerH
-- TOP/BOTTOM EDGES (stretch horizontally, scale vertically)
if adjustedContentWidth > 0 then
local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop)
local bottomCenterScaled =
getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom)
love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1)
love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1)
end
-- LEFT/RIGHT EDGES (stretch vertically, scale horizontally)
if adjustedContentHeight > 0 then
local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h)
local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h)
love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY)
love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY)
end
-- CENTER (stretch both dimensions, no scaling)
if adjustedContentWidth > 0 and adjustedContentHeight > 0 then
love.graphics.draw(
atlas,
makeQuad(regions.middleCenter),
x + scaledLeft,
y + scaledTop,
0,
adjustedScaleX,
adjustedScaleY
)
end
else
-- Original rendering logic (no scaling)
-- CORNERS (no scaling - 1:1 pixel perfect)
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
-- TOP/BOTTOM EDGES (stretch horizontally only)
if contentWidth > 0 then
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
end
-- LEFT/RIGHT EDGES (stretch vertically only)
if contentHeight > 0 then
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
end
-- CENTER (stretch both dimensions)
if contentWidth > 0 and contentHeight > 0 then
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
end
end
-- Reset color
love.graphics.setColor(1, 1, 1, 1)
end
return NinePatch
+351
View File
@@ -0,0 +1,351 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- All numeric, range, type, and enum validation lives here.
-- `clamp` is injected via init() to avoid a cross-import into utils.
-- `ErrorHandler` is injected via init() so error reporting routes through
-- the shared handler (matching the pre-split behavior of utils.validate*).
local ErrorHandler = nil
local clamp = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = table, clamp = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler or ErrorHandler
clamp = deps.clamp or clamp
end
end
-- Numeric validation utilities
--- Check if a value is NaN (not-a-number)
--- @param value any Value to check
--- @return boolean
local function isNaN(value)
return type(value) == "number" and value ~= value
end
--- Check if a value is Infinity
--- @param value any Value to check
--- @return boolean
local function isInfinity(value)
return type(value) == "number" and (value == math.huge or value == -math.huge)
end
--- Validate a numeric value with comprehensive checks
--- @param value any Value to validate
--- @param options table? Validation options
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
local function validateNumber(value, options)
options = options or {}
-- Check if value is a number type
if type(value) ~= "number" then
if options.default ~= nil then
return true, nil, options.default
end
return false, string.format("Value must be a number, got %s", type(value)), nil
end
-- Check for NaN
if isNaN(value) then
if not options.allowNaN then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is NaN (not-a-number)", nil
end
end
-- Check for Infinity
if isInfinity(value) then
if not options.allowInfinity then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is Infinity", nil
end
end
-- Check for integer requirement
if options.integer and math.floor(value) ~= value then
return false, string.format("Value must be an integer, got %s", value), nil
end
-- Check for positive requirement
if options.positive and value <= 0 then
return false, string.format("Value must be positive, got %s", value), nil
end
-- Check bounds
if options.min and value < options.min then
return false, string.format("Value %s is below minimum %s", value, options.min), nil
end
if options.max and value > options.max then
return false, string.format("Value %s is above maximum %s", value, options.max), nil
end
return true, nil, value
end
--- Sanitize a numeric value (never errors, always returns valid number)
--- @param value any Value to sanitize
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @param default number? Default value for invalid inputs
--- @return number Sanitized value
local function sanitizeNumber(value, min, max, default)
default = default or 0
min = min or -math.huge
max = max or math.huge
-- Convert to number if possible
if type(value) == "string" then
value = tonumber(value)
end
-- Handle non-numeric
if type(value) ~= "number" then
return default
end
-- Handle NaN
if isNaN(value) then
return default
end
-- Handle Infinity
if value == math.huge then
return max
end
if value == -math.huge then
return min
end
-- Clamp to range
return clamp(value, min, max)
end
--- Validate and convert to integer
--- @param value any Value to validate
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
local function validateInteger(value, min, max)
local valid, err, sanitized = validateNumber(value, {
min = min,
max = max,
integer = true,
})
if not valid then
return false, err, nil
end
return true, nil, math.floor(sanitized or value)
end
--- Validate and normalize percentage value
--- @param value any Value to validate (can be "50%", 0.5, or 50)
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
local function validatePercentage(value)
-- Handle string percentage
if type(value) == "string" then
local num = value:match("^(%d+%.?%d*)%%$")
if num then
value = tonumber(num)
if value then
value = value / 100
end
else
value = tonumber(value)
end
end
if type(value) ~= "number" then
return false, "Percentage must be a number", nil
end
if isNaN(value) or isInfinity(value) then
return false, "Percentage cannot be NaN or Infinity", nil
end
-- If value is > 1, assume it's 0-100 range
if value > 1 then
value = value / 100
end
-- Clamp to 0-1
value = clamp(value, 0, 1)
return true, nil, value
end
--- Validate opacity value (0-1)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
local function validateOpacity(value)
return validateNumber(value, { min = 0, max = 1, default = 1 })
end
--- Validate degree value (0-360)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
local function validateDegrees(value)
local valid, err, sanitized = validateNumber(value)
if not valid then
return false, err, nil
end
-- Normalize to 0-360 range
local degrees = sanitized or value
degrees = degrees % 360
if degrees < 0 then
degrees = degrees + 360
end
return true, nil, degrees
end
--- Validate coordinate value (pixel position)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
local function validateCoordinate(value)
return validateNumber(value, {
allowNaN = false,
allowInfinity = false,
})
end
--- Validate dimension value (width/height, must be non-negative)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
local function validateDimension(value)
return validateNumber(value, {
min = 0,
allowNaN = false,
allowInfinity = false,
})
end
--- Validate that a value is in an enum table
---@param value any Value to validate
---@param enumTable table Enum table with valid values
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateEnum(value, enumTable, propName, moduleName)
if value == nil then
return true
end
for _, validValue in pairs(enumTable) do
if value == validValue then
return true
end
end
-- Build list of valid options
local validOptions = {}
for _, v in pairs(enumTable) do
table.insert(validOptions, "'" .. v .. "'")
end
table.sort(validOptions)
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_007", {
property = propName,
expected = table.concat(validOptions, ", "),
got = tostring(value),
})
else
error(
string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))
)
end
end
--- Validate that a numeric value is within a range
---@param value any Value to validate
---@param min number Minimum allowed value
---@param max number Maximum allowed value
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateRange(value, min, max, propName, moduleName)
if value == nil then
return true
end
if type(value) ~= "number" then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_001", {
property = propName,
expected = "number",
got = type(value),
})
else
error(string.format("%s must be a number, got %s", propName, type(value)))
end
elseif value < min or value > max then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_002", {
property = propName,
min = tostring(min),
max = tostring(max),
value = tostring(value),
})
else
error(
string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))
)
end
end
return true
end
--- Validate that a value is of the expected type
---@param value any Value to validate
---@param expectedType string Expected type name
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateType(value, expectedType, propName, moduleName)
if value == nil then
return true
end
local actualType = type(value)
if actualType ~= expectedType then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_001", {
property = propName,
expected = expectedType,
got = actualType,
})
else
error(string.format("%s must be %s, got %s", propName, expectedType, actualType))
end
end
return true
end
return {
init = init,
isNaN = isNaN,
isInfinity = isInfinity,
validateNumber = validateNumber,
sanitizeNumber = sanitizeNumber,
validateInteger = validateInteger,
validatePercentage = validatePercentage,
validateOpacity = validateOpacity,
validateDegrees = validateDegrees,
validateCoordinate = validateCoordinate,
validateDimension = validateDimension,
validateEnum = validateEnum,
validateRange = validateRange,
validateType = validateType,
}
+198
View File
@@ -0,0 +1,198 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Path sanitization, validation, and file-extension helpers.
-- Uses love.filesystem when available (optional) for existence checks.
--- Normalize a file path for consistent cache keys
---@param path string File path to normalize
---@return string Normalized path
local function normalizePath(path)
path = path:match("^%s*(.-)%s*$")
path = path:gsub("\\", "/")
path = path:gsub("/+", "/")
return path
end
--- Sanitize a file path
--- @param path string Path to sanitize
--- @return string Sanitized path
local function sanitizePath(path)
if path == nil then
return ""
end
path = tostring(path)
-- Trim whitespace
path = path:match("^%s*(.-)%s*$") or ""
-- Normalize separators to forward slash
path = path:gsub("\\", "/")
-- Remove duplicate slashes
path = path:gsub("/+", "/")
-- Remove trailing slash (except for root)
if #path > 1 and path:sub(-1) == "/" then
path = path:sub(1, -2)
end
return path
end
--- Check if a path is safe (no traversal attacks)
--- @param path string Path to check
--- @param baseDir string? Base directory to check against (optional)
--- @return boolean, string? Returns true if safe, or false with reason
local function isPathSafe(path, baseDir)
if path == nil or path == "" then
return false, "Path is empty"
end
-- Sanitize the path
path = sanitizePath(path)
-- Check for suspicious patterns
if path:match("%.%.") then
return false, "Path contains '..' (parent directory reference)"
end
-- Check for null bytes
if path:match("%z") then
return false, "Path contains null bytes"
end
-- Check for encoded traversal attempts (including double-encoding)
local lowerPath = path:lower()
if
lowerPath:match("%%2e")
or lowerPath:match("%%2f")
or lowerPath:match("%%5c")
or lowerPath:match("%%252e")
or lowerPath:match("%%252f")
or lowerPath:match("%%255c")
then
return false, "Path contains URL-encoded directory separators"
end
-- If baseDir is provided, ensure path is within it
if baseDir then
baseDir = sanitizePath(baseDir)
-- For relative paths, prepend baseDir
local fullPath = path
if not path:match("^/") and not path:match("^%a:") then
fullPath = baseDir .. "/" .. path
end
fullPath = sanitizePath(fullPath)
-- Check if fullPath starts with baseDir
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
return false, "Path is outside allowed directory"
end
end
return true, nil
end
--- Validate a file path with comprehensive checks
--- @param path string Path to validate
--- @param options table? Validation options
--- @return boolean, string? Returns true if valid, or false with error message
local function validatePath(path, options)
options = options or {}
-- Check path is not nil/empty
if path == nil or path == "" then
return false, "Path is empty"
end
path = tostring(path)
-- Check maximum length
local maxLength = options.maxLength or 4096
if #path > maxLength then
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
end
-- Sanitize path
path = sanitizePath(path)
-- Check for safety (traversal attacks)
local safe, reason = isPathSafe(path, options.baseDir)
if not safe then
return false, reason
end
-- Check allowed extensions
if options.allowedExtensions then
local ext = path:match("%.([^%.]+)$")
if not ext then
return false, "Path has no file extension"
end
ext = ext:lower()
local allowed = false
for _, allowedExt in ipairs(options.allowedExtensions) do
if ext == allowedExt:lower() then
allowed = true
break
end
end
if not allowed then
return false, string.format("File extension '%s' is not allowed", ext)
end
end
-- Check if file must exist
if options.mustExist and love and love.filesystem then
local info = love.filesystem.getInfo(path)
if not info then
return false, "File does not exist"
end
end
return true, nil
end
--- Get file extension from path
--- @param path string File path
--- @return string? extension File extension (lowercase) or nil
local function getFileExtension(path)
if not path then
return nil
end
local ext = path:match("%.([^%.]+)$")
return ext and ext:lower() or nil
end
--- Check if path has allowed extension
--- @param path string File path
--- @param allowedExtensions table Array of allowed extensions
--- @return boolean
local function hasAllowedExtension(path, allowedExtensions)
local ext = getFileExtension(path)
if not ext then
return false
end
for _, allowedExt in ipairs(allowedExtensions) do
if ext == allowedExt:lower() then
return true
end
end
return false
end
return {
normalizePath = normalizePath,
sanitizePath = sanitizePath,
isPathSafe = isPathSafe,
validatePath = validatePath,
getFileExtension = getFileExtension,
hasAllowedExtension = hasAllowedExtension,
}
+560
View File
@@ -0,0 +1,560 @@
---@class Performance
---@field enabled boolean
---@field hudEnabled boolean
---@field hudToggleKey string
---@field hudPosition {x: number, y: number}
---@field warningThresholdMs number
---@field criticalThresholdMs number
---@field logToConsole boolean
---@field logWarnings boolean
---@field warningsEnabled boolean
---@field _ErrorHandler table?
---@field _timers table
---@field _metrics table
---@field _lastMetricsCleanup number
---@field _frameMetrics table
---@field _memoryMetrics table
---@field _warnings table
---@field _lastFrameStart number?
---@field _shownWarnings table
---@field _memoryProfiler table
local Performance = {}
Performance.__index = Performance
---@type Performance|nil
local instance = nil
local METRICS_CLEANUP_INTERVAL = 30
local METRICS_RETENTION_TIME = 10
local MAX_METRICS_COUNT = 500
local CORE_METRICS = { frame = true, layout = true, render = true }
---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}?
---@param deps {ErrorHandler: ErrorHandler}
---@return Performance
function Performance.init(config, deps)
if instance == nil then
local self = setmetatable({}, Performance)
-- Configuration
self.enabled = config and config.enabled or false
self.hudEnabled = config and config.hudEnabled or false
self.hudToggleKey = config and config.hudToggleKey or "f3"
self.hudPosition = config and config.hudPosition or { x = 10, y = 10 }
self.warningThresholdMs = config and config.warningThresholdMs or 13.0
self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67
self.logToConsole = config and config.logToConsole or false
self.logWarnings = config and config.logWarnings or true
self.warningsEnabled = config and config.warningsEnabled or true
self._timers = {}
self._metrics = {}
self._lastMetricsCleanup = 0
self._frameMetrics = {
frameCount = 0,
totalTime = 0,
lastFrameTime = 0,
minFrameTime = math.huge,
maxFrameTime = 0,
fps = 0,
lastFpsUpdate = 0,
fpsUpdateInterval = 0.5,
}
self._memoryMetrics = {
current = 0,
peak = 0,
gcCount = 0,
lastGcCheck = 0,
}
self._warnings = {}
self._lastFrameStart = nil
self._shownWarnings = {}
self._memoryProfiler = {
enabled = config and config.memoryProfiling or false,
sampleInterval = 60,
framesSinceLastSample = 0,
samples = {},
maxSamples = 20,
monitoredTables = {},
}
self._ErrorHandler = deps and deps.ErrorHandler
instance = self
end
return instance
end
--- Toggle HUD visibility
function Performance:toggleHUD()
self.hudEnabled = not self.hudEnabled
end
function Performance:startTimer(name)
if not self.enabled then
return
end
self._timers[name] = love.timer.getTime()
end
function Performance:stopTimer(name)
if not self.enabled then
return nil
end
local startTime = self._timers[name]
if not startTime then
-- Silently return nil if timer wasn't started
-- This can happen legitimately when Performance is toggled mid-frame
-- or when layout functions have early returns
return nil
end
local elapsed = (love.timer.getTime() - startTime) * 1000
self._timers[name] = nil
-- Update metrics
if not self._metrics[name] then
self._metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
lastUsed = love.timer.getTime(),
}
end
local m = self._metrics[name]
m.total = m.total + elapsed
m.count = m.count + 1
m.min = math.min(m.min, elapsed)
m.max = math.max(m.max, elapsed)
m.average = m.total / m.count
m.lastUsed = love.timer.getTime()
-- Check for warnings
if elapsed > self.criticalThresholdMs then
self:_addWarning(name, elapsed, "critical")
elseif elapsed > self.warningThresholdMs then
self:_addWarning(name, elapsed, "warning")
end
if self.logToConsole then
-- Use ErrorHandler if available, otherwise fall back to print
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn("Performance", "PERF_001", {
metric = name,
elapsed = string.format("%.3fms", elapsed),
})
else
print(string.format("[Performance] %s: %.3fms", name, elapsed))
end
end
return elapsed
end
--- Update with actual delta time from LÖVE (call from love.update)
---@param dt number Delta time in seconds
function Performance:updateDeltaTime(dt)
if not self.enabled then
return
end
local now = love.timer.getTime()
if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then
if dt > 0 then
self._frameMetrics.fps = math.floor(1 / dt + 0.5)
end
self._frameMetrics.lastFpsUpdate = now
end
end
--- Start frame timing (call at beginning of frame)
function Performance:startFrame()
if not self.enabled then
return
end
self._lastFrameStart = love.timer.getTime()
self:_updateMemory()
end
function Performance:endFrame()
if not self.enabled or not self._lastFrameStart then
return
end
local now = love.timer.getTime()
local frameTime = (now - self._lastFrameStart) * 1000
self._frameMetrics.lastFrameTime = frameTime
self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime
self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1
self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime)
self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime)
if frameTime > self.criticalThresholdMs then
self:_addWarning("frame", frameTime, "critical")
end
self:updateMemoryProfiling()
-- Periodic metrics cleanup
if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
local cleanupTime = now - METRICS_RETENTION_TIME
for name, data in pairs(self._metrics) do
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
self._metrics[name] = nil
end
end
self._lastMetricsCleanup = now
end
-- Enforce max metrics limit
local metricsCount = 0
for _ in pairs(self._metrics) do
metricsCount = metricsCount + 1
end
if metricsCount > MAX_METRICS_COUNT then
local sortedMetrics = {}
for name, data in pairs(self._metrics) do
if not CORE_METRICS[name] then
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
end
end
table.sort(sortedMetrics, function(a, b)
return a.lastUsed < b.lastUsed
end)
local toRemove = metricsCount - MAX_METRICS_COUNT
for i = 1, math.min(toRemove, #sortedMetrics) do
self._metrics[sortedMetrics[i].name] = nil
end
end
end
--- Update memory metrics
function Performance:_updateMemory()
if not self.enabled then
return
end
local memKb = collectgarbage("count")
self._memoryMetrics.current = memKb
self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb)
local now = love.timer.getTime()
if now - self._memoryMetrics.lastGcCheck >= 1.0 then
self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1
self._memoryMetrics.lastGcCheck = now
end
end
--- Add a performance warning (private)
--- @param name string Metric name
--- @param value number Metric value
--- @param level "warning"|"critical" Warning level
function Performance:_addWarning(name, value, level)
if not self.logWarnings then
return
end
local warning = {
name = name,
value = value,
level = level,
time = love.timer.getTime(),
}
table.insert(self._warnings, warning)
if #self._warnings > 100 then
table.remove(self._warnings, 1)
end
if self.logToConsole or self.warningsEnabled then
local warningKey = name .. "_" .. level
local lastWarningTime = self._shownWarnings[warningKey] or 0
local now = love.timer.getTime()
if now - lastWarningTime >= 60 then
if self._ErrorHandler and self._ErrorHandler.warn then
local code = level == "critical" and "PERF_002" or "PERF_001"
self._ErrorHandler:warn("Performance", code, {
metric = name,
value = string.format("%.2fms", value),
threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs,
})
end
self._shownWarnings[warningKey] = now
end
end
end
--- Render performance HUD
--- @param x number? X position (default: 10)
--- @param y number? Y position (default: 10)
function Performance:renderHUD(x, y)
if not self.hudEnabled then
return
end
x = x or self.hudPosition.x
y = y or self.hudPosition.y
self:_updateMemory()
local fm = self._frameMetrics
local mm = self._memoryMetrics
love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", x, y, 300, 220)
love.graphics.setColor(1, 1, 1, 1)
local lineHeight = 18
local currentY = y + 10
-- FPS
local fpsColor = { 1, 1, 1 }
if fm.lastFrameTime > self.criticalThresholdMs then
fpsColor = { 1, 0, 0 }
elseif fm.lastFrameTime > self.warningThresholdMs then
fpsColor = { 1, 1, 0 }
end
love.graphics.setColor(fpsColor)
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.setColor(1, 1, 1, 1)
local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0
love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
local currentMb = mm.current / 1024
local peakMb = mm.peak / 1024
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY)
currentY = currentY + lineHeight
local metricsCount = 0
for _ in pairs(self._metrics) do
metricsCount = metricsCount + 1
end
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
love.graphics.setColor(metricsColor)
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
currentY = currentY + lineHeight + 5
-- Top timings
love.graphics.setColor(1, 1, 1, 1)
local sortedMetrics = {}
for name, data in pairs(self._metrics) do
table.insert(sortedMetrics, { name = name, average = data.average })
end
table.sort(sortedMetrics, function(a, b)
return a.average > b.average
end)
love.graphics.print("Top Timings:", x + 10, currentY)
currentY = currentY + lineHeight
for i = 1, math.min(5, #sortedMetrics) do
local m = sortedMetrics[i]
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
currentY = currentY + lineHeight
end
if #self._warnings > 0 then
love.graphics.setColor(1, 0.5, 0, 1)
love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY)
end
end
--- Handle keyboard input for HUD toggle
--- @param key string Key pressed
function Performance:keypressed(key)
if key == self.hudToggleKey then
self:toggleHUD()
end
end
--- Log a performance warning (only once per warning key)
--- @param warningKey string Unique key for this warning type
--- @param module string Module name (e.g., "LayoutEngine", "Element")
--- @param message string Warning message
--- @param details table? Additional details
--- @param suggestion string? Optimization suggestion
function Performance:logWarning(warningKey, module, message, details, suggestion)
if not self.warningsEnabled then
return
end
if self._shownWarnings[warningKey] then
return
end
self._shownWarnings[warningKey] = true
local count = 0
for _ in pairs(self._shownWarnings) do
count = count + 1
end
if count > 1000 then
self._shownWarnings = { [warningKey] = true }
end
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn(module, "PERF_001", details or {})
end
end
--- Track a counter metric (increments per frame)
--- @param name string Counter name
--- @param value number? Value to add (default: 1)
function Performance:incrementCounter(name, value)
if not self.enabled then
return
end
value = value or 1
if not self._metrics[name] then
self._metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
frameValue = 0,
lastUsed = love.timer.getTime(),
}
end
local m = self._metrics[name]
m.frameValue = (m.frameValue or 0) + value
m.lastUsed = love.timer.getTime()
end
--- Reset frame counters (call at end of frame)
function Performance:resetFrameCounters()
if not self.enabled then
return
end
local now = love.timer.getTime()
local toRemove = {}
for name, data in pairs(self._metrics) do
if data.frameValue then
if data.frameValue > 0 then
data.total = data.total + data.frameValue
data.count = data.count + 1
data.min = math.min(data.min, data.frameValue)
data.max = math.max(data.max, data.frameValue)
data.average = data.total / data.count
data.lastUsed = now
end
data.frameValue = 0
if data.count == 0 and not CORE_METRICS[name] then
table.insert(toRemove, name)
end
end
end
for _, name in ipairs(toRemove) do
self._metrics[name] = nil
end
end
--- Register a table for memory leak monitoring
--- @param name string Friendly name for the table
--- @param tableRef table Reference to the table to monitor
function Performance:registerTableForMonitoring(name, tableRef)
self._memoryProfiler.monitoredTables[name] = tableRef
end
function Performance:_sampleMemory()
local sample = {
time = love.timer.getTime(),
memory = collectgarbage("count") / 1024, -- MB
tableSizes = {},
}
local function getTableSize(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do
sample.tableSizes[name] = getTableSize(tableRef)
end
table.insert(self._memoryProfiler.samples, sample)
-- Keep only maxSamples
if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then
table.remove(self._memoryProfiler.samples, 1)
end
-- Check for memory leaks (consistent growth)
if #self._memoryProfiler.samples >= 5 then
for name, _ in pairs(self._memoryProfiler.monitoredTables) do
local sizes = {}
for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do
table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name])
end
-- Check if table is consistently growing
local growing = true
for i = 2, #sizes do
if sizes[i] <= sizes[i - 1] then
growing = false
break
end
end
if growing and sizes[#sizes] > sizes[1] * 1.5 then
self:_addWarning("memory_leak", sizes[#sizes], "warning")
if not self._shownWarnings[name] then
local message = string.format("Table '%s' growing consistently", name)
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn("Performance", "MEM_001", {
table = name,
initialSize = sizes[1],
currentSize = sizes[#sizes],
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
})
end
self._shownWarnings[name] = true
end
elseif not growing then
self._shownWarnings[name] = nil
end
end
end
end
--- Update memory profiling (call from endFrame)
function Performance:updateMemoryProfiling()
if not self._memoryProfiler.enabled then
return
end
self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1
if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then
self:_sampleMemory()
self._memoryProfiler.framesSinceLastSample = 0
end
end
return Performance
+505
View File
@@ -0,0 +1,505 @@
-- modules/PropertySchema.lua
--
-- Declarative source of truth for every Element prop.
--
-- Each entry describes one prop that Element.new / Element:setProperty currently
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
-- setProperty dispatch) read this metadata instead of hardcoding property names.
--
-- Design constraints (locked — tasks 03/05 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Normalizers/validators are small, dependency-free closures so the module is
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
-- modules are left as `nil` here and applied by construction-time special
-- handlers in Task 03; only defaults expressible as literals are stored.
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
-- no per-call construction.
-- * Additive — `define(specs)` merges entries by name so build profiles can
-- extend/override without rebuilding the whole table.
--
-- Metadata shape per prop (all fields present, false/nil when not applicable):
-- type string — type tag for tooling ("number"|"string"|"boolean"|
-- "table"|"function"|"color"|"any")
-- default any|nil — literal default value applied when prop is absent
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
-- storage (e.g. single-value padding -> 4-side table)
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
-- input (Task 03 warns + falls back on false)
-- isDimension boolean — true for width/height: setProperty routes these
-- through _resolveDimensionProperty (unit-string
-- resolution + border-box sync). Other unit-accepting
-- props (x/y/gap/padding/etc.) are resolved at
-- construction via special handlers, NOT via this flag.
-- affectsLayout boolean — true for props in the legacy setProperty
-- `layoutProperties` table; setting one invalidates
-- layout (matches baseline behavior exactly).
-- syncsTheme boolean — true for props whose setProperty path must reach
-- ThemeManager/Renderer (disabled/active/themeComponent)
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
-- boolean companion prop (auto-wired by Task 03)
-- storageKey string|nil— when set, the prop is stored on the element under
-- this key instead of its own name (prop aliases, e.g.
-- isDisabled -> stored as `disabled`)
local PropertySchema = {}
---@type table<string, table>
local registry = {}
-- ---------------------------------------------------------------------------
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
-- ---------------------------------------------------------------------------
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
local function expandSides(value)
if value == nil then
return nil
end
if type(value) == "table" then
return value
end
return { top = value, right = value, bottom = value, left = value }
end
--- Normalize flex direction aliases to internal enum names.
--- "row" -> "horizontal", "column" -> "vertical",
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
--- everything else passes through.
local function normalizeFlexDirection(value)
if value == "row" then
return "horizontal"
elseif value == "column" then
return "vertical"
elseif value == "row-reverse" then
return "horizontal-reverse"
elseif value == "column-reverse" then
return "vertical-reverse"
end
return value
end
--- Replicate Element.new's border-shape normalization (pure).
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
--- truthy side remains.
--- * number / other truthy scalar: kept as-is.
--- * nil / false: nil.
local function normalizeBorder(value)
if value == nil or value == false then
return nil
end
if type(value) == "table" then
local function side(v)
if v == true then
return 1
elseif type(v) == "number" then
return v
else
return false
end
end
local t = side(value.top)
local r = side(value.right)
local b = side(value.bottom)
local l = side(value.left)
if not (t or r or b or l) then
return nil
end
return { top = t, right = r, bottom = b, left = l }
end
return value
end
--- Replicate Element.new's cornerRadius-shape normalization (pure).
--- * number: 0 -> nil, else the number.
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
--- * nil -> nil.
local function normalizeCornerRadius(value)
if value == nil then
return nil
end
if type(value) == "number" then
if value == 0 then
return nil
end
return value
end
if type(value) == "table" then
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
-- nil/false table collapses to nil; any present side — including 0 — yields
-- the 4-side table with zero-filled absent sides.
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
if not hasAny then
return nil
end
return {
topLeft = value.topLeft or 0,
topRight = value.topRight or 0,
bottomLeft = value.bottomLeft or 0,
bottomRight = value.bottomRight or 0,
}
end
return value
end
-- ---------------------------------------------------------------------------
-- Pure validators (dependency-free; return boolean)
-- ---------------------------------------------------------------------------
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
--- (absence handling is the default mechanism's job).
local function rangeValidator(min, max)
return function(v)
if v == nil then
return true
end
return type(v) == "number" and v >= min and v <= max
end
end
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
--- be an array or a map of value->truthy).
local function enumValidator(set)
local lookup = {}
if type(set) == "table" then
for k, v in pairs(set) do
if type(k) == "number" then
lookup[v] = true
else
lookup[k] = true
end
end
end
return function(v)
if v == nil then
return true
end
return lookup[v] == true
end
end
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
local function booleanValidator(v)
return v == nil or type(v) == "boolean"
end
-- ---------------------------------------------------------------------------
-- Registry construction
-- ---------------------------------------------------------------------------
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
local function entry(spec)
return {
type = spec.type or "any",
default = spec.default,
normalizer = spec.normalizer,
validator = spec.validator,
isDimension = spec.isDimension == true,
affectsLayout = spec.affectsLayout == true,
syncsTheme = spec.syncsTheme == true,
hasDeferred = spec.hasDeferred == true,
storageKey = spec.storageKey,
}
end
--- Merge prop specs into the registry (additive; later entries override earlier).
---@param specs table<string, table> map of prop-name -> spec
---@return table registry the live registry table (for chaining/inspection)
function PropertySchema.define(specs)
for name, spec in pairs(specs) do
registry[name] = entry(spec)
end
return registry
end
--- O(1) metadata lookup.
---@param name string prop name
---@return table|nil metadata nil for unknown props (no error)
function PropertySchema.get(name)
return registry[name]
end
--- Return the live registry (for inspection / coverage assertions only — not for
--- per-call construction).
---@return table
function PropertySchema.all()
return registry
end
--- True if a prop is registered.
---@param name string
---@return boolean
function PropertySchema.has(name)
return registry[name] ~= nil
end
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.affectsLayout(name)
local meta = registry[name]
return meta ~= nil and meta.affectsLayout == true
end
--- True for dimension props (width/height) that `setProperty` routes through
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.isDimension(name)
local meta = registry[name]
return meta ~= nil and meta.isDimension == true
end
--- True for props whose setProperty path must reach ThemeManager/Renderer
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
---@param name string prop name
---@return boolean
function PropertySchema.syncsTheme(name)
local meta = registry[name]
return meta ~= nil and meta.syncsTheme == true
end
-- ---------------------------------------------------------------------------
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
-- ---------------------------------------------------------------------------
local function defineDefaults()
PropertySchema.define({
-- ------------------------------------------------------------------ identity
id = { type = "string" },
userdata = { type = "any" },
parent = { type = "table", affectsLayout = true },
children = { type = "table" },
-- ------------------------------------------------------------------ callbacks
onEvent = { type = "function", hasDeferred = true },
onFocus = { type = "function", hasDeferred = true },
onBlur = { type = "function", hasDeferred = true },
onTextInput = { type = "function", hasDeferred = true },
onTextChange = { type = "function", hasDeferred = true },
onEnter = { type = "function", hasDeferred = true },
onCreate = { type = "function", hasDeferred = true },
onTouchEvent = { type = "function", hasDeferred = true },
onGesture = { type = "function", hasDeferred = true },
onImageLoad = { type = "function", hasDeferred = true },
onImageError = { type = "function", hasDeferred = true },
-- Deferred companion flags (stored directly; no further Deferred companion)
onEventDeferred = { type = "boolean", default = false },
onFocusDeferred = { type = "boolean", default = false },
onBlurDeferred = { type = "boolean", default = false },
onTextInputDeferred = { type = "boolean", default = false },
onTextChangeDeferred = { type = "boolean", default = false },
onEnterDeferred = { type = "boolean", default = false },
onCreateDeferred = { type = "boolean", default = false },
onTouchEventDeferred = { type = "boolean", default = false },
onGestureDeferred = { type = "boolean", default = false },
onImageLoadDeferred = { type = "boolean", default = false },
onImageErrorDeferred = { type = "boolean", default = false },
-- focus / touch behavior
dropFocusOnSelection = { type = "boolean" },
customDraw = { type = "function" },
touchEnabled = { type = "boolean", default = true },
multiTouchEnabled = { type = "boolean", default = false },
-- ------------------------------------------------------------------ theme
theme = { type = "table" },
themeComponent = { type = "string", syncsTheme = true },
disabled = { type = "boolean", default = false, syncsTheme = true },
isDisabled = {
type = "boolean",
default = false,
syncsTheme = true,
storageKey = "disabled",
},
active = { type = "boolean", default = false, syncsTheme = true },
disableHighlight = { type = "boolean" },
themeStateLock = { type = "boolean" },
themeComponentDisabledStates = { type = "table" },
scaleCorners = { type = "boolean" },
scalingAlgorithm = { type = "string" },
contentAutoSizingMultiplier = { type = "table" },
contentBlur = { type = "table" },
backdropBlur = { type = "table" },
-- ------------------------------------------------------------------ text editing
editable = { type = "boolean", default = false },
multiline = { type = "boolean", default = false },
passwordMode = { type = "boolean", default = false },
textWrap = { type = "string" }, -- default computed from multiline
maxLines = { type = "number" },
maxLength = { type = "number" },
placeholder = { type = "string" },
inputType = { type = "string", default = "text" },
textOverflow = { type = "string", default = "clip" },
scrollable = { type = "boolean" }, -- default = multiline
autoGrow = { type = "boolean" }, -- default = multiline
selectOnFocus = { type = "boolean", default = false },
cursorColor = { type = "color" },
selectionColor = { type = "color" },
cursorBlinkRate = { type = "number", default = 0.5 },
text = { type = "string" },
textAlign = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end", "justify" }),
},
-- textAlignVertical is a derived storage field split out from textAlign
-- (bindVisualState resolves table/compound-string input into H + V). Its
-- validator is exposed for bindVisualState to validate the V component; the
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
textAlignVertical = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end" }),
},
textColor = { type = "color" },
fontFamily = { type = "string" },
textSize = { type = "any" }, -- number | preset string; resolved by special handler
minTextSize = { type = "number" },
maxTextSize = { type = "number" },
autoScaleText = { type = "boolean", default = true },
-- ------------------------------------------------------------------ dimensions / box model
width = { type = "any", isDimension = true, affectsLayout = true },
height = { type = "any", isDimension = true, affectsLayout = true },
x = { type = "any", affectsLayout = false },
y = { type = "any", affectsLayout = false },
minWidth = { type = "any" },
maxWidth = { type = "any" },
minHeight = { type = "any" },
maxHeight = { type = "any" },
gap = { type = "any", affectsLayout = true },
padding = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
margin = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
flexDirection = {
type = "string",
default = "horizontal",
affectsLayout = true,
normalizer = normalizeFlexDirection,
},
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
alignItems = { type = "string", default = "stretch", affectsLayout = true },
alignContent = { type = "string", default = "stretch", affectsLayout = true },
positioning = { type = "string", default = "relative", affectsLayout = true },
gridRows = { type = "number", affectsLayout = true },
gridColumns = { type = "number", affectsLayout = true },
top = { type = "any", affectsLayout = true },
right = { type = "any", affectsLayout = true },
bottom = { type = "any", affectsLayout = true },
left = { type = "any", affectsLayout = true },
columnGap = { type = "any" },
rowGap = { type = "any" },
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
flexBasis = { type = "any", default = "auto" },
alignSelf = { type = "string", default = "auto" },
justifySelf = { type = "string" },
z = { type = "number", default = 0 },
tabIndex = { type = "number" },
-- ------------------------------------------------------------------ border / background / visual
border = { type = "any", normalizer = normalizeBorder },
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
backgroundColor = { type = "color" }, -- default transparent via special handler
opacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
visibility = { type = "string", default = "visible" },
display = {
type = "boolean",
default = true,
validator = booleanValidator,
},
transform = { type = "table" },
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
-- ------------------------------------------------------------------ image
imagePath = { type = "string" },
image = { type = "table" },
objectFit = {
type = "string",
default = "fill",
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
},
objectPosition = { type = "string", default = "center center" },
imageOpacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
imageRepeat = {
type = "string",
default = "no-repeat",
validator = enumValidator({
"no-repeat",
"repeat",
"repeat-x",
"repeat-y",
"space",
"round",
}),
},
imageTint = { type = "color" },
-- ------------------------------------------------------------------ scroll / scrollbar
overflow = { type = "string" },
overflowX = { type = "string" },
overflowY = { type = "string" },
scrollbarWidth = { type = "number" },
scrollbarColor = { type = "color" },
scrollbarTrackColor = { type = "color" },
scrollbarRadius = { type = "number" },
scrollbarPadding = { type = "number" },
scrollSpeed = { type = "number" },
invertScroll = { type = "boolean" },
smoothScrollEnabled = { type = "boolean" },
scrollBarStyle = { type = "string" },
scrollbarKnobOffset = { type = "number" },
hideScrollbars = { type = "boolean" },
scrollbarPlacement = { type = "string" },
scrollbarBalance = { type = "number" },
_scrollX = { type = "number", storageKey = "_scrollX" },
_scrollY = { type = "number", storageKey = "_scrollY" },
-- ------------------------------------------------------------------ select
selectParent = { type = "table" },
selectOption = { type = "table" },
-- ------------------------------------------------------------------ transition
transition = { type = "table", default = {} },
})
end
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
--- for build profiles that re-require the module. Returns the live registry.
---@return table registry
function PropertySchema.populate()
defineDefaults()
return registry
end
-- Auto-populate on require so the registry is ready without an explicit init call
-- (pure module, no external deps — safe at load time).
PropertySchema.populate()
return PropertySchema
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
local RoundedRect = {}
--- Generate points for a rounded rectangle
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
---@param segments number? -- Number of segments per corner arc (default: 10)
---@return table -- Array of vertices for love.graphics.polygon
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
segments = segments or 10
local points = {}
-- Helper to add arc points
local function addArc(cx, cy, radius, startAngle, endAngle)
if radius <= 0 then
table.insert(points, cx)
table.insert(points, cy)
return
end
for i = 0, segments do
local angle = startAngle + (endAngle - startAngle) * (i / segments)
table.insert(points, cx + math.cos(angle) * radius)
table.insert(points, cy + math.sin(angle) * radius)
end
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius,
}
end
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2)
-- Top-right corner
addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0)
-- Bottom-right corner
addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2)
-- Bottom-left corner
addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi)
-- Top-left corner
addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5)
return points
end
--- Draw a filled rounded rectangle
---@param mode string -- "fill" or "line"
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
if not cornerRadius then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
if cornerRadius <= 0 then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Convert to table format for processing
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius,
}
end
-- Check if any corners are rounded
local hasRoundedCorners = cornerRadius.topLeft > 0
or cornerRadius.topRight > 0
or cornerRadius.bottomLeft > 0
or cornerRadius.bottomRight > 0
if not hasRoundedCorners then
-- No rounded corners, use regular rectangle
love.graphics.rectangle(mode, x, y, width, height)
return
end
local points = RoundedRect.getPoints(x, y, width, height, cornerRadius)
if mode == "fill" then
love.graphics.polygon("fill", points)
else
-- For line mode, draw the outline
love.graphics.polygon("line", points)
end
end
--- Create a stencil function for rounded rectangle clipping
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
---@return function
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
return function()
RoundedRect.draw("fill", x, y, width, height, cornerRadius)
end
end
return RoundedRect
File diff suppressed because it is too large Load Diff
+719
View File
@@ -0,0 +1,719 @@
---@class Select
local Select = {}
---Initialize Select module with required dependencies
---@param deps table
function Select.init(deps)
Select._ErrorHandler = deps.ErrorHandler
Select._Context = deps.Context
Select._StateManager = deps.StateManager
Select._utils = deps.utils
Select._Element = deps.Element
end
---Initialize selectParent state on an element
---@param element Element
---@param selectParentConfig table
function Select.initSelectParent(element, selectParentConfig)
element._selectState = {
value = selectParentConfig.value,
open = selectParentConfig.open or false,
placeholder = selectParentConfig.placeholder,
selectFrame = nil,
selectAnchor = nil,
onChange = selectParentConfig.onChange,
options = {},
optionLookup = {},
expectedFrameParent = nil,
frameAdopted = false,
}
-- Restore select state from StateManager. Mode-aware via
-- Context.isImmediateMode (behavior-mode-unification task 11).
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Select._StateManager.getState(element._stateId)
if state and state._selectOpen ~= nil then
element._selectState.open = state._selectOpen
end
if state and state._selectValue ~= nil then
element._selectState.value = state._selectValue
if element.selectParent then
element.selectParent.value = state._selectValue
end
end
if state and state._selectSelectedLabel ~= nil then
element._selectState.selectedLabel = state._selectSelectedLabel
end
end
end
---Initialize selectOption on an element
---@param element Element
---@param selectOptionConfig table
function Select.initSelectOption(element, selectOptionConfig)
element.selectOption = {
value = selectOptionConfig.value,
label = selectOptionConfig.label or element.text,
disabled = selectOptionConfig.disabled or false,
}
end
---@param selectParent Element
function Select.rebuildOptionLookup(selectParent)
if not selectParent or not selectParent._selectState then
return
end
selectParent._selectState.optionLookup = {}
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement and optionElement.selectOption then
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
end
end
end
---@param selectParent Element
function Select.syncOptionStates(selectParent)
if not selectParent or not selectParent._selectState then
return
end
local selectedOption = nil
local selectedLabel = selectParent._selectState.selectedLabel
for _, optionElement in ipairs(selectParent._selectState.options) do
local isSelected = optionElement.selectOption
and optionElement.selectOption.value == selectParent._selectState.value
optionElement._selectSelected = isSelected
optionElement.ariaChecked = isSelected
if isSelected then
selectedOption = optionElement
selectedLabel = optionElement.selectOption.label or optionElement.text
end
end
selectParent._selectState.selectedOption = selectedOption
selectParent._selectState.selectedLabel = selectedLabel
end
---@param element Element
function Select.resetOptions(element)
if not element._selectState then
return
end
element._selectState.options = {}
element._selectState.optionLookup = {}
element._selectState.selectedOption = nil
end
---@param frame any
---@return boolean
function Select.isValidSelectFrame(frame)
local Element = Select._Element
return type(frame) == "table" and getmetatable(frame) == Element
end
---@param element Element
---@param code string
---@param details table?
function Select.warnSelectFrame(element, code, details)
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
end
---@param element Element
---@param frame Element
function Select.trackManagedFrame(element, frame)
element._selectState.selectFrame = frame
local expectedParent = element._selectState.selectAnchor or element
element._selectState.expectedFrameParent = expectedParent
element._selectState.frameAdopted = frame.parent == expectedParent
if frame._managedSelectBaseOpacity == nil then
frame._managedSelectBaseOpacity = frame.opacity
end
if frame._managedSelectBaseVisibility == nil then
frame._managedSelectBaseVisibility = frame.visibility or "visible"
end
if frame._managedSelectBaseDisabled == nil then
frame._managedSelectBaseDisabled = frame.disabled or false
end
frame._managedSelectOwner = element
frame._managedSelectFrame = true
end
---@param element Element
---@return Element
function Select.getOrCreateManagedAnchor(element)
if element._selectState.selectAnchor then
return element._selectState.selectAnchor
end
local Element = Select._Element
local anchor = Element.new({
id = string.format("%s__select_anchor", element.id or "select"),
parent = element,
positioning = Select._utils.enums.Positioning.ABSOLUTE,
left = 0,
top = element:getBorderBoxHeight(),
width = element:getBorderBoxWidth(),
opacity = 1,
visibility = "hidden",
disabled = true,
})
anchor._managedSelectAnchor = true
anchor._managedSelectOwner = element
element._selectState.selectAnchor = anchor
return anchor
end
---@param element Element
---@param frame Element
function Select.applyManagedFrameLayout(element, frame)
local anchor = Select.getOrCreateManagedAnchor(element)
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
frame._explicitlyAbsolute = false
frame.left = nil
frame.top = nil
frame.right = nil
frame.bottom = nil
if frame.parent ~= anchor then
frame:setParent(anchor)
end
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
element._selectState.expectedFrameParent = anchor
element._selectState.frameAdopted = frame.parent == anchor
end
---@param element Element
---@param frame Element
function Select.adoptSelectFrame(element, frame)
if not element._selectState then
return
end
if not Select.isValidSelectFrame(frame) then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
got = type(frame),
})
return
end
if frame == element then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
reason = "select cannot use itself as its managed frame",
})
return
end
local anchor = Select.getOrCreateManagedAnchor(element)
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
Select.warnSelectFrame(element, "ELEM_008", {
element = element.id,
frame = frame.id,
parent = frame.parent.id,
})
end
Select.trackManagedFrame(element, frame)
Select.applyManagedFrameLayout(element, frame)
Select.syncManagedFrameVisibility(element)
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
-- encapsulates the mode check (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
anchor:layoutChildren()
element:layoutChildren()
end
local pendingOptions = {}
for _, child in ipairs(element.children) do
if child ~= frame and child.selectOption then
table.insert(pendingOptions, child)
end
end
for _, option in ipairs(pendingOptions) do
Select.attachOptionToManagedFrame(option)
end
end
---@param element Element
function Select.ensureFrameState(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if anchor then
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
if frame.parent == anchor then
anchor:layoutChildren()
end
elseif frame.parent == element then
Select.applyManagedFrameLayout(element, frame)
end
local expectedParent = anchor or element._selectState.expectedFrameParent
if frame.parent ~= expectedParent then
Select.warnSelectFrame(element, "ELEM_009", {
element = element.id,
frame = frame.id,
expectedParent = expectedParent and expectedParent.id or nil,
actualParent = frame.parent and frame.parent.id or nil,
})
element._selectState.expectedFrameParent = frame.parent
element._selectState.frameAdopted = frame.parent == expectedParent
end
end
---@param element Element
function Select.syncManagedFrameVisibility(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
local isOpen = element._selectState.open == true
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
frame.opacity = frame._managedSelectBaseOpacity or 1
if isOpen then
frame.disabled = frame._managedSelectBaseDisabled == true
else
frame.disabled = true
end
if anchor then
anchor.visibility = isOpen and "visible" or "hidden"
anchor.opacity = 1
anchor.disabled = not isOpen
end
end
---@param element Element
---@return Element?
function Select.findOwningSelectParent(element)
if element._selectParentHint and element._selectParentHint._selectState then
return element._selectParentHint
end
local current = element.parent
while current do
if current._selectState then
return current
end
current = current.parent
end
return nil
end
---@param element Element
function Select.registerWithSelectParent(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent then
return
end
element._selectParentElement = selectParent
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
return
end
end
table.insert(selectParent._selectState.options, element)
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
end
---@param element Element
function Select.attachOptionToManagedFrame(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
return
end
local selectFrame = selectParent._selectState.selectFrame
if element.parent ~= selectFrame then
element._selectParentHint = selectParent
if
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
and element._managedSelectOptionUsesFrameLayout == nil
then
element._managedSelectOptionUsesFrameLayout = true
element.positioning = Select._utils.enums.Positioning.RELATIVE
element._originalPositioning = nil
element._explicitlyAbsolute = false
element.left = nil
element.top = nil
element.right = nil
element.bottom = nil
end
element:setParent(selectFrame)
-- Ensure frame geometry eagerly only in retained mode; deferred to the
-- per-frame update in immediate mode (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
Select.ensureFrameState(selectParent)
end
end
end
---@param element Element
function Select.unregisterFromSelectParent(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
element._selectParentElement = nil
return
end
local selectParent = element._selectParentElement
for index, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
table.remove(selectParent._selectState.options, index)
break
end
end
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
element._selectParentElement = nil
end
---@param element Element
function Select.saveStateToStateManager(element)
if not element._selectState then
return
end
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
Select._StateManager.updateState(element._stateId, {
_selectOpen = element._selectState.open,
_selectValue = element._selectState.value,
_selectSelectedLabel = element._selectState.selectedLabel,
})
end
end
---@param element Element
function Select.openSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = true
element.ariaExpanded = true
if element.selectParent then
element.selectParent.open = true
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.closeSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = false
element.ariaExpanded = false
if element.selectParent then
element.selectParent.open = false
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.toggleSelect(element)
if not element._selectState then
return
end
if element.disabled then
return
end
if element._selectState.open then
Select.closeSelect(element)
else
Select.openSelect(element)
end
if element.onEvent then
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
end
end
---@param element Element
---@return boolean
function Select.isSelectOpen(element)
return element._selectState ~= nil and element._selectState.open == true
end
---@param element Element
---@return any
function Select.getSelectValue(element)
if not element._selectState then
return nil
end
return element._selectState.value
end
---@param element Element
---@return string?
function Select.getSelectLabel(element)
if not element._selectState then
return nil
end
local selectedOption = element._selectState.selectedOption
or element._selectState.optionLookup[element._selectState.value]
if selectedOption and selectedOption.selectOption then
return selectedOption.selectOption.label or selectedOption.text
end
return element._selectState.selectedLabel or element._selectState.placeholder
end
---@param element Element
---@return boolean
function Select.isSelectedOption(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
return false
end
return element._selectParentElement._selectState.value == element.selectOption.value
end
---@param element Element
---@param value any
---@param optionElement Element?
function Select.setSelectValue(element, value, optionElement)
if not element._selectState then
return
end
if element.disabled then
return
end
local didChange = element._selectState.value ~= value
element._selectState.value = value
if element.selectParent then
element.selectParent.value = value
end
if optionElement and optionElement.selectOption then
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
end
Select.syncOptionStates(element)
Select.closeSelect(element)
Select.saveStateToStateManager(element)
if element.onEvent then
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
end
if didChange and element._selectState.onChange then
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
end
end
---@param element Element
function Select.handleRelease(element)
if element.disabled then
return
end
if element.selectOption then
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
if not selectParent then
return
end
if element.selectOption.disabled then
Select.closeSelect(selectParent)
return
end
Select.setSelectValue(selectParent, element.selectOption.value, element)
return
end
if element._selectState then
Select.toggleSelect(element)
end
end
---Save select state for state persistence (called from Element:saveState)
---@param element Element
---@return table?
function Select.saveState(element)
if not element._selectState then
return nil
end
return {
value = element._selectState.value,
open = element._selectState.open,
selectedLabel = element._selectState.selectedLabel,
}
end
---Restore select state (called from Element:restoreState)
---@param element Element
---@param state table
function Select.restoreState(element, state)
if not element._selectState or not state then
return
end
element._selectState.value = state.value
element._selectState.open = state.open or false
element._selectState.selectedLabel = state.selectedLabel
if element.selectParent then
element.selectParent.value = state.value
element.selectParent.open = state.open or false
end
element.ariaExpanded = element._selectState.open
Select.syncOptionStates(element)
end
---Clean up select-related resources (called from Element:destroy)
---@param element Element
function Select.cleanupDestroy(element)
if element._selectState then
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if frame then
frame._managedSelectOwner = nil
frame._managedSelectFrame = nil
frame._managedSelectBaseOpacity = nil
frame._managedSelectBaseVisibility = nil
frame._managedSelectBaseDisabled = nil
end
if anchor then
anchor._managedSelectOwner = nil
anchor._managedSelectAnchor = nil
end
element._selectState = nil
end
if element._managedSelectFrame and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectFrame = nil
element._managedSelectOwner._selectState.expectedFrameParent = nil
element._managedSelectOwner._selectState.frameAdopted = false
end
element._managedSelectOwner = nil
element._managedSelectFrame = nil
element._managedSelectBaseOpacity = nil
element._managedSelectBaseVisibility = nil
element._managedSelectBaseDisabled = nil
end
if element._managedSelectAnchor and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectAnchor = nil
end
element._managedSelectOwner = nil
element._managedSelectAnchor = nil
end
if element.selectParent then
element.selectParent.onChange = nil
end
end
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
---@param element Element The select parent whose child was removed.
---@param child Element The removed child.
function Select.handleChildRemoved(element, child)
if not element._selectState then
return
end
if element._selectState.selectFrame == child then
element._selectState.selectFrame = nil
element._selectState.expectedFrameParent = nil
element._selectState.frameAdopted = false
end
if element._selectState.selectAnchor == child then
element._selectState.selectAnchor = nil
end
end
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
---@param element Element The managed-select frame (the dropdown container).
---@param child Element The flex child being measured.
---@param childBorderBoxWidth number Current computed border-box width of `child`.
---@return number Possibly-adjusted border-box width.
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
if
element._managedSelectFrame
and element.autosizing
and element.autosizing.width
and child.units
and child.units.width
and child.units.width.unit == "%"
then
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
end
return childBorderBoxWidth
end
return Select
+790
View File
@@ -0,0 +1,790 @@
---@class StateManager
local StateManager = {}
-- ErrorHandler will be injected via init
local ErrorHandler
-- State storage: ID -> state table
local stateStore = {}
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
local stateMetadata = {}
-- Frame counter
local frameNumber = 0
-- Counter to track multiple elements created at the same source location (e.g., in loops)
local callSiteCounters = {}
-- Stateful element mapping: stateId -> element instance
-- Used in retained mode for cache-through: StateManager resolves id -> element -> field
local statefulElements = {}
-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame
local dirtyState = {}
-- Immediate mode flag
local _immediateMode = false
-- Configuration
local config = {
stateRetentionFrames = 2, -- Keep unused state for 2 frames
maxStateEntries = 1000, -- Maximum state entries before forced GC
}
-- Default state values (sparse storage - don't store these)
local stateDefaults = {
-- Interaction states
hover = false,
pressed = false,
focused = false,
disabled = false,
active = false,
-- Scrollbar states
scrollbarHoveredVertical = false,
scrollbarHoveredHorizontal = false,
scrollbarDragging = false,
hoveredScrollbar = nil,
scrollbarDragOffset = 0,
dragStartMouseX = 0,
dragStartMouseY = 0,
dragStartScrollX = 0,
dragStartScrollY = 0,
-- Scroll position
scrollX = 0,
scrollY = 0,
_scrollX = 0,
_scrollY = 0,
-- Click tracking
_clickCount = 0,
_lastClickTime = nil,
_lastClickButton = nil,
-- Internal states
_hovered = nil,
_focused = nil,
_cursorPosition = nil,
_selectionStart = nil,
_selectionEnd = nil,
_textBuffer = "",
_cursorBlinkTimer = 0,
_cursorVisible = true,
_cursorBlinkPaused = false,
_cursorBlinkPauseTimer = 0,
}
--- Check if a value equals the default for a key
---@param key string State key
---@param value any Value to check
---@return boolean isDefault True if value equals default
local function isDefaultValue(key, value)
local defaultVal = stateDefaults[key]
-- If no default defined, check for common defaults
if defaultVal == nil then
-- Empty tables are default
if type(value) == "table" and next(value) == nil then
return true
end
-- nil values are default
if value == nil then
return true
end
-- Otherwise, not a default value
return false
end
-- Compare values
if type(value) == "table" then
-- Empty tables are considered default
if next(value) == nil then
return true
end
-- For other tables, compare contents (shallow)
if type(defaultVal) ~= "table" then
return false
end
for k, v in pairs(value) do
if defaultVal[k] ~= v then
return false
end
end
return true
else
return value == defaultVal
end
end
-- ====================
-- ID Generation
-- ====================
--- Generate a hash from a table of properties
---@param props table
---@param visited table|nil Tracking table to prevent circular references
---@param depth number|nil Current recursion depth
---@return string
local function hashProps(props, visited, depth)
if not props then
return ""
end
-- Initialize visited table on first call
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent deep nesting issues
if depth > 3 then
return "[deep]"
end
-- Check if we've already visited this table (circular reference)
if visited[props] then
return "[circular]"
end
-- Mark this table as visited
visited[props] = true
local parts = {}
local keys = {}
-- Properties to skip (they cause issues or aren't relevant for ID generation)
local skipKeys = {
onEvent = true,
parent = true,
children = true,
onFocus = true,
onBlur = true,
onTextInput = true,
onTextChange = true,
onEnter = true,
userdata = true,
-- Dynamic input/state properties that should not affect ID stability
text = true, -- Text content changes as user types
placeholder = true, -- Placeholder text is presentational
editable = true, -- Editable state can be toggled dynamically
selectOnFocus = true, -- Input behavior flag
autoGrow = true, -- Auto-grow behavior flag
passwordMode = true, -- Password mode can be toggled
}
-- Collect and sort keys for consistent ordering
for k in pairs(props) do
if not skipKeys[k] then
table.insert(keys, k)
end
end
table.sort(keys)
-- Build hash string from sorted key-value pairs
for _, k in ipairs(keys) do
local v = props[k]
local vtype = type(v)
if vtype == "string" or vtype == "number" or vtype == "boolean" then
table.insert(parts, k .. "=" .. tostring(v))
elseif vtype == "table" then
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
end
end
return table.concat(parts, ";")
end
--- Generate a unique ID from call site and properties
---@param props table|nil Optional properties to include in ID generation
---@param parent table|nil Optional parent element for tree-based ID generation
---@return string
function StateManager.generateID(props, parent)
-- Get call stack information
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
if not info then
-- Fallback to random ID if debug info unavailable
return "auto_" .. tostring(math.random(1000000, 9999999))
end
local source = info.source or "unknown"
local line = info.currentline or 0
-- Create base location key from source file and line number
local filename = source:match("([^/\\]+)$") or source -- Get filename
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
local locationKey = filename .. "_L" .. line
-- If we have a parent, use tree-based ID generation for stability
if parent and parent.id and parent.id ~= "" then
-- For child elements, use call-site (file + line) like top-level elements
-- This ensures the same call site always generates the same ID, even when
-- retained children persist in parent.children array
local baseID = parent.id .. "_" .. locationKey
-- Count how many children have been created at THIS call site
local callSiteKey = parent.id .. "_" .. locationKey
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
local instanceNum = callSiteCounters[callSiteKey]
if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum
end
-- Add property hash if provided (for additional differentiation)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- No parent (top-level element): use call-site counter approach
-- Track how many elements have been created at this location
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
local instanceNum = callSiteCounters[locationKey]
local baseID = locationKey
-- Add instance number if multiple elements created at same location (e.g., in loops)
if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum
end
-- Add property hash if provided (for additional differentiation)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- ====================
-- State Management
-- ====================
--- Initialize StateManager with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
function StateManager.init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
end
end
--- Get state for an element ID, creating if it doesn't exist
---@param id string Element ID
---@param defaultState table|nil Default state if creating new
---@return table state State table for the element
function StateManager.getState(id, defaultState)
if not id then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id",
value = "nil",
})
end
-- Create state if it doesn't exist
if not stateStore[id] then
-- Start with empty state (sparse storage)
stateStore[id] = defaultState or {}
-- Create metadata
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 0,
}
else
-- Update metadata
local meta = stateMetadata[id]
meta.lastFrame = frameNumber
meta.accessCount = meta.accessCount + 1
end
return stateStore[id]
end
--- Set state for an element ID (replaces entire state)
---@param id string Element ID
---@param state table State to store
function StateManager.setState(id, state)
if not id then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id",
value = "nil",
})
end
-- Create sparse state (remove default values)
local sparseState = {}
for key, value in pairs(state) do
if not isDefaultValue(key, value) then
sparseState[key] = value
end
end
stateStore[id] = sparseState
-- Update or create metadata
if not stateMetadata[id] then
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 1,
}
else
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state for an element ID (merges with existing state)
---@param id string Element ID
---@param newState table New state values to merge
function StateManager.updateState(id, newState)
local state = StateManager.getState(id)
-- Merge new state into existing state (with diffing optimization)
local changed = false
for key, value in pairs(newState) do
if state[key] ~= value then
state[key] = value
changed = true
end
end
-- Only update metadata if something actually changed
if changed then
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state only if values have changed (optimized for immediate mode)
---@param id string Element ID
---@param newState table New state values to merge
---@return boolean changed True if any values changed
function StateManager.updateStateIfChanged(id, newState)
local state = StateManager.getState(id)
local changed = false
for key, value in pairs(newState) do
-- Skip if value hasn't changed (optimization)
if state[key] ~= value then
state[key] = value
changed = true
end
end
if changed then
stateMetadata[id].lastFrame = frameNumber
end
return changed
end
--- Clear state for a specific element ID
---@param id string Element ID
function StateManager.clearState(id)
stateStore[id] = nil
stateMetadata[id] = nil
end
--- Mark state as used this frame (updates last accessed frame)
---@param id string Element ID
function StateManager.markStateUsed(id)
if stateMetadata[id] then
stateMetadata[id].lastFrame = frameNumber
end
end
-- ====================
-- Frame Management
-- ====================
--- Increment frame counter (called at frame start)
function StateManager.incrementFrame()
frameNumber = frameNumber + 1
-- Reset call site counters for new frame
callSiteCounters = {}
end
--- Get current frame number
---@return number
function StateManager.getFrameNumber()
return frameNumber
end
-- ====================
-- Granular State Access (Unified API for both modes)
-- ====================
--- Get a single state value by key for a given element ID.
--- Works identically in both modes — the caller does not need to know the mode.
---
--- Immediate mode: reads from persistent state store.
--- Retained mode: resolves through registered element field (cache-through).
---
---@param id string Element state ID
---@param key string State key
---@return any value The stored value, or nil if not found
function StateManager.getStateValue(id, key)
if not id or not key then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id and key",
value = "missing",
})
end
-- Update metadata for access tracking
if stateMetadata[id] then
stateMetadata[id].lastFrame = frameNumber
stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1
end
if _immediateMode then
-- Immediate mode: read from persistent state store
local state = stateStore[id]
if state then
return state[key]
end
return nil
else
-- Retained mode: resolve through element field
local element = statefulElements[id]
if element then
return element[key]
end
return nil
end
end
--- Set a single state value by key for a given element ID.
--- Works identically in both modes — the caller does not need to know the mode.
---
--- Immediate mode: marks dirty for flushFrame() persistence.
--- Retained mode: writes directly to element field (cache-through).
---
---@param id string Element state ID
---@param key string State key
---@param value any Value to store
function StateManager.setStateValue(id, key, value)
if not id or not key then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id and key",
value = "missing",
})
end
-- Update metadata
if not stateMetadata[id] then
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 1,
}
else
stateMetadata[id].lastFrame = frameNumber
end
if _immediateMode then
-- Immediate mode: mark dirty for flushFrame persistence
local state = StateManager.getState(id)
state[key] = value
dirtyState[id] = dirtyState[id] or {}
dirtyState[id][key] = true
else
-- Retained mode: write directly to element field
local element = statefulElements[id]
if element then
element[key] = value
end
end
end
-- ====================
-- Stateful Element Registration (Retained Mode Cache-Through)
-- ====================
--- Register an element instance for retained-mode cache-through.
--- After registration, getStateValue/setStateValue will resolve through the element's fields.
---
--- Called by Element in _construct phase.
---
---@param id string State ID (typically element.id)
---@param element table Element instance to link
function StateManager.registerStateful(id, element)
if not id or not element then
return
end
statefulElements[id] = element
end
--- Unregister an element instance.
--- After unregistration, retained-mode access will fall back to nil.
---
--- Called by Element in _cleanup phase.
---
---@param id string State ID to unregister
function StateManager.unregisterStateful(id)
if id then
statefulElements[id] = nil
end
end
-- ====================
-- Frame Flush (Immediate Mode Dirty State Persistence)
-- ====================
--- Flush dirty state to persistent store at end of frame.
--- Called automatically at frame end in immediate mode.
--- Behaviors call setStateValue during update without knowing the mode.
---
--- In retained mode, this is a no-op (state is written directly to elements).
function StateManager.flushFrame()
if not _immediateMode then
return
end
-- All dirty writes were already applied to stateStore during setStateValue
-- This method exists for future extensions (e.g., batching, analytics)
-- Reset dirty tracking for next frame
dirtyState = {}
end
-- ====================
-- Mode Configuration
-- ====================
--- Configure immediate mode state.
--- Called by Context when immediate mode is enabled/disabled.
---
---@param enabled boolean Whether immediate mode is active
function StateManager.setImmediateMode(enabled)
_immediateMode = enabled
end
--- Check if immediate mode is active.
---@return boolean
function StateManager.isImmediateMode()
return _immediateMode
end
--- Whether at-construction layout / eager initialization should run now.
--- Returns true in retained mode (layout eagerly), false in immediate mode
--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all
--- elements for the frame have been created). This replaces the scattered
--- `if not _immediateMode then layoutChildren()` mode checks with a single
--- mode-aware query (behavior-mode-unification task 11).
---@return boolean
function StateManager.shouldLayout()
return not _immediateMode
end
-- ====================
-- Cleanup & Maintenance
-- ====================
--- Clean up stale states (not accessed recently)
---@return number count Number of states cleaned up
function StateManager.cleanup()
local cleanedCount = 0
local retentionFrames = config.stateRetentionFrames
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = frameNumber - meta.lastFrame
if framesSinceAccess > retentionFrames then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
-- Clean up empty states (sparse storage optimization)
for id, state in pairs(stateStore) do
if next(state) == nil then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount
end
--- Force cleanup if state count exceeds maximum
---@return number count Number of states cleaned up
function StateManager.forceCleanupIfNeeded()
local stateCount = StateManager.getStateCount()
if stateCount > config.maxStateEntries then
-- Clean up states not accessed in last 10 frames (aggressive)
local cleanedCount = 0
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = frameNumber - meta.lastFrame
if framesSinceAccess > 10 then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount
end
return 0
end
--- Get total number of stored states
---@return number
function StateManager.getStateCount()
local count = 0
for _ in pairs(stateStore) do
count = count + 1
end
return count
end
--- Clear all states
function StateManager.clearAllStates()
stateStore = {}
stateMetadata = {}
end
--- Configure state management
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
function StateManager.configure(newConfig)
if newConfig.stateRetentionFrames then
config.stateRetentionFrames = newConfig.stateRetentionFrames
end
if newConfig.maxStateEntries then
config.maxStateEntries = newConfig.maxStateEntries
end
end
--- Get state statistics for debugging
---@return table stats State usage statistics
function StateManager.getStats()
local stateCount = StateManager.getStateCount()
local oldest = nil
local newest = nil
for _, meta in pairs(stateMetadata) do
if not oldest or meta.createdFrame < oldest then
oldest = meta.createdFrame
end
if not newest or meta.createdFrame > newest then
newest = meta.createdFrame
end
end
-- Count callSiteCounters
local callSiteCount = 0
for _ in pairs(callSiteCounters) do
callSiteCount = callSiteCount + 1
end
-- Warn if callSiteCounters is unexpectedly large
if callSiteCount > 1000 then
if ErrorHandler then
ErrorHandler.warn("StateManager", "STATE_001", {
count = callSiteCount,
expected = "near 0",
frameNumber = frameNumber,
})
end
end
return {
stateCount = stateCount,
frameNumber = frameNumber,
oldestState = oldest,
newestState = newest,
callSiteCounterCount = callSiteCount,
}
end
--- Get internal state (for debugging/profiling only)
---@return table internal {stateStore, stateMetadata, callSiteCounters}
function StateManager._getInternalState()
return {
stateStore = stateStore,
stateMetadata = stateMetadata,
callSiteCounters = callSiteCounters,
}
end
--- Reset the entire state system (for testing)
function StateManager.reset()
stateStore = {}
stateMetadata = {}
frameNumber = 0
callSiteCounters = {}
statefulElements = {}
dirtyState = {}
_immediateMode = false
end
-- ====================
-- Convenience Functions (for backward compatibility)
-- ====================
--- Check if an element is currently hovered
---@param id string Element ID
---@return boolean
function StateManager.isHovered(id)
local state = StateManager.getState(id)
return state.hover or false
end
--- Check if an element is currently pressed
---@param id string Element ID
---@return boolean
function StateManager.isPressed(id)
local state = StateManager.getState(id)
return state.pressed or false
end
--- Check if an element is currently focused
---@param id string Element ID
---@return boolean
function StateManager.isFocused(id)
local state = StateManager.getState(id)
return state.focused or false
end
--- Check if an element is disabled
---@param id string Element ID
---@return boolean
function StateManager.isDisabled(id)
local state = StateManager.getState(id)
return state.disabled or false
end
--- Check if an element is active (e.g., input focused)
---@param id string Element ID
---@return boolean
function StateManager.isActive(id)
local state = StateManager.getState(id)
return state.active or false
end
return StateManager

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