mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 17:00:58 +02:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fea0b05a4 | |||
| c3136bf8f7 | |||
| 517170f1c8 | |||
| e5bebea437 | |||
| 3320eacc26 | |||
| b40ff167e7 | |||
| f4497b4dbb | |||
| b9684ef162 | |||
| f394f56395 | |||
| 6731937841 | |||
| b2819d04e0 | |||
| 52fac8ced6 | |||
| 1176a269e5 | |||
| 96af652d3b | |||
| 29a2b9a123 | |||
| 238af263d6 | |||
| dbd8abe14c | |||
| 04490c9b9a | |||
| 49d094b14d | |||
| cd7985e4ac | |||
| 61edf3470f | |||
| cd02cd6f33 | |||
| 127e3da909 | |||
| 8fa702e715 | |||
| 3aaaf9936e | |||
| af33c6e810 | |||
| e8eccfd4df | |||
| 241c3345bd | |||
| 20e0692486 | |||
| 0136429d3e | |||
| 01aab1d763 | |||
| 3d2d53362d | |||
| 03838ee1d4 | |||
| 41f02ecfbc | |||
| cef286b170 | |||
| ab94b96a79 | |||
| cc43bd77d2 | |||
| d4dc72d0f4 | |||
| 6d841526b1 | |||
| 8e48bb4e32 | |||
| a0279f5be2 | |||
| 2c8c800584 | |||
| ccb5358aad | |||
| 4db97164bb | |||
| 882763cfe1 | |||
| 798f3c25c0 | |||
| d21a0edffc | |||
| 02024fef58 | |||
| a4c51eccea | |||
| a9767e5df1 | |||
| e59175fc89 | |||
| 59725c0ead | |||
| 4e20f4585f | |||
| 1c37867e52 | |||
| 67491e2dac | |||
| 2d85c9595d | |||
| b8138ef850 | |||
| e3fc8380cf | |||
| 9f54734f62 | |||
| eec396e388 | |||
| 1ef0d8c2c0 | |||
| 0f24105686 | |||
| d35b9e5c3e | |||
| ee5c168983 |
@@ -1,8 +1,9 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster
|
||||
# handheld ports on the self-hosted Mac runner, and publishes them as a
|
||||
# GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
@@ -355,6 +356,20 @@ jobs:
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Linux ARM SBC PortMaster port
|
||||
env:
|
||||
# The release workflow must package the commit being released. The
|
||||
# script defaults to the latest published release for standalone
|
||||
# builds, while this explicit local override keeps CI source-aligned.
|
||||
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
|
||||
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
|
||||
# keeps its own cache because the two scripts use different staging
|
||||
# layouts and runtime package paths.
|
||||
./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -449,6 +464,11 @@ jobs:
|
||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
|
||||
# Linux ARM SBC PortMaster handheld port.
|
||||
sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||
[ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; }
|
||||
cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
@@ -564,6 +584,7 @@ jobs:
|
||||
"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}-sbc-portmaster.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
)
|
||||
|
||||
@@ -68,3 +68,10 @@ mobile/ios/bundle_id.local
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
# Native TLS dialer build output (dotnet publish)
|
||||
/native/tls_dial/bin/
|
||||
/native/tls_dial/obj/
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
# KANTO-CONTINUE: executing the never-run half of Gold (sections 19-32)
|
||||
|
||||
Deferred from the 2026-08-09 fix campaign by request. This doc is self-contained: the
|
||||
audit documents it was distilled from are being retired, so everything a future session
|
||||
needs is here.
|
||||
|
||||
## What this is
|
||||
|
||||
The Johto main line (walkthrough sections 00-18) is proven: the route bot beats Champion
|
||||
Lance with every row passing. Kanto (sections 19-32) is extracted and wired but has
|
||||
NEVER been executed by any bot row, driver, or human. Every entry below reads clean in
|
||||
static review (cache + code + cart source were cross-checked adversarially); the risk is
|
||||
purely runtime. The job: make each beat actually run, fix what breaks, and leave
|
||||
executable evidence (bot rows or assertion drivers) behind.
|
||||
|
||||
## State after the 2026-08-09 campaign (what changed under you)
|
||||
|
||||
- ~80 defects fixed across the port; 91/91 gen2 suites, engine 128/128, T3 all green.
|
||||
- The post-game handoff WORKS now: post-E4 credits end at the title screen, and
|
||||
CONTINUE consumes the post-game spawn (HallOfFame.consumePostGameSpawn): after Lance
|
||||
you continue in NEW_BARK_TOWN, after Red at SILVER_CAVE_OUTSIDE (23,20). Section 19
|
||||
is reachable by normal play for the first time.
|
||||
- The Snorlax wake chain is fully functional (engine-flag bridge unlocks the Pokegear
|
||||
and EXPN card; the tuned radio song persists after closing the gear; SnorlaxAwake
|
||||
fires; proven live by tests/drivers/gold_radio_persist.lua).
|
||||
- Landed systems Kanto beats depend on: phone random/outgoing calls, field item use
|
||||
(Escape Rope/Dig via save.backupWarp), Repel, swarms, Rock Smash encounters,
|
||||
waterfall current tiles, roamer scatter on CONTINUE, trap volatiles + FORCESHINY
|
||||
battle types (beast catching in section 32 is now mechanically possible), badge
|
||||
boosts, held items, specialty balls (Lure Ball needs battleType 'fish', already
|
||||
stamped by updateFishing).
|
||||
- NPC HOUR WINDOWS are now enforced (they were not when the Johto route was authored).
|
||||
Any Kanto row that talks to a time-gated NPC must pin the clock (POKEPORT_GOLD_HOUR /
|
||||
POKEPORT_GOLD_DAY) or handle absence. Only 11 hour-gated objects exist on the whole
|
||||
cart: CELADON_GAME_CORNER 6/7, GOLDENROD_GAME_CORNER 4/5, MOUNT_MOON_GIFT_SHOP 1-4,
|
||||
and the three Moms in PLAYERS_HOUSE_1F; Celadon and Mt Moon are the Kanto exposures.
|
||||
- The game clock now anchors on every new game (cart InitClock semantics), so time of
|
||||
day no longer follows the host wall clock in driver runs; POKEPORT_GOLD_HOUR pins
|
||||
the anchored base too. Runs are reproducible across day/night; a pre-existing flake
|
||||
class (night runs catching HOOTHOOT where day runs catch PIDGEY, shifting grind
|
||||
levels) died with it.
|
||||
- Battles hit harder both ways since the cart MIN_DAMAGE floor landed; grind budgets
|
||||
tuned for the old numbers may need a nudge.
|
||||
|
||||
## Known bot weaknesses to expect (verified pre-existing, not campaign regressions)
|
||||
|
||||
- Pathfinding pocket: travel into ILEX_FOREST / SLOWPOKE_WELL bounces (probe
|
||||
CHERRYGROVE_CITY>ILEX_FOREST reports no route), and route row 16.54 gives up the
|
||||
walk to VICTORY_ROAD_GATE with the identical TELEPORT signature in every logged run.
|
||||
Kanto routing should waypoint doors explicitly per the ops recipes above.
|
||||
- Optional buy rows 04.5d and 18.g4/18.g5 fail whenever the wallet is empty at that
|
||||
point in the run; they are budgeting gaps in the route, not engine bugs.
|
||||
- Grind row 08.g (Route 41 water) has almost no margin; with the clock now anchored
|
||||
its inputs are stable, but a low-level POLIWAG catch can still stretch it.
|
||||
|
||||
## How to run things
|
||||
|
||||
Identity/cache: gold-dev (~/Library/Application Support/LOVE/gold-dev/gold), rebuilt
|
||||
2026-08-09 with swarm/rock/roam tables. Checkpoints gold-ckpt-NN.lua exist per section;
|
||||
gold-ckpt-18-pristine.lua is the post-E4 seed. ROM: ../decprep/Pokemon - Gold
|
||||
Version.gbc. Cart source: ../pokegold (cite it in any hand-ported code).
|
||||
|
||||
Headless love needs a pty on macOS; wrap every run:
|
||||
perl -e 'alarm 900; exec @ARGV' python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
|
||||
Full bot run: POKEPORT_IDENTITY=<id> POKEPORT_GAME=gold POKEPORT_SPEED=200 \
|
||||
POKEPORT_GOLD_CKPT=1 POKEPORT_GOLD_STALL=20000 POKEPORT_GOLD_LOG=/tmp/b.log \
|
||||
POKEPORT_DRIVER=tests/drivers/gold_bot.lua love .
|
||||
Resume a section: POKEPORT_GOLD_RESUME=NN (same identity, prior CKPT=1 run).
|
||||
Travel probe: POKEPORT_GOLD_PROBE="MAP_A>MAP_B" with tests/drivers/gold_travel_probe.lua.
|
||||
Map graph: luajit tools/goldwalk/mapgraph.lua path|map|reach|audit; regenerate
|
||||
tests/drivers/gold/map_regions.lua with `mapgraph.lua graph >` after ANY extractor map
|
||||
change, then re-check route region indices.
|
||||
Route validation (before any run): luajit tests/gold_route_validate_test.lua and
|
||||
luajit tests/gold_flag_names_test.lua.
|
||||
Suites: GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" luajit
|
||||
tests/gen2_<name>_test.lua; tiers run_engine/run_modkit/run_tests.
|
||||
Reimport after extractor/manifest changes: python3 tools/make_gold_manifest.py, then
|
||||
POKEPORT_IMPORT_TRACE=1 POKEPORT_IDENTITY=gold-dev POKEPORT_IMPORT_ROM="../decprep/\
|
||||
Pokemon - Gold Version.gbc" POKEPORT_IMPORT_ONLY=1 POKEPORT_FORCE_IMPORT=1 \
|
||||
POKEPORT_GAME=gold love .
|
||||
|
||||
## Recommended shape of the work
|
||||
|
||||
1. Extend tests/drivers/gold/route.lua with sections 19-32 (the row format, ops and
|
||||
evidence rules are documented in the file head and bot.lua). Seed runs from
|
||||
gold-ckpt-18-pristine plus the now-working post-credits CONTINUE. `expect` flags are
|
||||
the only oracles; never make a row load-bearing on a soft-failing map.
|
||||
2. Highest-risk multi-map scripted chains first: S.S. Aqua voyage, Machine Part chain,
|
||||
PASS/Magnet Train chain. Each deserves an assertion driver even if bot rows also
|
||||
cover it.
|
||||
3. Time-gated beats (S.S. Aqua sailing days, Indigo rival Mon/Wed, Dragon's Den
|
||||
Tue/Thu, Clefairy Monday night, TM03 night gate) need POKEPORT_GOLD_DAY/HOUR pins.
|
||||
4. Johto stragglers are folded in below (lighthouse descent, TM08 flake, and the
|
||||
optional gift beats); they ride the same route-extension pass.
|
||||
5. Section 32 (beasts) last: it needs long roaming play; trap volatiles and FORCESHINY
|
||||
landed this campaign, so Mean Look + Heavy/Fast Ball plans are now viable.
|
||||
|
||||
## Also deferred (deliberate stubs, do not re-flag)
|
||||
|
||||
Time Capsule trading, Trainer House CAL2/Mystery Gift visitor, Silver-version import,
|
||||
Cable Club link rooms, and Game Boy Printer output are deliberate product-level stubs,
|
||||
not campaign work.
|
||||
|
||||
## The beats, in walkthrough order
|
||||
|
||||
### Runtime coverage ends at the Hall of Fame: no bot row, driver, or human has run any Kanto beat (sections 19-32) or the section 14 Tin Tower/Whirl Islands content
|
||||
|
||||
walkthrough section(s) 19,20,21,22,23,24,27,28,29,30,31,14 | missing
|
||||
|
||||
One root cause: tests/drivers/gold/route.lua (1264 lines) ends at row 18.20 (settle HALL_OF_FAME, expect EVENT_BEAT_ELITE_FOUR); the campaign covers asm-walk sections 00-18 only and none of the 24/36 gold_* drivers reaches any Kanto map, so sections 19-32 have zero execution proof. Extend route.lua into sections 19-32 and/or add targeted drivers. Per section pair: (s19/20) next rows are the Elm ticket (needs the post-credits spawn fix or a fly detour from Indigo), the ship crossing as far as the docking events, the Surge fight, and a Saffron Gym pad-chain assertion. (s21/22) All 24 route/gym trainers resolve in cache with correct parties: Route 9 six (Edna, Sid, Dean, Sidney, Tim, Heidi); Route 25 seven plus Kevin, whose Nugget-then-battle script 50:488d is hand-shaped (a full bag aborts before the battle, cart behavior); Cerulean's three swimmers; Route 10 South's Jim and Robert (Robert's Quagsire holds a BERRY via TRAINERTYPE_ITEM); Route 8's five; Celadon Gym's five including the twins' shared beat flag. Sight-line engagement is Trainers.sees (proven by the Johto bot); prize money base x level x 4 is Prize.lua; Erika's class item extracts as HYPER_POTION (the walkthrough's 'three Full Restores' is a FAQ error, not a port gap); Gold-only wilds (Mankey line Route 9, Growlithe Routes 7/8) come from the ROM version branch; Route 8's PRZCureBerry fruit tree is a fruittree op (Vm.lua:1218) into Apricorns.lua daily reset. One Kanto smoke-run converts the whole block. (s23/24) Nothing touches Routes 11-19, Fuchsia City/gym, Vermilion Snorlax, Diglett's Cave, Route 2, Pewter; the report's three blocker findings (START menu row, Pokegear card flags, radio persistence) are exactly the UI-plumbing breaks data audits miss: a gold_snorlax_wake.lua that grants the gear and EXPN card the way the game does (setflag 4, setflag 0, setflag 3 through the VM, not by poking save fields), tunes the radio, closes the gear, and talks to the Snorlax would currently fail at three separate steps; gold_cycling_road.lua and gold_fuchsia_gym.lua are the other two cheap proofs (gold_menu_shots.lua:72-73 only ever hand-seeded save fields). (s27/28) Drive first: (1) Cinnabar Blue talk -> disappear -> clearevent 1910 -> Viridian Gym populated -> Blue beaten -> ENGINE_EARTHBADGE set -> VAR_BADGES answers 16 (a break anywhere makes the 16th badge unobtainable and the Mt. Silver gate unpassable); cache chain exists: CINNABAR_ISLAND obj 1 SPRITE_BLUE at (9,6) scriptKey 4e:4985 eventFlag 1909; VIRIDIAN_GYM objs 1-2 gated on 1910; (2) the ROUTE_20 MAPCALLBACK_NEWMAP 4e:4cfa setevent 215 / ROUTE_19 MAPCALLBACK_TILES 4e:4f09 changeblock unseal pair (regression walls off Route 19). Driver: surf Pallet -> Route 21 -> Cinnabar -> talk to Blue -> Route 20 -> beat Blaine -> fly to Viridian -> beat Blue. (s29/30) No rows for Kanto OAKS_LAB, ROUTE_22, ROUTE_28, SILVER_CAVE_OUTSIDE, or the Union Cave B2F Lapras revisit (route.lua's only Union Cave rows are 03.29/04.g); risk concentrates on composites never run together: the 16-badge Oak visit with ProfOaksPCBoot text chain, the double Cut approach to the Route 28 west strip, surf-to-talk on the drifting SWIM_WANDER Lapras (Lake of Rage Gyarados proves the shape via gold_lake_probe.lua), and the daily-flag consume-and-respawn cycle across a save reload; a targeted gold_lapras_probe driver (clockDay=5, walk in, assert object present, catch, walk out, re-enter, assert gone) plus route extension converts inspected to proven. (s31) All six Silver Cave maps decode (warps, item balls flags 1689-1693, hidden items, SILVER_CAVE_OUTSIDE flypoint callback, PALETTE_DARK on Room 1, grass/water tables including Room 2 water); Flash lifts PALETTE_DARK via Palettes.isDarkness + World flashUsed; Surf/Waterfall live in FieldMoves with the badge table; gen2 ledges landed via Permissions.ledgeFacings / World:tryLedgeJump (the asm-walk's 'ledge hop missing' row is stale, GOLD-NEXT-RUN records the fix); the flypoint is FieldMoves.lua:386 flag 75; but no driver has entered Silver Cave, used Waterfall on Room 2's two bands, hopped Room 1's Escape Rope ledge, or talked to Red (closest: gold_halloffame_shots, gold_hm07_probe which proved Waterfall only at Tohjo Falls). (s14) The bot covers sections 10,11,12,13,16,17,18 and never section 14 (Ho-Oh is optional); untested: the 4F-9F ladder/hop mazes (depend on the recently added ledge-hop and one-way-wall support), the Ecruteak entrance sage scene retarget from Morty's setmapscene, the 1F sage unmasking off EVENT_TEAM_ROCKET_DISBANDED (set on the Gold arm of the Radio Tower boss script), the roof save/reset loop, and the entire Gold-side Whirl Islands dive; needs one probe driver carrying a RAINBOW_WING save up the tower and one diving to the Lugia chamber.
|
||||
|
||||
*How to prove it:* Extend tests/drivers/gold/route.lua past row 18.20 into sections 19-32, and/or write the named targeted drivers: gold_snorlax_wake.lua, gold_cycling_road.lua, gold_fuchsia_gym.lua, a gold_lapras_probe (clockDay=5 present/absent cycle), a Cinnabar-to-Viridian 16th-badge driver, a Route 20/19 unseal driver, a Silver Cave/Red driver, a Tin Tower RAINBOW_WING probe, and a Whirl Islands Lugia dive probe.
|
||||
|
||||
*Notes:* Merged from the parent heading plus six 'Also filed as' sub-findings (all one cause). Doc's own ratings: parent missing/minor (s19,20, already in ledger); s21,22 unverified/minor; s23,24 unverified/minor; s27,28 missing/major; s29,30 unverified/minor; s31 unverified/minor; s14 unverified/minor. The adversarial re-check ran on the s27/28 sub-claim, tried to refute and could not; its one correction: tests/drivers/gold/flag_names.lua and map_regions.lua DO name the Kanto maps (map_regions even carries CINNABAR/SEAFOAM/VIRIDIAN region graphs, so bot travel there is data-ready) but they are lookup tables, not executed coverage. 'Implemented statically, executed by nobody' matches the project's Roamers/Events:restore failure mode. GOLD-WALK-HANDOFF sections 1 and 4 already document the Johto-only scope.
|
||||
|
||||
|
||||
### Olivine Port pier and Fast Ship boarding gate: the only road into Kanto, fully extracted, never executed
|
||||
|
||||
walkthrough section(s) 19 | unverified
|
||||
|
||||
Coord event at (7,15) (cache 5b:407d) does the full cart sequence: temporary-event bails, first-time bypass on event 48, readvar VAR_WEEKDAY branch, yesorno, checkitem S_S_TICKET (item 68), setevent 1, 7-step applymovement, sjump into gangway script 5b:401e which plays SFX, disappears/appears the sailor, runs special 46 = FadeOutToWhite (specialOrder[46+1], implemented Specials.lua:998), clears the eight eastbound EVENT_BEAT_* rematch flags on repeat trips, sets FAST_SHIP_1F scene 1, and warps to (25,1). Port sprite toggles for the post-Hall-of-Fame sailor (events 1847/1848) are armed by the HOF script. All data extracted, every op has a proven Johto call site; matched op-for-op against pokegold maps/OlivinePort.asm. Needs one live run or driver.
|
||||
|
||||
*How to prove it:* A driver or bot section that reaches OLIVINE_PORT post-HOF with the S_S_TICKET, boards, and asserts the warp to FAST_SHIP_1F (25,1) with scene 1 set. No gold_* driver currently reaches OLIVINE_PORT post-HOF.
|
||||
|
||||
*Notes:* Doc rating: unverified/blocker, new. If this chain fails, the walkthrough stops here (Kanto unreachable).
|
||||
|
||||
|
||||
### S.S. Aqua maiden voyage: trainer gauntlet, lazy sailor, granddaughter, Metal Coat, docking; extracted end to end, never run
|
||||
|
||||
walkthrough section(s) 19 | unverified
|
||||
|
||||
Verified in cache, never executed: FastShip1F enter scene 5b:48a8 sdefer 5b:48ad (SFX_BOAT, earthquake 30, blackoutmod to cabin map, clearevent 49, scene 2 on first trip); grandpa bump coord events at (24,6)/(25,6); maiden-trip trainer objects behind flag 1849 with parties matching data/trainers/parties.asm exactly (NOLAND Sandslash31/Golem33, LYLE Koffing28/Flareon31/Koffing28, COLIN Delibird32 holding BERRY, MEG&PEG, FRITZ Mr.Mime/Magmar/Machoke, JEFF 2x Raticate32, DEBRA Seaking33, STANLY Machop31/Machoke33/Psyduck26, all in trainers.lua); B1F blocking-sailor coord scripts 5b:5e0c/5b:5e21 plus shared talk script 5b:5e37 clearing event 1837 to spawn the lazy sailor; FastShipLazySailorScript 5b:4d68 (playmusic, loadtrainer SAILOR 9, reloadmap, special 27 = HealParty, setmapscene B1F NOOP, readvar VAR_FACING walk-out); bed script 5b:521b (HealParty, fades, ReloadSpritesNoPalettes, RestartMapMusic, dock check on events 49/50/48 so sleeping cannot dock the maiden voyage); granddaughter payoff 5b:55c6 (fade, disappear/moveobject/appear teleport, showemote, sjump 5b:5633 = Metal Coat verbosegiveitem plus EVENT_FAST_SHIP_HAS_ARRIVED/FOUND_GIRL); door guard 5b:48d2 with arrived arm 5b:48ec (scall facing arm, setmapscene VERMILION_PORT scene 1, warp to VERMILION_PORT 7,17). Specials 27/46/47/49/50/60 resolve to HealParty/FadeOutToWhite/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/RestartMapMusic, all implemented. Nothing in the chain touches a stubbed special, callasm, or VAR. Matched against pokegold maps/FastShip*.asm.
|
||||
|
||||
*How to prove it:* A bot section or driver for the full crossing (board, fight or dodge the gauntlet, sleep check, granddaughter Metal Coat, dock at VERMILION_PORT 7,17).
|
||||
|
||||
*Notes:* Doc rating: unverified/blocker, new. If any link breaks, arrival in Kanto is impossible.
|
||||
|
||||
|
||||
### Machine Part quest chain (Power Plant manager, Cerulean Gym grunt scene, Route 24 grunt, part return, TM07) never executed
|
||||
|
||||
walkthrough section(s) 21,22 | unverified
|
||||
|
||||
The spine of sections 21-22: manager talk sets EVENT_MET_MANAGER_AT_POWER_PLANT 202, clears EVENT_CERULEAN_GYM_ROCKET 1901 and EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM 251, arms CERULEAN_GYM scene 1 and the POWER_PLANT guard phone-call scene; grunt runs out of the gym (sets 203, clears 1900/1902, arms ROUTE_25 scene 1); beat the Route 24 grunt (GRUNTM 31, L30 Golbat); return the part for TM07 Zap Cannon plus EVENT_RESTORED_POWER_TO_KANTO 205 and the Saffron station population flip (clears 1906). Statically verified: cache holds full bytecode for PowerPlantManager 54:4dbd with both branches (54:4deb takeitem MACHINE_PART / 54:4e04 TM give guarded by event 223), gym scene script 54:4332 sdefer 54:4336 with all 34 rows, Route 24 grunt script 50:4407; initial_events.lua seeds flags 251/1900/1901/1902/1903/1906/1907 at new game exactly as InitializeEventsScript does (pokegold engine/events/std_scripts.asm:546-550); World.lua:607 applies the seed while EVENT_INITIALIZED_EVENTS is clear. Engine deps all have call sites: World:trySceneScript (World.lua:5369, called at :6893), setmapscene/setscene (Vm.lua:274-284), specials RestartMapMusic/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/FadeOutMusic (Specials.lua:999-1072). Missing only run-time proof: route bot covers sections 00-18, no Kanto driver exists.
|
||||
|
||||
*How to prove it:* Route extension or a driver walking manager -> gym grunt scene -> Route 24 grunt battle -> part return, asserting events 202/203/205 set and 1900/1901/1902/251/1906 cleared and TM07 in bag.
|
||||
|
||||
*Notes:* Doc rating: unverified/blocker, new.
|
||||
|
||||
|
||||
### Route 25 Misty date scene, Cerulean gym population, and Misty fight never executed (and Misty gives no TM, contrary to walkthrough)
|
||||
|
||||
walkthrough section(s) 21 | unverified
|
||||
|
||||
The date cutscene at Route 25 cells (42,6)/(42,7) is what clears EVENT_TRAINERS_IN_CERULEAN_GYM 1903 and populates the gym with Misty, three swimmers, and the guide; without it Misty never appears and the Cascade Badge is unreachable. Cache has both coord events with sceneId=1 (scripts 18305/18369) and full scripts 50:4781/50:47c1 (34-row cutscene: heart emote, boyfriend flees, Misty approach movement, clearevent 1903, setscene 0, special 60=RestartMapMusic), armed by gym grunt script 54:4336 row 27 (setmapscene group=7 map=16 scene=1). Engine deps: World:tryCoordScript (World.lua:5006) filters on the map scene id; mapScenes persist on the save (GOLD-INDEX tier note on World:loadPlayerData). CeruleanGymMistyScript 54:438a is in the cache; MISTY trainer 1 party (L42 Golduck, L42 Quagsire, L44 Lapras, L47 Starmie with full movesets) extracts correctly; baseMoney 25 so the 4700G payout follows from Prize.reward.
|
||||
|
||||
*How to prove it:* Driver: arm the scene via the gym grunt, trigger the date coord event at (42,6)/(42,7), assert 1903 cleared and the gym populated, then fight Misty and assert the Cascade Badge and 4700G.
|
||||
|
||||
*Notes:* Doc rating: unverified/blocker, new. Correction embedded in the doc: Misty gives NO TM on the Gold cart, only the badge plus retroactive swimmer beat flags; the walkthrough and the lead's 'TM from Misty' are wrong about the cart, nothing is missing in the port there.
|
||||
|
||||
|
||||
### PASS quest: Copycat lost item and Fan Club doll chain fully extracted, never run
|
||||
|
||||
walkthrough section(s) 22 | unverified
|
||||
|
||||
Copycat (COPYCATS_HOUSE_2F object 1, variable sprite slot 251) talk after the machine part return sets EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM 206; Fan Club Clefairy guy (POKEMON_FAN_CLUB object 3, script 59:437b) then gives LOST_ITEM 130 and disappears the doll (flag 1908, correctly NOT in the initial seed; doc evidence line also cites POKEMON_FAN_CLUB object 5 flag 1908); returning it (Copycat branch 61:528b: takeitem 130, setevent 208, clearevent 1907 to re-show her shelf doll, then PASS 134 via 61:529d) yields the PASS. All bytecode in cache (61:5235/61:5270/61:528b); the mimicry gag's variablesprite slot 11 plus special 93 resolves to LoadUsedSpritesGFX (specialOrder[94], Specials.lua:1033); the Copycat's variable sprite slot is seeded through World.initialSprites/findInitialSprites (World.lua:1530, the fix GOLD-INDEX credits for the Copycat existing at all). Flag 1907 is in the initial seed so the shelf doll is hidden until the return; 1906 is seeded so the Saffron station stays empty until power is restored.
|
||||
|
||||
*How to prove it:* Driver: after machine-part return, talk to Copycat (assert 206), get LOST_ITEM at the Fan Club (assert doll flag 1908 set), return it (assert 208 set, 1907 cleared, PASS 134 in bag).
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's 'Rail Pass then Magnet Train Pass' is one item (PASS) on the cart, not a port gap. Merge-with hint: the s20 entry 'Copycat lost doll quest chain toward the rail PASS' (copycat-doll-pass-s20) describes the same quest from the section-20 audit with slightly different event ids cited (210/201 gate checks there vs 206/208 here).
|
||||
|
||||
|
||||
### Magnet Train ride wired end to end; arrival behavior needs a human eye, never ridden since
|
||||
|
||||
walkthrough section(s) 22 | unverified
|
||||
|
||||
SaffronMagnetTrainStationOfficerScript (cache 61:4bc2/61:4bd0, rows 11-14) checks EVENT_RESTORED_POWER_TO_KANTO 205 then checkitem PASS 134, does setval 1, special id=35 which resolves to MagnetTrain (specialOrder[36]), then warpcheck and newloadmap 249 (MAPSETUP_TRAIN). All implemented: H.MagnetTrain (Specials.lua:2119) reads scriptVar for direction and blocks on World:magnetTrain (World.lua:2393) which pushes Gen2MagnetTrainRide; Vm handles warpcheck (:1135) and newloadmap (:1177); MAPSETUP_TRAIN defined at World.lua:175. Gating events (205 set by the Power Plant manager, PASS from the Copycat) are this section's other findings.
|
||||
|
||||
*How to prove it:* WHATS-NEXT item 8: a human rides both ways and presses a direction on arrival; the conversation must start on the first step because south is the only legal step out of the doorway cell. Nothing has run this since.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, already named in the ledger.
|
||||
|
||||
|
||||
### Silver Wing in Gold: Pewter City gramps script extracted, but Kanto (and the Whirl Islands dive) never run
|
||||
|
||||
walkthrough section(s) 14 | unverified
|
||||
|
||||
For a Gold cart, Lugia needs the SILVER_WING carried in the bag (WhirlIslandLugiaChamberLugiaCallback does checkevent EVENT_FOUGHT_LUGIA then checkitem SILVER_WING), and in Gold the wing comes from the Pewter City gramps (pokegold maps/PewterCity.asm PewterCityGrampsScript: checkver, Silver arm branches away, Gold falls through to verbosegiveitem SILVER_WING + setevent). Verified in cache: PEWTER_CITY object 3 (SPRITE_GRAMPS at 29,17) scriptKey 4d:583e = faceplayer / opentext / checkver / iftrue 4d:585c / checkevent 121 / verbosegiveitem item 71 / setevent 121 / closetext / end, all generic opcodes. The Lugia chamber map, its SPRITE_LUGIA object (scriptKey 47:41a0, eventFlag 1853), and its MAPCALLBACK_OBJECTS callback (47:418c) are in the cache using only implemented machinery. Route bot has no section 14/25 rows (grep 'id = "1[0-9]' shows 10,11,12,13,16,17,18). One mechanical gap on the dive path: the waterfall forced-down item, reported separately in the doc.
|
||||
|
||||
*How to prove it:* Driver or bot rows: reach Pewter in Gold, talk to the gramps (assert SILVER_WING item 71 given, event 121 set), then the full Whirl Islands dive to the Lugia chamber.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new. The waterfall forced-down item on the dive path is a separate finding elsewhere in the doc, not part of this entry.
|
||||
|
||||
|
||||
### Vermilion Port arrival bookkeeping, Vermilion flypoint, and Kanto music never executed
|
||||
|
||||
walkthrough section(s) 19 | unverified
|
||||
|
||||
Arrival scene (VERMILION_PORT scene 1, sdefer 5b:450e) extracted intact: one step up, appear the gangway sailor, setscene 0, end-of-voyage flags (1841/1840 hide grandpa and granddaughter for good, 1849 retires the maiden-trip trainers, clear 1843 to reveal the Olivine passage Pokefan, set 48 EVENT_FAST_SHIP_FIRST_TIME), and blackoutmod VERMILION_CITY. ENGINE_FLYPOINT_VERMILION comes from MAPCALLBACK_NEWMAP on both VERMILION_PORT and VERMILION_CITY (setflag 57); the port's fly table carries the matching row (FieldMoves.lua:392) and World:runMapCallback has a live call site (World.lua:5659 plus the MAPCALLBACK_OBJECTS call in setMap). Fly deliberately stays region-locked exactly like the cart (FieldMoves.flyPoints, FieldMoves.lua:442: Kanto page only after SPAWN_INDIGO, no cross-region fly, verified against pokegold engine/pokegear/pokegear.asm .KantoFlyMap). Kanto music extracted: audio.lua songs has 92 entries, musicOrder includes Music_VermilionCity and Music_ViridianCity (musicOrder[63]=Music_VermilionCity), mapSongs carries all 362 map rows.
|
||||
|
||||
*How to prove it:* Driver: dock at Vermilion, assert flags 1841/1840/1849/48 set and 1843 cleared, flypoint flag 57 set on map entry, and Vermilion/Viridian city music playing.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new.
|
||||
|
||||
|
||||
### Pokemon Fan Club chairman Rare Candy give never run
|
||||
|
||||
walkthrough section(s) 19 | unverified
|
||||
|
||||
Chairman script 59:4340: checkevent 212 (already heard), checkevent 211 (bag-full retry), yesorno where answering No gives nothing (cart behavior), two promptbutton speech pages, verbosegiveitem item 32 = RARE_CANDY, setevent 212. The cart quirk that nothing ever sets the bag-full retry event 211 is faithfully carried in the extracted bytecode. Same map also holds the Clefairy doll object (flag 1908) and Clefairy Guy script 59:437b that the later Copycat quest reads. yesorno, verbosegiveitem, and multi-page writetext all have proven Johto call sites. Matched against pokegold maps/PokemonFanClub.asm; RARE_CANDY present in items.lua.
|
||||
|
||||
*How to prove it:* Covered by any Kanto smoke-run that talks to the chairman and asserts RARE_CANDY given and event 212 set.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Lt. Surge gym: trainers, Thunder Badge, AI item use fully extracted, never fought
|
||||
|
||||
walkthrough section(s) 19 | unverified
|
||||
|
||||
Surge script 59:4bfc: checkflag 36 re-fight guard, loadtrainer class 19 member 1, winlosstext, reloadmapafterbattle (whose loss-abort semantics were fixed during the Johto campaign), retroactive setevent of the three gym trainers, setflag 36 = ENGINE_THUNDERBADGE which routes to save.player.kantoBadges.THUNDER. Parties in trainers.lua match data/trainers/parties.asm exactly: Surge is RAICHU44/ELECTRODE40/MAGNETON40/ELECTRODE40/ELECTABUZZ46 with full TRAINERTYPE_MOVES movesets; Vincent, Horton, Gregory all match (GUITARIST 2/JUGGLER 3/GENTLEMAN 3). Trainer AI item use implemented: Ai.ITEM_ORDER/Ai.chooseItem (Ai.lua:1368-1400) dispatched from Battle.lua:1790 (AI_SwitchOrTryItem) off the extracted class attributes. The gym has no puzzle in Gen 2, matching the walkthrough's power-outage line.
|
||||
|
||||
*How to prove it:* Driver or route rows: fight the gym trainers and Surge, assert ENGINE_THUNDERBADGE (flag 36) set into save.player.kantoBadges.THUNDER and the AI HYPER_POTION use path exercised.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's claim that Surge holds a Full Restore is a FAQ error; pokegold data/trainers/attributes.asm line 114 (block 113-117) gives the Lt Surge class HYPER_POTION and the cache carries exactly that (LT_SURGE items=HYPER_POTION, baseMoney 25).
|
||||
|
||||
|
||||
### Route 6 Underground Path blocker and Saffron gate guard extracted, never walked
|
||||
|
||||
walkthrough section(s) 20 | unverified
|
||||
|
||||
The walkthrough's 'large man who blocks the entire entrance' is the ROUTE6_POKEFAN_M object at (17,4) whose event flag EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH hides him only when SET (set by the Power Plant script in a later section); the port's object-visibility rule 'flag set hides object' is implemented and documented at Events.lua:1-2, and his script is a bare jumptextfaceplayer about the Power Plant. The Route 6 Saffron gate guard at (0,4) stands off the walking lane and is dialogue only (branches on EVENT_RETURNED_MACHINE_PART). ROUTE_6 grass and water encounter tables (Abra/Magnemite slots) are present in encounters.lua. Nothing gated on engine work; it has just never been walked.
|
||||
|
||||
*How to prove it:* Covered by any Kanto walk through Route 6 before and after the Power Plant script sets the blocker flag.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Mr. Psychic's TM29 Psychic give extracted verbatim, Kanto walk to the door unproven
|
||||
|
||||
walkthrough section(s) 20 | unverified
|
||||
|
||||
Cache 61:4b15: checkevent 227, writetext, promptbutton, verbosegiveitem item 221 = TM_PSYCHIC_M, iffalse full-bag fall-through WITHOUT setting the flag (cart behavior, re-talk after making room), setevent 227. TM_PSYCHIC_M is in items.lua with pocket TM_HM and price 2000; the four-pocket bag and TM teaching were exercised in the Johto run (TM08 ROCK SMASH teach passed on merit per GOLD-WALK-HANDOFF). Matched against pokegold maps/MrPsychicsHouse.asm.
|
||||
|
||||
*How to prove it:* Any Kanto run that walks to the house, talks, and asserts TM29 given and event 227 set (plus the full-bag retry arm).
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Silph Co. 1F Up-Grade give extracted, never run
|
||||
|
||||
walkthrough section(s) 20 | unverified
|
||||
|
||||
Cache 61:4f81: same shape as Mr. Psychic, verbosegiveitem item 172 = UP_GRADE guarded by event 222 with the full-bag fall-through. The officer at (13,1) has event flag -1 so he never moves, and SILPH_CO_1F has only the two street-door warps: there is no upstairs in Gen 2, so the 'man blocking the stairway' never opens, matching the cart (verified against pokegold maps/SilphCo1F.asm). Generic mechanisms only. UP_GRADE present in items.lua.
|
||||
|
||||
*How to prove it:* Any Kanto run that enters Silph Co. 1F and asserts UP_GRADE given behind event 222.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Saffron Gym teleporter maze (30 same-map warp pads) never exercised; same-map warp chains untested anywhere
|
||||
|
||||
walkthrough section(s) 20 | unverified
|
||||
|
||||
The gym's whole puzzle is 30 warp pads (cache SAFFRON_GYM warps 3-32) that all target SAFFRON_GYM itself; the only route to Sabrina is entrance pad (11,15) -> ... -> (1,5) -> (11,9). The engine mechanism exists and reads correct: pads are COLL_WARP_PANEL 0x7c, an immediate warp (Permissions.lua:311); World:takeWarp (World.lua:6022-6040) handles a destination equal to the current map through setMap; warpCooldown keyed on the landing cell (World.lua:6135 set, 6217-6222, 6240 suppression check) stops the landing pad from re-firing until the player steps off, exactly what a pad-to-pad maze needs. But the asm-walk's own port-coverage table flags that no driver has ever exercised a same-map warp chain, and no Kanto run exists. If this breaks, Sabrina is unreachable and the 10th badge (and eventually Red's gate) is lost.
|
||||
|
||||
*How to prove it:* Targeted driver: enter the gym, ride pad (11,15), assert arrival at (19,17), then the full 5-hop chain to (11,9).
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new.
|
||||
|
||||
|
||||
### Sabrina battle and Marsh Badge fully extracted, never fought
|
||||
|
||||
walkthrough section(s) 20 | unverified
|
||||
|
||||
Sabrina script (cache 61:40cf): checkflag 39 guard, loadtrainer class 35 member 1, reloadmapafterbattle, force-sets the four gym trainer flags (so the trainers are skippable, as the walkthrough implies), setflag 39 = ENGINE_MARSHBADGE routed to save.player.kantoBadges.MARSH. Party matches the cart exactly (trainers.lua SABRINA 1: ESPEON46 Sand-Attack/Quick Attack/Swift/Psychic, MR__MIME46 Barrier/Reflect/Baton Pass/Psychic, ALAKAZAM48 Recover/Future Sight/Psychic/Reflect; class item HYPER_POTION, base reward 25, AI attribute words extracted; attributes.asm:209-213). The four trainers (Rebecca, Franklin, Doris, Jared; MEDIUM 6,7 / PSYCHIC_T 2,11; parties verified including the cart's L35 third Exeggcute that the FAQ gets wrong) are OBJECTTYPE_TRAINER with SPINRANDOM movement, which Npc.lua supports, and sight/battle handoff is the proven Trainers.lua path. Prize money base x last-level x4 is implemented in Prize.lua (matches the walkthrough's 4800G).
|
||||
|
||||
*How to prove it:* Driver through the warp maze to Sabrina; assert ENGINE_MARSHBADGE (flag 39) into save.player.kantoBadges.MARSH, the four trainer flags force-set, and the 4800G payout.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new. Corrections embedded: the L35 third Exeggcute is cart-correct (the FAQ is wrong); Sabrina's class item is HYPER_POTION.
|
||||
|
||||
|
||||
### Copycat lost doll quest chain toward the rail PASS (section-20 filing): all pieces present and generic, never run
|
||||
|
||||
walkthrough section(s) 20 | unverified
|
||||
|
||||
Spans this section's maps (Copycat's house is Saffron warp 8, the doll source is the Vermilion Fan Club) though the walkthrough text defers it to after the Power Plant (EVENT_RETURNED_MACHINE_PART gates the dialogue arms). Copycat's 2F object uses variable sprite 251 (SPRITE_COPYCAT), and the new-game variablesprite seeding that used to leave Copycat despawned was fixed during the Johto campaign (tests/gen2_variable_sprites_test.lua, GOLD-WALK-HANDOFF section 5); her script (cache 61:5235) is extracted bytecode whose only special, LoadUsedSpritesGFX, is implemented (Specials.lua:1033); the Fan Club Clefairy Guy (59:437b) checks events 210/201 and gives LOST_ITEM; the doll object (flag 1907) and the PASS handover (verbosegiveitem PASS, setevent EVENT_GOT_PASS_FROM_COPYCAT per pokegold maps/CopycatsHouse2F.asm:61-73) are ordinary ops; PASS is a KEY_ITEM in items.lua. Nothing engine-shaped is missing.
|
||||
|
||||
*How to prove it:* Same driver as the s22 PASS quest entry: full Copycat/Fan Club round trip asserting LOST_ITEM, doll flags, and PASS.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new, named in the leads. Merge-with hint: same quest chain as pass-quest-copycat-fanclub (s22); kept separate because the doc filed them as distinct headings from two section audits. This filing cites Clefairy Guy gate events 210/201 and doll flag 1907; the s22 filing cites 206/208 and doll-disappear flag 1908.
|
||||
|
||||
|
||||
### Celadon side items: TM03 Curse night gate, Leftovers trash can, hidden PP Up (plus Berserk Gene, Route 9 Ether, Route 25 Potion) never run
|
||||
|
||||
walkthrough section(s) 22 | unverified
|
||||
|
||||
TM03 Curse: roof house pharmacist script 5e:5083 in cache with checktime mask 4 (NITE) at row 7; the Vm checktime opcode (Vm.lua:788) maps NITE to bit 4 off World:timeOfDayId, so the night-only gate should behave; the give branch 5e:509a is guarded by EVENT_GOT_TM03_CURSE 218. Leftovers: CeladonCafeTrashcan bg event at (7,1) key 5e:648c present, plain giveitem bytecode with the flag only set on success so a full pack retries, exactly the cart. Hidden PP Up at Celadon (37,21): BGEVENT_ITEM row present, handled by HiddenItems (fixed PP_UP; the walkthrough's 'random PP Up' is wrong on the cart too). Berserk Gene at Cerulean (2,12), hidden Ether Route 9 (10,5), and hidden Potion Route 25 (4,5) likewise extract. All generic and statically sound, none ever run.
|
||||
|
||||
*How to prove it:* Kanto run collecting each: night visit for TM03 (checktime NITE), cafe trash can Leftovers with full-pack retry, hidden-item picks via HiddenItems.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new. Correction embedded: the PP Up is fixed, not random, on the cart.
|
||||
|
||||
|
||||
### Mt. Moon Square Clefairy dance (Monday night) + hidden Moon Stone + Rock Smash rock: all dependencies present, sequence never executed
|
||||
|
||||
walkthrough section(s) 26 | unverified
|
||||
|
||||
Coord event at (7,11) scene 0 runs 5b:676a, whose 45 rows extract completely: checkflag 87 (ENGINE_MT_MOON_SQUARE_CLEFAIRY), readvar var 11 (VAR_WEEKDAY, real in the port) ifnotequal 1 (MONDAY), checktime NITE, then the appear/follow/applymovement/cry/showemote dance, clearevent 236 (the hidden Moon Stone flag) and setflag 87. Both callbacks extracted (type 5 NEWMAP 5b:6763 re-hides the stone on entry; type 2 OBJECTS 5b:6767 hides the rock). The rock object (SPRITE_ROCK at 7,7, scriptKey 5b:67ee = jumpstd SmashRockScript) resolves to the real SmashRock/AskRockSmash bodies GOLD-INDEX.md:377-382 documents; follow/stopfollow are implemented (Vm.lua:1009,1026). Flag semantics match the cart: ENGINE_MT_MOON_SQUARE_CLEFAIRY lives in wDailyFlags2 (pokegold constants/engine_flags.asm:105-106) and the port clears id 87 in Apricorns.DAILY_ENGINE_FLAGS, so the dance is once per day, i.e. once per Monday night. Unproven in-game: (a) whether the appeared rock correctly blocks/receives the A press on the Moon Stone tile at (7,7) and Rock Smash then exposes the hidden item in the same visit, and (b) the full follow-chain animation. The stubbed RockMonEncounter (CallAsm STUB_ROWS) does NOT affect this rock: MOUNT_MOON_SQUARE is not in the cart's RockMonMaps (pokegold data/wild/treemon_maps.asm:43-48), so no encounter roll is owed here.
|
||||
|
||||
*How to prove it:* Driver: force clockDay=MONDAY clockHour=NITE, walk to (7,11), watch the dance, smash the rock, take the Moon Stone.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Rival re-encounters after Mt Moon (Indigo Plateau Mon/Wed rematch, Dragon's Den Tue/Thu cameo) never run
|
||||
|
||||
walkthrough section(s) 26 | unverified
|
||||
|
||||
Both beats are pure extracted content keyed on EVENT_BEAT_RIVAL_IN_MT_MOON (set by the implemented Mt Moon script) and VAR_WEEKDAY (real in the port; World:readVar VAR_WEEKDAY at World.lua:1289): the cache carries INDIGO_PLATEAU_POKECENTER_1F's NEWMAP callback 5a:48b6 (23 rows of setmapscene/clearevent resets) plus its two coord events, and DRAGONS_DEN_B1F's callback 47:44e1 (checkevent 793 -> disappear object 4). The daily rematch flag ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT (id 92) is in Apricorns.DAILY_ENGINE_FLAGS so it clears on the daily rollover. The rematch parties RIVAL2 members 4-6 (L45-50 with CROBAT) are in trainers.lua. Nothing looks missing, but the coord-event choreography in the Pokecenter (rival walks in as you leave) is the kind of scene worth a driver before trusting; filed unverified rather than implemented for that reason.
|
||||
|
||||
*How to prove it:* Driver: set EVENT_BEAT_RIVAL_IN_MT_MOON via the Mt Moon fight, force weekday Monday/Wednesday, enter the Indigo lobby and assert the rematch triggers; force Tuesday/Thursday and assert the Dragon's Den object 4 state.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new. Merge-with hint: overlaps indigo-lobby-rival-rematch (s18), the same Indigo Plateau rematch filed from the section-18 audit; that filing cites World:weekday at World.lua:1247 and scriptKeys 5a:48ff/5a:4940.
|
||||
|
||||
|
||||
### Indigo lobby rival rematch (Monday/Wednesday, RIVAL2, post Mt. Moon) armed on tiles the walkthrough crosses, never run
|
||||
|
||||
walkthrough section(s) 18 | unverified
|
||||
|
||||
Not in the walkthrough text but armed on the lobby tiles it crosses: coord events at (16,4)/(17,4) run PlateauRivalBattle1/2 (cache scriptKeys 5a:48ff/5a:4940), which fight the RIVAL2 second-tier party when EVENT_BEAT_RIVAL_IN_MT_MOON is set, ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT is clear, and VAR_WEEKDAY is Monday or Wednesday (pokegold maps/IndigoPlateauPokecenter1F.asm). Every dependency exists: coord events and scripts extracted, VAR_WEEKDAY answers the real weekday (World:weekday, World.lua:1247), the daily engine flag is registered for midnight reset (Apricorns.lua:88, id 92 ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT), and RIVAL2 parties 4-6 (SNEASEL/45, CROBAT/48, MAGNETON/45, GENGAR/46, ALAKAZAM/46, starter/50) are in the trainers cache. But the gate flag EVENT_BEAT_RIVAL_IN_MT_MOON is Kanto content no run has ever set.
|
||||
|
||||
*How to prove it:* Should be picked up when section 26 is audited/run: same driver as rival-rematch-mtmoon, entering the lobby on Monday/Wednesday with the Mt Moon flag set and asserting the coord-event battle fires.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new. Merge-with hint: same beat as rival-rematch-mtmoon (s26); kept separate because the doc filed both headings. Line-cite difference between filings: World.lua:1247 (weekday) here vs World.lua:1289 (readVar VAR_WEEKDAY) there.
|
||||
|
||||
|
||||
### Lighthouse descent and swim to Cianwood (route rows 08.50/08.51) never walked; collision data matches the cart, blame points at the bot's region graph
|
||||
|
||||
walkthrough section(s) 08 | unverified
|
||||
|
||||
The route bot has never completed the descent from Jasmine to Route 40, so the Surf crossing, all fourteen Route 40/41 swimmers, and Cianwood arrival are engine-unwalked (runs reach section 08+ by teleport; run 28 still failed rows 08.50/08.51). The handoff narrows it to the 3F seven-cell pocket: warps 8/9 at (8,3)/(9,3) 'do not take' when stood on. Verified in the current cache: OLIVINE_LIGHTHOUSE_3F (8,3)/(9,3) decode to COLL_FLOOR 0x00, and pokegold data/tilesets/lighthouse_collision.asm block $27 is FLOOR,FLOOR,FLOOR,FLOOR, so those two warp rows are one-way landing anchors for the 4F pits at (8,3)/(9,3) (which decode to COLL_PIT 0x60) and never fire on the cart either. The pocket's real exit is warp 3, the staircase at (9,5), which decodes to 0x72 and does fire ((13,3) also 0x72). Fix shape: correct the bot's region graph, which treats landing-anchor warp coordinates as exits (the exact trap GOLD-WALK-HANDOFF.md:593 documents), not the engine; a human following the walkthrough (drop beside Connie, fight Terrell, take the Ether, head up the stairs) uses the (9,5) staircase and should be fine.
|
||||
|
||||
*How to prove it:* Something must walk 6F down to CIANWOOD_CITY in the engine (bot rows 08.50/08.51 passing, or a driver doing the descent and the Route 40/41 Surf crossing); until then the second half of section 08 stays a beat-completeness check rather than a proven path.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, already named in the ledger. The doc's own re-check corrected the suspicion of an engine defect: the cache collision matches the cart exactly, so the failing rows indict the bot's region graph, not the engine or the warp data.
|
||||
|
||||
|
||||
### TM08 Rock Smash give (route row 06.21) flaky across runs; ledger contradicts itself and the failure is unresolved
|
||||
|
||||
walkthrough section(s) 06 | unverified
|
||||
|
||||
The Route 36 fisher gives TM08 after EVENT_FOUGHT_SUDOWOODO (Route36RockSmashGuyScript, generic extracted bytecode with verbosegiveitem guarded by iffalse .NoRoomForTM). The ledger contradicts itself across runs: GOLD-WALK-HANDOFF section 8 says 'Everything before it now passes on merit: Sudowoodo, TM08, the ROCK SMASH teach', but run 28's failure table lists '06.21 ROUTE_36 EVENT_GOT_TM08_ROCK_SMASH not set' while the Sudowoodo row itself passed. Plausible causes: full TM pocket taking the iffalse arm, or the bot failing to stand adjacent. The script path is generic and the same give shape works everywhere else, so this is most likely route flake, but it is unresolved in the latest continuous run and TM08 gates all Rock Smash content downstream (Burned Tower rocks, Route 40/Cianwood rock items).
|
||||
|
||||
*How to prove it:* Re-run route row 06.21 in a continuous run and assert EVENT_GOT_TM08_ROCK_SMASH gets set; instrument for TM-pocket fullness and adjacency to distinguish the two suspected causes.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, already named in the ledger. Not given severity unverified-risk because the problem is a reproduced-once run failure (a live flake), not purely never-executed content.
|
||||
|
||||
|
||||
### New Bark rival push scene (talk to rival by the lab) unproven; low risk, zero coverage
|
||||
|
||||
walkthrough section(s) 00 | unverified
|
||||
|
||||
Walkthrough: 'If you talk to him, he'll push you out of the way.' NewBarkTownRivalScript is optional, repeatable (sets no flag) and uses follow PLAYER, applymovement with turn_head/step/fix_facing/jump_step, and SFX_TACKLE. Every opcode involved is implemented (follow/stopfollow in the VM, jump_step in Movement.lua which the ledge fixes exercised), and the sibling teacher-drag scene has a dedicated driver (tests/drivers/gold_teacher_scene.lua), but no driver or test touches this specific scene and the route bot never talks to optional NPCs. Cache maps.lua NEW_BARK_TOWN objects include the rival with a scriptKey and eventFlag.
|
||||
|
||||
*How to prove it:* A short driver that talks to the rival by the lab and observes the push movement chain, mirroring gold_teacher_scene.lua.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Kiyo battle and Tyrogue gift (Mount Mortar B1F): fully wired, never exercised by any driver or bot run
|
||||
|
||||
walkthrough section(s) 15 | unverified
|
||||
|
||||
The game's only Tyrogue. Cache scripts['46:5eec'] holds all 29 rows of MountMortarB1FKiyoScript (checkevent 97 / 1193, winlosstext, loadtrainer class BLACKBELT_T member 6, startbattle, reloadmapafterbattle, setevent 1193, readvar var=1 VAR_PARTYCOUNT, ifequal 6, givepoke species 236 level 10, setevent 97); trainers cache has BLACKBELT_T[6] KIYO = L34 HITMONLEE + L34 HITMONCHAN (KIYO object at 13,4 with scriptKey 46:5eec); readvar VAR_PARTYCOUNT is a real read (World.lua:1271); givepoke is implemented at Vm.lua:443. The full dependency chain has never run: Surf across the 1F Outside lake, the in-cave Waterfall climb to warp 4 at (17,5), the 2F/1F-inside/B1F warp descent, the battle, and the gift. The waterfall climb mechanic itself is bot-verified only at Tohjo Falls; route.lua contains no MORTAR/KIYO/DARK_CAVE rows.
|
||||
|
||||
*How to prove it:* A Mount Mortar driver: Surf the outside lake, Waterfall to warp 4 (17,5), descend to B1F, beat Kiyo, assert Tyrogue (species 236, L10) given with a party of <6 and events 97/1193 set.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new. Correction embedded: GOLD-INDEX's claim that only VAR_WEEKDAY and VAR_FACING answer readvar is STALE; World.lua:1265-1330 now answers essentially every VAR_*.
|
||||
|
||||
|
||||
### Blackglasses pharmacist in Dark Cave Blackthorn entrance extracted exactly, never run (same for TM13 Snore and Revive balls)
|
||||
|
||||
walkthrough section(s) 15 | unverified
|
||||
|
||||
The script is fully extracted (cache scripts['47:436c'], 13 rows matching pokegold maps/DarkCaveBlackthornEntrance.asm exactly: checkevent / verbosegiveitem / iffalse pack-full arm / setevent), the spinning NPC object (SPRITE_PHARMACIST) is in the cache at (7,3) with that scriptKey, and verbosegiveitem is implemented in the VM. Reaching him also needs Flash plus Surf inside the cave, both implemented. The same unverified state covers the TM13 Snore and Revive item balls beside him (item ball arm is implemented and tested). No driver enters Dark Cave and the bot route bypasses it (grep DARK_CAVE in route.lua is empty).
|
||||
|
||||
*How to prove it:* A Dark Cave driver: Flash + Surf to the Blackthorn entrance, talk to the pharmacist (assert Blackglasses given), pick up TM13 Snore and the Revives.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Master Ball from Elm after eighth badge: route rows exist but optional, no run log confirms the event ever set
|
||||
|
||||
walkthrough section(s) 16 | unverified
|
||||
|
||||
ProfElmScript's ElmCheckMasterBall gate is checkflag ENGINE_RISINGBADGE then verbosegiveitem MASTER_BALL then setevent EVENT_GOT_MASTER_BALL_FROM_ELM (pokegold maps/ElmsLab.asm:57-61). All generic extracted bytecode; checkflag over ENGINE_* badges works since the badge-store fix (GOLD-WALK-HANDOFF.md:502, badges routed into save.player.badges). The route bot has rows 16.24/16.24b for this beat but both are marked optional = true, and no run log or handoff note confirms EVENT_GOT_MASTER_BALL_FROM_ELM was ever actually set in a live run (no MASTER hits in GOLD-NEXT-RUN.md run logs). A full bag silently skips the setevent (verbosegiveitem iffalse), same as the cart.
|
||||
|
||||
*How to prove it:* Make route rows 16.24/16.24b non-optional (or run them) and assert EVENT_GOT_MASTER_BALL_FROM_ELM set with bag space available.
|
||||
|
||||
*Notes:* Doc rating: unverified/major, new.
|
||||
|
||||
|
||||
### Everstone from Elm by showing a self-hatched Togepi: pieces individually proven, full chain never exercised end to end
|
||||
|
||||
walkthrough section(s) 16 | unverified
|
||||
|
||||
The gating special FindPartyMonThatSpeciesYourTrainerID is a real handler with the own-OT check (Specials.lua:1134), and the phone-call prerequisite is NOT required: pokegold maps/ElmsLab.asm ElmCheckTogepiEgg -> EVENT_TOGEPI_HATCHED -> ElmEggHatchedScript re-runs the same species scan, so a hatched Togepi in the party is sufficient (maps/ElmsLab.asm:62-95, engine/phone/scripts/elm.asm:46). Egg hatching has a driver (tests/drivers/gold_egg_hatch.lua) and PC withdraw exists (BoxMenu.lua / PcMenu.lua). The full chain (hatch with your OT, withdraw, talk, EVENT_SHOWED_TOGEPI_TO_ELM, verbosegiveitem EVERSTONE) has never run end to end. The .egghatched arm of ElmPhoneCalleeScript (sets EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE) lives in the bank $41 callee scripts and only matters for the alternate entry path. No route row exists for the Everstone.
|
||||
|
||||
*How to prove it:* A driver chaining gold_egg_hatch-style hatching (own OT), PC withdraw, and the Elm talk, asserting EVENT_SHOWED_TOGEPI_TO_ELM and EVERSTONE in bag.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### TM37 Sandstorm happiness gate at the Route 27 house never exercised; gate is first-slot happiness >= 150
|
||||
|
||||
walkthrough section(s) 16 | unverified
|
||||
|
||||
The granny's script is extracted (cache scripts['60:6352'], 9 rows: checkevent, special id 88, ifgreater, sjump into the loyal/disloyal arms) and special id 88 resolves through constants.specialOrder with the VM's order[id+1] rule (Vm.lua:1773) to specialOrder[89] = GetFirstPokemonHappiness, a real handler at Specials.lua:1506 reading the first non-egg party member. The cart gate is happiness >= 150 on the FIRST party slot (ifgreater 150-1), not 'one of your Pokemon'. Happiness accrues via Happiness.lua stepped from StepEvents. No driver or bot row enters ROUTE_27_SANDSTORM_HOUSE.
|
||||
|
||||
*How to prove it:* Driver: enter ROUTE_27_SANDSTORM_HOUSE with a first-slot mon at happiness >= 150 and assert TM37 given; also assert the disloyal arm below 150.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Route 27 whirlpool island (Bird Keeper Jose, TM22 Solarbeam, Rare Candy) and Tohjo Falls Moon Stone never run; whirlpool mechanic only proven on Route 41
|
||||
|
||||
walkthrough section(s) 16 | unverified
|
||||
|
||||
The whirlpool mechanic is implemented and driver-verified, but on Route 41, not here: tests/drivers/gold_water_moves.lua:105-124 clears a real whirlpool block with GLACIERBADGE gating and asserts the block replacement. The Route 27 island content is all in the cache (TM_SOLARBEAM ball at (60,12) item id 213 with event flag, Rare Candy ball at (53,12), BIRD_KEEPER[14] JOSE = L35 FARFETCH_D with phone contact rows; maps.ROUTE_27 objects rows 6-8) and the item ball arm is implemented, but the bot route crosses Route 27 only via Tohjo Falls and never surfs south past Gilbert (route rows 16.42-16.43 skip the south water), so this specific whirlpool, the Jose fight, and the TM22 pickup have never run. Same for the Tohjo Falls MOON_STONE ball at (2,6) (maps.TOHJO_FALLS object 1), which the walkthrough itself never mentions.
|
||||
|
||||
*How to prove it:* Driver or route rows: surf south past Gilbert, clear the Route 27 whirlpool, fight Jose, collect TM22 and the Rare Candy; separately pick up the Tohjo Falls Moon Stone.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Route 26 heal house free heal extracted with all specials real, never entered
|
||||
|
||||
walkthrough section(s) 16 | unverified
|
||||
|
||||
The teacher's script is extracted (cache scripts['60:60e6'], 17 rows) and every special id in it resolves via order[id+1] to a real handler: 47 FadeOutToBlack, 50 ReloadSpritesNoPalettes, 27 HealParty, 49 FadeInFromBlack, 60 RestartMapMusic (Specials.lua:450, :999, :1025, and the fade-in/restart pair at :1059). Unconditional and repeatable like the cart. No driver or bot row ever enters ROUTE_26_HEAL_HOUSE; the bot route's Route 26 leg is a single travel row straight to the gate.
|
||||
|
||||
*How to prove it:* Driver: enter ROUTE_26_HEAL_HOUSE with a damaged party, talk, assert full heal and that the scene is repeatable.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
|
||||
### Victory Road item pickups (TM26 Earthquake pit, Full Restore shelf, Max Revive, Full Heal, X Special, two hidden items) never exercised
|
||||
|
||||
walkthrough section(s) 17 | unverified
|
||||
|
||||
The walkthrough collects five Poke Ball items (TM26 Earthquake via the one-way pit at (0,11), Full Restore on the ledge shelf via the (17,19) ladder, Max Revive and Full Heal in the entrance region, X Special on the HOP_LEFT column) plus two hidden items (Max Potion at (3,29), Full Heal at (3,65)). All pieces exist: cache maps.VICTORY_ROAD carries 6 objects (5 with itemball item/qty payloads), 2 bgEvents with hiddenItem tables, 10 warps, 2 coord events; World:interact consumes obj.itemball (World.lua:5483-5490, an earlier bot-found fix); HiddenItems handles BGEVENT_ITEM; Permissions models the COLL_PIT one-way warp, the six ladders, ledge hops (isLedge/ledgeFacings, Gold's own direction order where $a0 is HOP_RIGHT), and COLL_UP_WALL side walls (Permissions.lua:170-260 LEDGE_FACINGS/SIDE_BLOCKS/NEIGHBOR_ARM), with a test section at tests/gen2_world_test.lua:2936. But nothing has walked these routes: the bot's section 17 is only travel/walk rows 17.2, 17.11, 17.15 (no pickup rows), so the pit drop, the two HOP exits from the TM pocket, the HOP_DOWN off the shelf, and the X Special ball sitting ON a HOP_LEFT tile (the one placement the asm-walk flags as needing hardware confirmation) are all unproven end to end.
|
||||
|
||||
*How to prove it:* Driver or route rows through Victory Road collecting all five balls and both hidden items, specifically exercising the (0,11) pit drop, the (17,19) ladder shelf with HOP_DOWN exit, the two HOP exits from the TM pocket, and interaction with the X Special ball on its HOP_LEFT tile.
|
||||
|
||||
*Notes:* Doc rating: unverified/minor, new.
|
||||
|
||||
Executable
+366
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds.
|
||||
# The package uses PortMaster control hooks and a self-contained LÖVE runtime,
|
||||
# while keeping paths relative to the launcher for broad CFW compatibility.
|
||||
#
|
||||
# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64
|
||||
# runtime so the device does not need a separate runtime download on first launch.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-linux-arm-sbc.sh [--version X.Y.Z]
|
||||
# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z
|
||||
# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z
|
||||
#
|
||||
# Output:
|
||||
# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip
|
||||
#
|
||||
# Install on device:
|
||||
# 1. Install PortMaster for the handheld firmware.
|
||||
# 2. Unzip into the device's PortMaster ports folder so you have:
|
||||
# Roms/Ports (PORTS)/gen1recomp-sbc.sh
|
||||
# Roms/Ports (PORTS)/gen1recomp-sbc/...
|
||||
# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/
|
||||
# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that
|
||||
# folder when zenity is missing).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/linux-arm-sbc"
|
||||
WORK="$HERE/work/linux-arm-sbc"
|
||||
DIST="$ROOT/dist/linux-arm-sbc"
|
||||
|
||||
APP_NAME="gen1recomp-sbc"
|
||||
# Artifact suffix identifies this as the generic PortMaster SBC package.
|
||||
# Release uploads stage it as gen1recomp-<ver>-sbc-portmaster.zip.
|
||||
ARTIFACT_SUFFIX="portmaster"
|
||||
PORT_DIR_NAME="gen1recomp-sbc"
|
||||
LAUNCHER_NAME="gen1recomp-sbc.sh"
|
||||
LOVE_VERSION="11.5"
|
||||
# By default the pack is reproducible from the latest published GitHub release,
|
||||
# not whatever happens to be in the caller's checkout. Development builds can
|
||||
# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo.
|
||||
SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}"
|
||||
SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}"
|
||||
VERSION="${GEN1RECOMP_VERSION:-}"
|
||||
|
||||
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||
RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest"
|
||||
RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;;
|
||||
--source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;;
|
||||
--release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v curl >/dev/null || fail "curl is required"
|
||||
command -v zip >/dev/null || fail "zip is required"
|
||||
command -v unzip >/dev/null || fail "unzip is required"
|
||||
command -v tar >/dev/null || fail "tar is required"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||
return 0
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||
|| fail "download failed: $url"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- source + game tree
|
||||
# Release builds use the latest published source archive. A local checkout is
|
||||
# an explicit override for development and for CI's just-built release source.
|
||||
if [ -n "$SOURCE_DIR_OVERRIDE" ]; then
|
||||
SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \
|
||||
|| fail "source directory does not exist: $SOURCE_DIR_OVERRIDE"
|
||||
SOURCE_DIR="$SOURCE_DIR_OVERRIDE"
|
||||
SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}"
|
||||
if [ "$SOURCE_TAG" != "local" ]; then
|
||||
printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
fi
|
||||
else
|
||||
if [ -z "$SOURCE_TAG_OVERRIDE" ]; then
|
||||
latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \
|
||||
| awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \
|
||||
|| fail "could not resolve latest published release"
|
||||
SOURCE_TAG_OVERRIDE="${latest_location##*/}"
|
||||
fi
|
||||
printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE"
|
||||
SOURCE_TAG="$SOURCE_TAG_OVERRIDE"
|
||||
SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz"
|
||||
download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE"
|
||||
SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG"
|
||||
rm -rf "$SOURCE_EXTRACT"
|
||||
mkdir -p "$SOURCE_EXTRACT"
|
||||
tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT"
|
||||
SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
|
||||
[ -n "$SOURCE_DIR" ] || fail "release archive had no source directory"
|
||||
if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi
|
||||
fi
|
||||
|
||||
say "staging lovegame/ from $SOURCE_TAG"
|
||||
GAME_SRC="$WORK/lovegame"
|
||||
rm -rf "$GAME_SRC"
|
||||
mkdir -p "$GAME_SRC"
|
||||
|
||||
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
# Stamp release version into the staged tree only (never the working tree).
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
"$GAME_SRC/src/core/Version.lua" \
|
||||
|| fail "version stamp failed"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||
: > "$GAME_SRC/portable.txt"
|
||||
|
||||
# --------------------------------------------------------------- love runtime
|
||||
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||
LOVE_BIN="$CACHE/love.aarch64"
|
||||
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||
OGG_LIB="$CACHE/libogg.so.0"
|
||||
|
||||
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||
|
||||
# Sanity: love stub must be an aarch64 ELF.
|
||||
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||
|
||||
# --------------------------------------------------------------- port tree
|
||||
say "assembling port package"
|
||||
PORT_ROOT="$WORK/port"
|
||||
rm -rf "$PORT_ROOT"
|
||||
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||
|
||||
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||
see https://love2d.org/ for full terms.
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- launcher
|
||||
# Resolve the game directory from the launcher so this works with both
|
||||
# PortMaster-managed ports directories.
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Data/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
if [ ! -f "$controlfolder/control.txt" ]; then
|
||||
echo "PortMaster control.txt not found under $controlfolder" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp-sbc"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
fi
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
# --------------------------------------------------------------- metadata
|
||||
cat > "$PORT_ROOT/port.json" <<EOF
|
||||
{
|
||||
"version": 2,
|
||||
"name": "gen1recomp-sbc.zip",
|
||||
"items": [
|
||||
"$LAUNCHER_NAME",
|
||||
"$PORT_DIR_NAME"
|
||||
],
|
||||
"items_opt": null,
|
||||
"attr": {
|
||||
"title": "gen1recomp-sbc",
|
||||
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||
"source": "https://github.com/bryanthaboi/gen1recomp/releases/tag/$SOURCE_TAG",
|
||||
"inst": "Requires a 64-bit Linux ARM handheld with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp-sbc/lovegame/, then launch and press Choose ROM.",
|
||||
"genres": ["adventure", "rpg"],
|
||||
"porter": ["gen1recomp-sbc"],
|
||||
"image": {},
|
||||
"rtr": true,
|
||||
"runtime": null,
|
||||
"reqs": [],
|
||||
"arch": ["aarch64"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<gameList>
|
||||
<game>
|
||||
<path>./$LAUNCHER_NAME</path>
|
||||
<name>gen1recomp-sbc</name>
|
||||
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||
<releasedate>20250101T000000</releasedate>
|
||||
<developer>the bois club</developer>
|
||||
<publisher>the bois club</publisher>
|
||||
<genre>RPG</genre>
|
||||
</game>
|
||||
</gameList>
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||
## gen1recomp-sbc (Linux ARM SBC / PortMaster)
|
||||
|
||||
Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**.
|
||||
|
||||
### Install
|
||||
|
||||
1. Install PortMaster for your handheld firmware.
|
||||
2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory.
|
||||
3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`.
|
||||
4. Refresh the launcher and launch **gen1recomp-sbc** from Ports.
|
||||
|
||||
### Controls
|
||||
|
||||
| Input | Action |
|
||||
|--|--|
|
||||
| D-pad | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Start / Select | Play or choose ROM |
|
||||
|
||||
Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime.
|
||||
|
||||
### First run
|
||||
|
||||
Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`).
|
||||
|
||||
### Thanks
|
||||
|
||||
LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team.
|
||||
EOF
|
||||
sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md"
|
||||
rm -f "$PORT_ROOT/README.md.bak"
|
||||
|
||||
# --------------------------------------------------------------- zip
|
||||
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||
rm -f "$ZIP_OUT"
|
||||
say "packing $ZIP_OUT"
|
||||
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||
|
||||
say "done."
|
||||
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||
say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/"
|
||||
+7
-4
@@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC"
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
|
||||
&& fail "payload unexpectedly contains generated ROM data"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
|
||||
@@ -203,7 +203,8 @@ return {
|
||||
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
|
||||
+24
-7
@@ -206,6 +206,13 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
|
||||
local function escortToLab(oak)
|
||||
-- PalletMovementScript_OakMoveLeft
|
||||
-- (engine/overworld/auto_movement.asm) starts MUSIC_MUSEUM_GUY
|
||||
-- when the escort begins in Yellow. Until then, Pallet Town plays
|
||||
-- after the battle; Red/Blue leave MUSIC_MEET_PROF_OAK playing.
|
||||
if yellow then
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
end
|
||||
local numSteps = x - 10
|
||||
if oak and numSteps > 0 then
|
||||
ow:scriptMove(oak, "left", numSteps, function()
|
||||
@@ -249,14 +256,24 @@ M.PALLET_TOWN = {
|
||||
function()
|
||||
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||
-- looks right, right exit looks left -- the
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch).
|
||||
-- In pokeyellow, PalletTownOakGreetsPlayerScript turns Oak and
|
||||
-- PalletTownPikachuBattleScript arms the battle on the next
|
||||
-- overworld iteration. OverworldLoopLessDelay
|
||||
-- (home/overworld.asm) burns two DelayFrame calls at the top
|
||||
-- of each iteration and calls RunMapScript before checking
|
||||
-- wCurOpponent, so those two DelayFrame calls are what keep
|
||||
-- Oak's turn on screen before the battle check fires.
|
||||
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
game.stack:push(battle)
|
||||
hold(2, nil, function()
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
-- Use the standard wild-battle entry transition.
|
||||
Commands.pushBattle(ctx, battle)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ M.CERULEAN_MELANIES_HOUSE = {
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
-- pet flavor: the text with the species' cry over it
|
||||
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||
@@ -105,7 +106,8 @@ M.VERMILION_CITY = {
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ local function oldMan2Talk(game, ow, npc, done)
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Linux ARM SBC Handhelds (PortMaster)
|
||||
|
||||
Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices.
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder.
|
||||
2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`.
|
||||
3. Install PortMaster for your firmware and refresh the Ports list.
|
||||
4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`.
|
||||
5. Launch **gen1recomp-sbc** from Ports and choose the ROM.
|
||||
|
||||
The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes.
|
||||
|
||||
Canonical US cart SHA-1 values:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Controls
|
||||
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| D-pad | Move cursor |
|
||||
| A | Click / confirm |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Start / Select | Play or choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**.
|
||||
|
||||
## Runtime and suspend
|
||||
|
||||
The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points.
|
||||
|
||||
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
|
||||
|
||||
## Building
|
||||
|
||||
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||
|
||||
```sh
|
||||
./build-linux-arm-sbc.sh --version 0.1.75
|
||||
```
|
||||
|
||||
For development, package a local checkout explicitly:
|
||||
|
||||
```sh
|
||||
GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0
|
||||
# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0
|
||||
```
|
||||
|
||||
The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card.
|
||||
|
||||
PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem.
|
||||
@@ -0,0 +1,91 @@
|
||||
# RFC 0008 — Runtime mod option schema export
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `src/mods/Loader.lua`. Tests:
|
||||
`tests/mod_loader_tests.lua`. This RFC defines an optional filesystem
|
||||
contract; it does not require a native launcher or any other consumer.
|
||||
|
||||
## Motivation
|
||||
|
||||
A native launcher may want to present settings for installed mods before it
|
||||
starts the game. Running every mod's entry chunk in that launcher just to
|
||||
discover its settings would duplicate engine behavior and give the launcher
|
||||
an unnecessary code-execution surface. The engine already has the authoritative
|
||||
runtime schemas after mod loading, so it can publish a data-only snapshot for
|
||||
platform shells that want one.
|
||||
|
||||
## The exact contract
|
||||
|
||||
After the mod loader has finished running entry chunks, it may write
|
||||
`mod_option_schemas.json` beside `options.lua` in the same filesystem. The
|
||||
document is a snapshot of the current boot; it is not a second settings store
|
||||
and does not change how option values are read or written.
|
||||
|
||||
Version 1 has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mods": {
|
||||
"example": [
|
||||
{"key":"enabled","type":"toggle","label":"Enabled","default":true},
|
||||
{"key":"mode","type":"choice","label":"Mode","default":"safe",
|
||||
"choices":[["Safe","safe"],["Fast","fast"]]},
|
||||
{"key":"rate","type":"number","label":"Rate","default":5,
|
||||
"min":0,"max":10,"step":1},
|
||||
{"key":"name","type":"text","label":"Name","default":"","maxLen":12}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mods` is keyed by mod id. Its rows come from the runtime
|
||||
`mod.options:define` schema, or from the legacy manifest `options_schema` file
|
||||
when the runtime schema is absent. The supported row types are `toggle`,
|
||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||
established by the existing in-game option UI: choices are `[label, value]`
|
||||
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||
provide `maxLen`.
|
||||
|
||||
Only mods that are enabled and successfully loaded in the current boot are
|
||||
included. A disabled or failed mod must not contribute rows. If an older
|
||||
snapshot exists and the current boot has no schema-bearing mods, the producer
|
||||
overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale
|
||||
settings rows from surviving a disable or load failure. A fresh mod-free boot
|
||||
does not create the file, and a filesystem without write support is tolerated.
|
||||
|
||||
The producer writes the snapshot after entry chunks and the final load set
|
||||
have been established. Consumers must treat the file as untrusted input and
|
||||
must not execute anything from it.
|
||||
|
||||
## Compatibility and versioning
|
||||
|
||||
The contract is optional on both sides. A native consumer may be absent, and
|
||||
the engine continues normally if the file cannot be written. A native
|
||||
consumer is not required to render, validate, or persist every supported row;
|
||||
it may ignore an unknown row type or optional field.
|
||||
|
||||
For compatibility with files produced by the original unversioned prototype,
|
||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||
with a newer version rather than guessing at their shape. Producers must bump
|
||||
the version whenever they change the document shape or the meaning of an
|
||||
existing field. Version 1 is therefore the legacy unversioned format as well
|
||||
as the explicitly versioned format shown above.
|
||||
|
||||
## Migration note
|
||||
|
||||
Nothing. Existing mods, option values, and the in-game options UI are
|
||||
unchanged. Platforms that do not consume `mod_option_schemas.json` have no
|
||||
new integration requirement.
|
||||
|
||||
## Parity tests
|
||||
|
||||
`tests/mod_loader_tests.lua` verifies the explicit version, runtime and legacy
|
||||
row round-tripping, enabled/disabled filtering, failed-mod filtering,
|
||||
stale-snapshot clearing, and tolerance of a read-only filesystem.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The unversioned file form remains readable as legacy
|
||||
version 1; new producers write the explicit `schema_version` field.
|
||||
+104
-9
@@ -68,6 +68,14 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
|
||||
## Party ordering
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
`mod.world:canReorderParty()` before offering a reorder action, then
|
||||
`mod.world:reorderParty(fromSlot, toSlot)` with one-based party slots. The
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -231,7 +239,18 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
validated matching ordinary-save chronology only; it never exposes normal-save
|
||||
progress or a slot/path handle.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is read-only: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
@@ -250,13 +269,21 @@ if capability.canCapture then
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
|
||||
-- After the tool has durably committed its first checkpoint, make a
|
||||
-- never-saved playthrough reachable through ordinary title boot exactly once.
|
||||
local anchored, anchorCode, anchorMessage =
|
||||
mod.checkpoints:ensureNormalSave(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control and proven battle
|
||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
player-decision safe points. Ordinary single-player wild/trainer encounters are
|
||||
supported. Scripted story battles are also supported when the engine can detach
|
||||
their current built-in battle command and data-only row continuation, rebind any
|
||||
NPC by stable id, and resume the story through a fresh runner. The suspended Lua
|
||||
coroutine is never serialized. Link, Safari, ghost, demo, opaque callback,
|
||||
non-data-only script, animation, message, queue, concurrent-script, and
|
||||
forced-action phases fail closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
@@ -293,7 +320,37 @@ private state. A mod that deliberately stores progress-coupled truth in
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to
|
||||
live `restore`. It validates the same data-only checkpoint against the
|
||||
engine-selected existing playthrough, reconstructs only after all validation
|
||||
passes, preserves current options, and verifies by recapture. A title session
|
||||
has no live gameplay rollback state: if reconstruction or verification fails,
|
||||
the engine rebuilds a usable title session and returns `false, code, message`.
|
||||
It never rewrites a normal Pokémon save. It is unavailable outside title and does
|
||||
not broaden capture or arbitrary-frame support.
|
||||
|
||||
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
|
||||
operation for durable checkpoint tools. It creates ordinary progress only when
|
||||
none exists, only after validating that the supplied checkpoint is the exact
|
||||
current safe runtime, and through the normal atomic save lifecycle. Once an
|
||||
ordinary save exists it returns `true, "already_exists"` without writing, so
|
||||
subsequent checkpoints and the player's later SAVE commands remain independent.
|
||||
Call it only after the tool's own checkpoint/index commit; treat an anchoring
|
||||
failure as a failed first checkpoint rather than claiming restart safety.
|
||||
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||
codes.
|
||||
|
||||
At that same settled supported wild/trainer decision boundary, a tool may claim
|
||||
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
|
||||
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
|
||||
never receives the live battle controller. Return `true` to consume START after
|
||||
opening source-owned UI, or call `next(game, context)` to allow lower-priority
|
||||
handlers. With no handler, START remains inert. Ordinary encounters and the
|
||||
validated built-in scripted battle origins described by RFC 0005 are eligible;
|
||||
opaque scripts, link/Safari/ghost/demo battles, action queues,
|
||||
animation/messages, forced choices, and every phase that cannot safely be
|
||||
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||
and fall through without advancing a turn.
|
||||
|
||||
## Developer console
|
||||
|
||||
@@ -397,9 +454,12 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
||||
only owns battle presentation should return `false` only for its active battle
|
||||
or text-box state.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
own state, preserving selective control outside a battle; a wrapper that only
|
||||
owns battle presentation should return `false` only for its active battle or
|
||||
text-box state.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
@@ -449,3 +509,38 @@ platform-bridge mod bundled only with that build's launcher, for example).
|
||||
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||
already falls straight through to the vanilla function when no mod has
|
||||
wrapped the name, at negligible cost.
|
||||
|
||||
## Detached Pokémon icon presentation
|
||||
|
||||
`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon
|
||||
the native Party menu would resolve without exposing a live Pokémon record or
|
||||
the private Party menu. `summary` is the detached data-only shape
|
||||
`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and
|
||||
`opts.counter` optionally request the native selected-icon animation phase.
|
||||
|
||||
The engine retains icon ownership. Content registered through
|
||||
`mod.content.icons`, species `icon` definitions, asset overrides, and the
|
||||
public `pokemon.icon` hook therefore continue to compose. Invalid summaries
|
||||
return `false, code, message` and draw nothing. The helper is presentation
|
||||
only: it does not expose moves, status, checkpoint payloads, or mutable party
|
||||
state.
|
||||
|
||||
## Shared date and time presentation
|
||||
|
||||
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
|
||||
`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`).
|
||||
These preferences live in `options.lua`, so checkpoint restore never rewinds
|
||||
them. `DEVICE` uses the process time locale when the platform provides one;
|
||||
the portable fallback is `DD-MM-YYYY` plus 24-hour time.
|
||||
|
||||
Mods format captured timestamps through the read-only public facade:
|
||||
|
||||
```lua
|
||||
local date = mod.datetime:date(game, createdAt)
|
||||
local time = mod.datetime:time(game, createdAt)
|
||||
local both = mod.datetime:dateTime(game, createdAt)
|
||||
```
|
||||
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
@@ -27,8 +27,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
||||
|
||||
* **Separate Gold save file** beside the Gen 1 ones
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load** on the same keys as Gen 1
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
* **Border-block surrounds** for maps smaller than the screen
|
||||
* **Gold-specific launcher options**
|
||||
@@ -39,5 +38,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
* **On-screen touch pad** and controller SELECT for registered items
|
||||
|
||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
||||
|
||||
|
||||
@@ -15,9 +15,12 @@ references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
The engine can instead expose a narrow semantic safe point. Ordinary encounters
|
||||
use fixed engine-owned completion descriptors. A scripted story encounter may
|
||||
also participate when its active command row and remaining row-list are
|
||||
detached data, its NPC can be rebound by stable object id, and its completion is
|
||||
one of the engine-declared semantic forms. The suspended coroutine itself is
|
||||
never captured.
|
||||
|
||||
## API delta
|
||||
|
||||
@@ -26,7 +29,7 @@ second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
`mod.checkpoints:inspect(game)` returns this only when a supported single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
@@ -35,13 +38,15 @@ wild or trainer battle is settled at the player command menu:
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
The battle must carry an engine-owned semantic continuation descriptor. An
|
||||
ordinary encounter requires an idle overworld. A scripted story encounter may
|
||||
have exactly its originating foreground runner suspended at the battle command;
|
||||
queued/parallel scripts and scripted movement remain unsafe.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
ghost, old-man/demo, fishing, opaque callback continuations, non-data-only
|
||||
scripts, and unsupported concurrent script work remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
@@ -107,10 +112,20 @@ trainer class/party, and optional header event; a win reapplies the same defeate
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
For `start_battle`, `rival_battle`, and `static_battle`, the runner records the
|
||||
detached row list and current command program counter plus stable source/NPC
|
||||
identity where present. On restore, battle completion starts a fresh runner at
|
||||
that command with a one-use semantic battle result. Replaying the current
|
||||
command (rather than skipping to the next row) preserves wrapper behavior such
|
||||
as rival-party consequences, static-object removal, `lastCheck`, and deferred
|
||||
`afterBattle` evolution ordering. The reconstructed overworld starts with
|
||||
normalized input/NPC freeze state, so an engine-marked `release_npc` callback
|
||||
needs no closure revival.
|
||||
|
||||
Arbitrary `onDone` callbacks, function-bearing rows, unknown commands, missing
|
||||
NPC identities, old-man/demo flows, and concurrent scripts fail closed. This is
|
||||
not a general ScriptRunner snapshot: no coroutine, Lua stack, local variable,
|
||||
function, or runtime object enters the checkpoint.
|
||||
|
||||
## Migration note
|
||||
|
||||
@@ -138,3 +153,6 @@ No-mod behavior is unchanged when checkpoints are unused.
|
||||
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
- scripted trainer and static/wild story continuation, including wrapper-row
|
||||
replay, cold reconstruction, malformed row rejection, and opaque callback
|
||||
refusal.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC 0006 — Selected title playthrough storage and checkpoint resume
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `Storage.lua`, `Checkpoint.lua`, and
|
||||
`Loader.lua`. Tests: `title_playthrough_context.lua`, existing storage,
|
||||
checkpoint, title, save-slot, and no-mod parity suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
A tool checkpoint may be the first durable record of a new playthrough. The
|
||||
engine intentionally keeps normal Pokémon SAVE independent: before the first
|
||||
normal write, identity is retained by the engine-owned selected-slot mapping,
|
||||
while title starts with a fresh New Game skeleton. A durable checkpoint tool may
|
||||
explicitly create one ordinary progress anchor after its first checkpoint has
|
||||
committed; later tool writes must remain independent. Calling ordinary active
|
||||
`mod.storage` there would allocate/adopt an identity, and live
|
||||
`mod.checkpoints:restore` correctly refuses title because it has no gameplay
|
||||
rollback state. Generic public capabilities are required; a tool must not use
|
||||
private storage paths, slot ids, or simulate the player's SAVE menu flow.
|
||||
|
||||
## Additive public API
|
||||
|
||||
### `mod.storage:selected(game)`
|
||||
|
||||
Available only while the engine is in a title session. Returns an opaque bound
|
||||
facade or `nil, code, message`:
|
||||
|
||||
```lua
|
||||
local selected = mod.storage:selected(game)
|
||||
local context = selected:context()
|
||||
local history = selected:read("history/index")
|
||||
```
|
||||
|
||||
The facade exposes `context()`, `read(key)`, `write(key, value)`,
|
||||
`list(prefix)`, and `delete(key)`. It is bound internally to the launcher-
|
||||
selected existing game-version/playthrough and the calling mod id. It neither
|
||||
accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or
|
||||
another mod namespace. Resolution is read-only; no selected mapping means
|
||||
`no_selected_playthrough`, and opening a title browser never mints an identity.
|
||||
Its detached context can include only `normalSavedAt` from a matching ordinary
|
||||
save, so title tools can apply their own resume policy without receiving the
|
||||
canonical normal-save record.
|
||||
|
||||
### `mod.checkpoints:ensureNormalSave(game, checkpoint)`
|
||||
|
||||
Available only at a live checkpoint-safe boundary. After a tool has durably
|
||||
committed the supplied current checkpoint, it may request an ordinary progress
|
||||
anchor for a playthrough that has never had one. The engine validates the
|
||||
checkpoint, proves it exactly matches a fresh capture of the live runtime, and
|
||||
uses the normal atomic save path including `save.write` lifecycle/veto hooks.
|
||||
|
||||
The operation is idempotent. It returns `true, "already_exists"` without writing
|
||||
when matching normal progress already exists, so later checkpoints never move
|
||||
the vanilla CONTINUE target. A stale/non-current checkpoint, unsafe runtime,
|
||||
write veto/failure, or failed readback returns a structured failure. A tool
|
||||
should call it only after its own checkpoint and index are durable and must not
|
||||
report that first checkpoint as successful if the required anchor fails.
|
||||
|
||||
### `mod.checkpoints:resume(game, checkpoint)`
|
||||
|
||||
Available only from title. It validates format, data-only structure, selected
|
||||
game/playthrough identity, canonical save/content, overworld/battle runtime, and
|
||||
RNG exactly as `restore` does. It then reconstructs semantic overworld or a
|
||||
supported battle continuation, preserves current options, and differentially
|
||||
recaptures before committing. On success it emits `checkpoint.restored` once.
|
||||
|
||||
Title has no live runtime rollback. A reconstruction or verification failure
|
||||
therefore rebuilds a clean title session from the pre-operation title save and
|
||||
RNG; it emits no success event and never rewrites normal progress. Validation
|
||||
failure leaves the existing title session untouched. Stable errors include
|
||||
`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes,
|
||||
`resume_failed`, and `title_recovery_failed`.
|
||||
|
||||
## Isolation and migration
|
||||
|
||||
Explicit NEW GAME retains its existing fresh-identity rule. It does not reuse a
|
||||
previous selected mapping and cannot see old tool history. Existing mods change
|
||||
nothing: no identity, storage, title reconstruction, or event is created unless
|
||||
the new methods are called. `mod.storage` remains independent durable data and
|
||||
does not rewind with a checkpoint; canonical `game.save` / `mod.save` does.
|
||||
|
||||
## Verification
|
||||
|
||||
The public SDK test starts a fresh playthrough, stores tool history, creates and
|
||||
readback-verifies exactly one normal anchor, proves subsequent calls do not
|
||||
rewrite it, simulates title/restart, reads the selected binding without
|
||||
allocating title identity, resumes an overworld checkpoint, preserves options,
|
||||
differentially recaptures, and confirms a later explicit NEW GAME receives
|
||||
another identity. A separate two-process disk test proves cold-start routing and
|
||||
reconstruction. Existing no-mod, storage, checkpoint, battle, and title suites
|
||||
prove additive parity.
|
||||
@@ -0,0 +1,46 @@
|
||||
# RFC 0007: Battle menu auxiliary actions
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Problem
|
||||
|
||||
Tool mods can inspect/capture a persistent checkpoint only at a settled
|
||||
ordinary wild/trainer player-decision boundary. Before this proposal, that
|
||||
boundary had no public semantic input/action seam: `BattleState` consumed the
|
||||
command loop directly. A mod could reach it only through private battle/input
|
||||
internals, which would be unsafe and incompatible with controller/touch input.
|
||||
|
||||
## Contract
|
||||
|
||||
`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START
|
||||
is pressed at the existing checkpoint-safe player-decision boundary. The
|
||||
callback signature is:
|
||||
|
||||
```lua
|
||||
function callback(next, game, context)
|
||||
-- context is { kind = "wild" } or { kind = "trainer" }
|
||||
-- return true after claiming START, otherwise return next(game, context)
|
||||
end
|
||||
```
|
||||
|
||||
The context is data-only. No live battle controller, input object, serializer,
|
||||
or restoration primitive is exposed. A `true` result consumes START for that
|
||||
fixed step without selecting a battle command. With no installed handler,
|
||||
START is inert exactly as before. Hook priorities and error isolation are the
|
||||
existing generic wrapper semantics: a throwing handler is skipped and cannot
|
||||
advance battle state.
|
||||
|
||||
The engine reuses the same internal safety predicate as battle checkpoint
|
||||
capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues,
|
||||
animations, messages, forced replacement/locked actions, and unsettled HP or
|
||||
status presentation never invoke the hook.
|
||||
|
||||
## Compatibility and verification
|
||||
|
||||
The call is additive and no-op with no handler. ROM-free engine tests prove
|
||||
wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal;
|
||||
the mod-SDK fixture proves a loaded mod can consume the semantic action using
|
||||
only its public hook facade. `gate_hooks` automatically includes the new call
|
||||
site in no-mod parity coverage.
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# After first boot of a compatible Linux ARM handheld (or when PortMaster is installed), reinsert the
|
||||
# SD card and run this to install gen1recomp-sbc + Red/Blue ROMs into Roms/PORTS.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGE="$ROOT/.bazinga/work/linux-arm-sbc-install"
|
||||
DECPREP="${DECPREP:-$ROOT/../decprep}"
|
||||
ZIP="$ROOT/dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Find a mounted handheld userdata volume with a ROMs or Apps directory.
|
||||
find_roms_root() {
|
||||
local v candidate
|
||||
for v in /Volumes/*; do
|
||||
[ -d "$v" ] || continue
|
||||
# Prefer a volume that already has Roms/ or Apps/
|
||||
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ] || [ -d "$v/Apps" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fallback: common removable-volume labels
|
||||
for v in /Volumes/SDCARD /Volumes/sdcard /Volumes/NO\ NAME /Volumes/ROMS; do
|
||||
if [ -d "$v" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
say "looking for handheld SD volume"
|
||||
ROMS_ROOT="$(find_roms_root)" || fail "no SD volume mounted. boot the handheld once, power it off, reinsert the SD, then rerun."
|
||||
|
||||
say "using: $ROMS_ROOT"
|
||||
# Resolve the device PortMaster ports directory
|
||||
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/ports"
|
||||
elif [ -d "$ROMS_ROOT/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/PORTS"
|
||||
else
|
||||
mkdir -p "$ROMS_ROOT/Roms/PORTS"
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
fi
|
||||
say "PORTS: $PORTS"
|
||||
|
||||
# Refresh staged payload
|
||||
mkdir -p "$STAGE/PORTS"
|
||||
if [ -f "$ZIP" ]; then
|
||||
rm -rf "$STAGE/PORTS/gen1recomp-sbc.sh" "$STAGE/PORTS/gen1recomp-sbc" "$STAGE/PORTS/port.json" \
|
||||
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
|
||||
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
|
||||
else
|
||||
fail "missing $ZIP — run ./build-linux-arm-sbc.sh first"
|
||||
fi
|
||||
|
||||
# Ensure ROMs are in lovegame (Choose ROM scans this folder on minimal images)
|
||||
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
|
||||
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
|
||||
|
||||
say "copying gen1recomp port"
|
||||
rm -rf "$PORTS/gen1recomp-sbc" "$PORTS/gen1recomp-sbc.sh"
|
||||
cp -R "$STAGE/PORTS/gen1recomp-sbc" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/gen1recomp-sbc.sh" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/port.json" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/README.md" "$PORTS/"
|
||||
chmod +x "$PORTS/gen1recomp-sbc.sh" "$PORTS/gen1recomp-sbc/bin/love.aarch64"
|
||||
|
||||
# Also drop carts in the stock GB folder for the emulator library
|
||||
GB_DIR=""
|
||||
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
|
||||
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
|
||||
done
|
||||
if [ -n "$GB_DIR" ]; then
|
||||
say "copying .gb into $GB_DIR"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
|
||||
fi
|
||||
|
||||
sync
|
||||
say "installed:"
|
||||
ls -lh "$PORTS/gen1recomp-sbc.sh"
|
||||
ls -lh "$PORTS/gen1recomp-sbc/lovegame/"*.gb
|
||||
say "eject the SD, insert it in the handheld, open Ports → gen1recomp-sbc, Choose ROM."
|
||||
@@ -23,6 +23,7 @@
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <SDL.h>
|
||||
@@ -324,6 +325,182 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
* missing method answers like a platform without TLS instead of aborting.
|
||||
*/
|
||||
static jclass tlsActivityClass(JNIEnv *env)
|
||||
{
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return nullptr;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
return activity;
|
||||
}
|
||||
|
||||
int tlsOpen(const char *host, int port)
|
||||
{
|
||||
if (host == nullptr)
|
||||
return -1;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsOpen", "(Ljava/lang/String;I)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jstring jhost = env->NewStringUTF(host);
|
||||
jint result = env->CallStaticIntMethod(activity, method, jhost, (jint) port);
|
||||
env->DeleteLocalRef(jhost);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsStatus(int handle)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsStatus", "(I)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jint result = env->CallStaticIntMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsSend(int handle, const char *data, int length)
|
||||
{
|
||||
if (data == nullptr || length <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsSend", "(I[B)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jbyteArray payload = env->NewByteArray((jsize) length);
|
||||
if (payload == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
env->SetByteArrayRegion(payload, 0, (jsize) length, (const jbyte*) data);
|
||||
|
||||
jint result = env->CallStaticIntMethod(activity, method, (jint) handle, payload);
|
||||
env->DeleteLocalRef(payload);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsReceive(int handle, char *buf, int max)
|
||||
{
|
||||
if (buf == nullptr || max <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsReceive", "(II)[B");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle, (jint) max);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (result == nullptr)
|
||||
return 0;
|
||||
|
||||
jbyteArray bytes = (jbyteArray) result;
|
||||
jsize length = env->GetArrayLength(bytes);
|
||||
if (length > max)
|
||||
length = max;
|
||||
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) buf);
|
||||
env->DeleteLocalRef(result);
|
||||
return (int) length;
|
||||
}
|
||||
|
||||
bool tlsError(int handle, char *buf, int max)
|
||||
{
|
||||
if (buf == nullptr || max <= 0)
|
||||
return false;
|
||||
buf[0] = '\0';
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return false;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsError", "(I)Ljava/lang/String;");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (result == nullptr)
|
||||
return false;
|
||||
|
||||
jstring text = (jstring) result;
|
||||
const char *utf = env->GetStringUTFChars(text, nullptr);
|
||||
if (utf != nullptr)
|
||||
{
|
||||
strncpy(buf, utf, (size_t) max - 1);
|
||||
buf[max - 1] = '\0';
|
||||
env->ReleaseStringUTFChars(text, utf);
|
||||
}
|
||||
env->DeleteLocalRef(result);
|
||||
return buf[0] != '\0';
|
||||
}
|
||||
|
||||
void tlsClose(int handle)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsClose", "(I)V");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return;
|
||||
}
|
||||
|
||||
env->CallStaticVoidMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -98,6 +98,27 @@ bool restartApp();
|
||||
**/
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||
|
||||
/**
|
||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
* unreachable -- and an Archipelago room hosted on archipelago.gg accepts a
|
||||
* plain connection only to drop it. The platform has both a TLS stack and the
|
||||
* system trust store, so this borrows them rather than vendoring mbedTLS.
|
||||
*
|
||||
* tlsOpen returns a handle immediately and connects on its own thread: poll
|
||||
* tlsStatus for 0 connecting / 1 open / 2 closed, and -1 for a handle that
|
||||
* does not exist. Bytes given to tlsSend before the handshake finishes are
|
||||
* queued rather than refused. tlsReceive fills buf and returns how much it
|
||||
* took, 0 when nothing is waiting. A closed connection keeps both its reason
|
||||
* (tlsError) and whatever arrived before it closed until tlsClose.
|
||||
**/
|
||||
int tlsOpen(const char *host, int port);
|
||||
int tlsStatus(int handle);
|
||||
int tlsSend(int handle, const char *data, int length);
|
||||
int tlsReceive(int handle, char *buf, int max);
|
||||
bool tlsError(int handle, char *buf, int max);
|
||||
void tlsClose(int handle);
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -244,6 +244,72 @@ bool System::httpDownload(const char *url, const char *destPath,
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsOpen(host, port);
|
||||
#else
|
||||
LOVE_UNUSED(host);
|
||||
LOVE_UNUSED(port);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsStatus(int handle) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsStatus(handle);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsSend(int handle, const char *data, int length) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsSend(handle, data, length);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(data);
|
||||
LOVE_UNUSED(length);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsReceive(int handle, char *buf, int max) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsReceive(handle, buf, max);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(buf);
|
||||
LOVE_UNUSED(max);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::tlsError(int handle, char *buf, int max) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsError(handle, buf, max);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(buf);
|
||||
LOVE_UNUSED(max);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void System::tlsClose(int handle) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
love::android::tlsClose(handle);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -149,6 +149,20 @@ public:
|
||||
virtual bool httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
||||
|
||||
/**
|
||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
* tlsOpen returns a handle and connects on its own thread, tlsStatus
|
||||
* reports 0 connecting / 1 open / 2 closed / -1 unknown, and bytes sent
|
||||
* before the handshake completes are queued rather than refused.
|
||||
**/
|
||||
virtual int tlsOpen(const char *host, int port) const;
|
||||
virtual int tlsStatus(int handle) const;
|
||||
virtual int tlsSend(int handle, const char *data, int length) const;
|
||||
virtual int tlsReceive(int handle, char *buf, int max) const;
|
||||
virtual bool tlsError(int handle, char *buf, int max) const;
|
||||
virtual void tlsClose(int handle) const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
* Throws an exception on unsupported platforms.
|
||||
|
||||
@@ -139,6 +139,79 @@ int w_hasBackgroundMusic(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Deliberately a handle-and-poll API rather than an object:
|
||||
* the caller is a per-frame pump that must never block, and everything with
|
||||
* a thread behind it lives on the Java side.
|
||||
*/
|
||||
int w_tlsOpen(lua_State *L)
|
||||
{
|
||||
const char *host = luaL_checkstring(L, 1);
|
||||
int port = (int) luaL_checknumber(L, 2);
|
||||
lua_pushnumber(L, instance()->tlsOpen(host, port));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsStatus(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
lua_pushnumber(L, instance()->tlsStatus(handle));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsSend(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
size_t length = 0;
|
||||
const char *data = luaL_checklstring(L, 2, &length);
|
||||
lua_pushnumber(L, instance()->tlsSend(handle, data, (int) length));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsReceive(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
int max = (int) luaL_optnumber(L, 2, 8192);
|
||||
if (max <= 0)
|
||||
{
|
||||
lua_pushliteral(L, "");
|
||||
return 1;
|
||||
}
|
||||
// A frame's worth of a busy room, on the C stack rather than the heap:
|
||||
// this runs every frame and an allocation per poll is not worth it.
|
||||
if (max > 65536)
|
||||
max = 65536;
|
||||
char buf[65536];
|
||||
int got = instance()->tlsReceive(handle, buf, max);
|
||||
if (got < 0)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushlstring(L, buf, (size_t) got);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsError(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
char buf[512];
|
||||
if (!instance()->tlsError(handle, buf, (int) sizeof(buf)))
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, buf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsClose(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
instance()->tlsClose(handle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const luaL_Reg functions[] =
|
||||
{
|
||||
{ "getOS", w_getOS },
|
||||
@@ -153,6 +226,12 @@ static const luaL_Reg functions[] =
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
{ "tlsReceive", w_tlsReceive },
|
||||
{ "tlsError", w_tlsError },
|
||||
{ "tlsClose", w_tlsClose },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -606,6 +606,45 @@ public class GameActivity extends SDLActivity {
|
||||
* The body lands in a .part file and is renamed only once complete, so a
|
||||
* dropped connection can never leave a half file the caller trusts.
|
||||
*/
|
||||
/**
|
||||
* TLS client sockets, exposed as love.system.tls* and used by the
|
||||
* Archipelago mod for wss:// rooms. LuaSocket speaks TCP only, so without
|
||||
* these a hosted room -- every one of which is TLS-only -- is unreachable
|
||||
* from the game. The work is in TlsSocket; these are the static entry
|
||||
* points, because the JNI side resolves methods on the activity's own
|
||||
* class (see love/src/common/android.cpp) and cannot see other classes
|
||||
* from a worker thread.
|
||||
*/
|
||||
@Keep
|
||||
public static int tlsOpen(String host, int port) {
|
||||
return TlsSocket.open(host, port);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static int tlsStatus(int handle) {
|
||||
return TlsSocket.status(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static int tlsSend(int handle, byte[] data) {
|
||||
return TlsSocket.send(handle, data);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static byte[] tlsReceive(int handle, int max) {
|
||||
return TlsSocket.receive(handle, max);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String tlsError(int handle) {
|
||||
return TlsSocket.error(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void tlsClose(int handle) {
|
||||
TlsSocket.close(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
|
||||
if (url == null || destPath == null) return false;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package org.love2d.android;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SNIServerName;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
/**
|
||||
* A TLS client socket that Lua can drive without ever blocking a frame.
|
||||
*
|
||||
* WHY THIS EXISTS. LuaSocket, which is what LOVE ships, speaks TCP and nothing
|
||||
* else, so wss:// was simply unreachable from the game -- and every room hosted
|
||||
* on archipelago.gg is TLS-only, accepting a plain connection just long enough
|
||||
* to drop it. The alternative was vendoring mbedTLS into the NDK build and
|
||||
* carrying a CA bundle in the APK; the platform already has both a TLS stack
|
||||
* and the system trust store, so this asks Android instead.
|
||||
*
|
||||
* THE CONTRACT. Callers get an int handle and poll it. open() returns
|
||||
* immediately and the connect and handshake happen on their own thread, so a
|
||||
* slow or unreachable host costs nothing on the game thread -- which matters
|
||||
* more here than it did for httpDownload, since that runs on a worker and this
|
||||
* is serviced from the frame loop. Bytes handed to send() before the handshake
|
||||
* finishes are queued, not refused, so a caller can write its request the
|
||||
* moment it has a handle and never think about readiness again.
|
||||
*
|
||||
* Reads are drained by a thread into a chunk queue and handed over a copy at a
|
||||
* time; a caller that stops polling stops the connection rather than growing
|
||||
* the heap without limit.
|
||||
*/
|
||||
final class TlsSocket {
|
||||
static final int STATUS_CONNECTING = 0;
|
||||
static final int STATUS_OPEN = 1;
|
||||
static final int STATUS_CLOSED = 2;
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 15000;
|
||||
private static final int READ_CHUNK = 16384;
|
||||
/** Roughly a second of a very chatty room; past this the reader is gone. */
|
||||
private static final int MAX_BUFFERED = 4 * 1024 * 1024;
|
||||
|
||||
private static final ConcurrentHashMap<Integer, TlsSocket> LIVE =
|
||||
new ConcurrentHashMap<Integer, TlsSocket>();
|
||||
private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1);
|
||||
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final int handle;
|
||||
|
||||
private volatile int status = STATUS_CONNECTING;
|
||||
private volatile String error = null;
|
||||
private volatile boolean closing = false;
|
||||
private volatile SSLSocket socket = null;
|
||||
|
||||
private final Object inLock = new Object();
|
||||
private final ArrayDeque<byte[]> inChunks = new ArrayDeque<byte[]>();
|
||||
private int inHeadOffset = 0;
|
||||
private int inAvailable = 0;
|
||||
|
||||
private final Object outLock = new Object();
|
||||
private final ArrayDeque<byte[]> outChunks = new ArrayDeque<byte[]>();
|
||||
|
||||
private TlsSocket(String host, int port, int handle) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- API
|
||||
|
||||
static int open(String host, int port) {
|
||||
if (host == null || host.length() == 0 || port <= 0 || port > 65535) return -1;
|
||||
final int handle = NEXT_HANDLE.getAndIncrement();
|
||||
final TlsSocket self = new TlsSocket(host, port, handle);
|
||||
LIVE.put(Integer.valueOf(handle), self);
|
||||
Thread dialer = new Thread(new Runnable() {
|
||||
@Override public void run() { self.dial(); }
|
||||
}, "tls-dial-" + handle);
|
||||
dialer.setDaemon(true);
|
||||
dialer.start();
|
||||
return handle;
|
||||
}
|
||||
|
||||
static int status(int handle) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
return self == null ? -1 : self.status;
|
||||
}
|
||||
|
||||
static String error(int handle) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
return self == null ? null : self.error;
|
||||
}
|
||||
|
||||
static int send(int handle, byte[] data) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
if (self == null || data == null) return -1;
|
||||
if (self.status == STATUS_CLOSED) return -1;
|
||||
if (data.length == 0) return 0;
|
||||
synchronized (self.outLock) {
|
||||
self.outChunks.add(data);
|
||||
self.outLock.notifyAll();
|
||||
}
|
||||
return data.length;
|
||||
}
|
||||
|
||||
static byte[] receive(int handle, int max) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
if (self == null || max <= 0) return null;
|
||||
return self.take(max);
|
||||
}
|
||||
|
||||
static void close(int handle) {
|
||||
TlsSocket self = LIVE.remove(Integer.valueOf(handle));
|
||||
if (self != null) self.shutdown(null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
private void dial() {
|
||||
Socket plain = null;
|
||||
try {
|
||||
plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
|
||||
plain.setTcpNoDelay(true);
|
||||
|
||||
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||
SSLSocket ssl = (SSLSocket) factory.createSocket(plain, host, port, true);
|
||||
|
||||
// Wrapping an already-connected socket skips the SNI and hostname
|
||||
// checking that createSocket(host, port) would have done for us, and
|
||||
// a shared address like archipelago.gg answers with the wrong
|
||||
// certificate without the name in the hello. Both are set through
|
||||
// SSLParameters where the platform has it, with the verifier below
|
||||
// as the floor for anything older.
|
||||
boolean verifiedByPlatform = false;
|
||||
try {
|
||||
SSLParameters params = ssl.getSSLParameters();
|
||||
params.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
List<SNIServerName> names = new ArrayList<SNIServerName>(1);
|
||||
names.add(new SNIHostName(host));
|
||||
params.setServerNames(names);
|
||||
ssl.setSSLParameters(params);
|
||||
verifiedByPlatform = true;
|
||||
} catch (Throwable ignored) {
|
||||
// Older platform: handled after the handshake instead.
|
||||
}
|
||||
|
||||
enableModernProtocols(ssl);
|
||||
ssl.startHandshake();
|
||||
|
||||
if (!verifiedByPlatform
|
||||
&& !HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
.verify(host, ssl.getSession())) {
|
||||
throw new java.io.IOException(
|
||||
"certificate does not match " + host);
|
||||
}
|
||||
|
||||
socket = ssl;
|
||||
if (closing) { shutdown(null); return; }
|
||||
status = STATUS_OPEN;
|
||||
|
||||
Thread writer = new Thread(new Runnable() {
|
||||
@Override public void run() { pumpOut(); }
|
||||
}, "tls-write-" + handle);
|
||||
writer.setDaemon(true);
|
||||
writer.start();
|
||||
|
||||
pumpIn();
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
if (plain != null) {
|
||||
try { plain.close(); } catch (Throwable ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* minSdk is 16, where TLS 1.1/1.2 exist but are off by default. Every
|
||||
* modern server refuses everything older, so switch on whatever the
|
||||
* platform has rather than leaving an old device negotiating TLS 1.0.
|
||||
*/
|
||||
private static void enableModernProtocols(SSLSocket ssl) {
|
||||
try {
|
||||
List<String> wanted = new ArrayList<String>(3);
|
||||
for (String supported : ssl.getSupportedProtocols()) {
|
||||
if (supported.startsWith("TLSv1.1")
|
||||
|| supported.startsWith("TLSv1.2")
|
||||
|| supported.startsWith("TLSv1.3")) {
|
||||
wanted.add(supported);
|
||||
}
|
||||
}
|
||||
if (!wanted.isEmpty()) {
|
||||
ssl.setEnabledProtocols(wanted.toArray(new String[wanted.size()]));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpIn() {
|
||||
try {
|
||||
InputStream in = socket.getInputStream();
|
||||
byte[] buf = new byte[READ_CHUNK];
|
||||
while (!closing) {
|
||||
int n = in.read(buf);
|
||||
if (n < 0) break;
|
||||
if (n == 0) continue;
|
||||
byte[] chunk = new byte[n];
|
||||
System.arraycopy(buf, 0, chunk, 0, n);
|
||||
synchronized (inLock) {
|
||||
if (inAvailable + n > MAX_BUFFERED) {
|
||||
throw new java.io.IOException("read buffer overflow");
|
||||
}
|
||||
inChunks.add(chunk);
|
||||
inAvailable += n;
|
||||
}
|
||||
}
|
||||
shutdown(null);
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpOut() {
|
||||
try {
|
||||
OutputStream out = socket.getOutputStream();
|
||||
while (true) {
|
||||
byte[] chunk;
|
||||
synchronized (outLock) {
|
||||
while (outChunks.isEmpty() && !closing && status != STATUS_CLOSED) {
|
||||
outLock.wait();
|
||||
}
|
||||
if (closing || status == STATUS_CLOSED) return;
|
||||
chunk = outChunks.poll();
|
||||
}
|
||||
if (chunk != null) {
|
||||
out.write(chunk);
|
||||
out.flush();
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] take(int max) {
|
||||
synchronized (inLock) {
|
||||
if (inAvailable <= 0) return null;
|
||||
int want = Math.min(max, inAvailable);
|
||||
byte[] out = new byte[want];
|
||||
int filled = 0;
|
||||
while (filled < want) {
|
||||
byte[] head = inChunks.peek();
|
||||
if (head == null) break;
|
||||
int have = head.length - inHeadOffset;
|
||||
int take = Math.min(have, want - filled);
|
||||
System.arraycopy(head, inHeadOffset, out, filled, take);
|
||||
filled += take;
|
||||
inHeadOffset += take;
|
||||
if (inHeadOffset >= head.length) {
|
||||
inChunks.poll();
|
||||
inHeadOffset = 0;
|
||||
}
|
||||
}
|
||||
inAvailable -= filled;
|
||||
if (filled == want) return out;
|
||||
byte[] short_ = new byte[filled];
|
||||
System.arraycopy(out, 0, short_, 0, filled);
|
||||
return short_;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle stays registered until the caller closes it, so why the
|
||||
* connection ended and whatever arrived before it did are both still
|
||||
* readable. Dropping it here instead would turn a server that states its
|
||||
* refusal and hangs up into an unknown handle, which is the one failure a
|
||||
* player most needs the reason for.
|
||||
*/
|
||||
private void shutdown(String why) {
|
||||
if (why != null && error == null) error = why;
|
||||
closing = true;
|
||||
status = STATUS_CLOSED;
|
||||
synchronized (outLock) { outLock.notifyAll(); }
|
||||
SSLSocket s = socket;
|
||||
socket = null;
|
||||
if (s != null) {
|
||||
try { s.close(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (why != null) Log.d("TlsSocket", host + ":" + port + " -- " + why);
|
||||
}
|
||||
|
||||
private static String describe(Throwable t) {
|
||||
String msg = t.getMessage();
|
||||
String name = t.getClass().getSimpleName();
|
||||
if (msg == null || msg.length() == 0) return name;
|
||||
return name + ": " + msg;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.79",
|
||||
"date": "2026-08-12",
|
||||
"size": 10956333,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.79/gen1recomp-0.1.79-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1090 With TILT Option enabled, screen does not correctly render beyond what would be visible with TILT Disabled.\n- #1093 Gen 2 Gold Beta: jingle doesn't play when receiving Elm's phone number\n- #1094 Gen 2 Gold Beta: no battle SFX when animations are Off\n- #1095 Gen 2 Gold Beta: player sprite has white box outline that cuts off Pokémon sprites\n- #1098 [Gold] Can't change controls (no menu for it and it doesn't use the one set in RBY)\n- #1102 [Gold] Shop ui off because of a border\n- #1105 [Gold] Game speed doesn't apply to the intro\n- #1106 [Gold] Games slots don't respect portable.txt\n- #1108 [Gold] Launcher doesn't show any information about the save slot(s)\n- #1109 [Options] text speed doesn't work at all.\n- #1110 [Gold] When catching a Pokemon, the ball disappears and the text doesn't wait for you to progress\n- #1111 [Gold] nurse joy doesn't look left for healing\n- #1113 [Gold] Poke center -> Bills PC -> Deposit Pokemon, screen off\n- #1114 [Gold] Can't release Pokemon, menu doesn't appear\n- #1117 [Gold/Options] sound doesn't change\n- #1118 [Gold] Berrys animation missing and speedup.\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1122 [Gold] Level up UI.\n- #1123 [Gold] Character stops \"inside door\" when leaving buildings instead of moving one tile down\n- #1124 [Gold] When \"turning\", no animation is played\n- #1126 [Gold] Jumping off ledge animation missing\n- #1127 [Gold] Very minor Pokegear issues.\n- #1128 [Gold] When calling mom, the yes/no dialogue background color doesn't fit\n- #1131 [Gold] Pokegear map bug(s)\n- #1132 [gold] pokedex bugs\n- #1134 Unable to Validate/Pack mod calling Gold's overworld sprite\n- #1137 64x64 sprite support broken for mods on Gen 2\n- #1141 Teleport doesn't end wild battle\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @dburton95\n- @dlloa\n- @MaxTomahawk\n- @swuff-star\n- @TheRealSolidusSnake\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.78",
|
||||
"date": "2026-08-11",
|
||||
"size": 10944298,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.78/gen1recomp-0.1.78-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.77",
|
||||
"date": "2026-08-10",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!--
|
||||
Desktop TLS dialer for mods that need outbound WSS/TLS (e.g. multiworld
|
||||
clients). Native AOT so the game gets a plain C ABI DLL with no .NET
|
||||
runtime to install. Windows uses Schannel via SslStream; Linux and macOS
|
||||
use their platform backends the same way.
|
||||
|
||||
Publish examples:
|
||||
dotnet publish -c Release -r win-x64 -o ../../dist/native/win-x64
|
||||
dotnet publish -c Release -r linux-x64 -o ../../dist/native/linux-x64
|
||||
dotnet publish -c Release -r osx-x64 -o ../../dist/native/osx-x64
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PublishAot>true</PublishAot>
|
||||
<NativeLib>Shared</NativeLib>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<AssemblyName>gen1tls</AssemblyName>
|
||||
<RootNamespace>Gen1Tls</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
# gen1tls — desktop TLS dialer
|
||||
|
||||
Native AOT library that gives mods a non-blocking TLS client with the same
|
||||
handle/poll contract as the Android `TlsSocket` / `love.system.tls*` bridge.
|
||||
|
||||
- **Windows:** Schannel via `SslStream` (system trust store, SNI)
|
||||
- **Linux / macOS:** same project, publish with `-r linux-x64` / `osx-x64` /
|
||||
`osx-arm64` when those builds are wired up
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj -c Release -r win-x64 -o dist/native/win-x64
|
||||
```
|
||||
|
||||
The Windows game zip script already does this:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Version 0.1.77
|
||||
```
|
||||
|
||||
Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
|
||||
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||
required on the player's machine.
|
||||
|
||||
## Android
|
||||
|
||||
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
||||
`tlsStatus` / `tlsSend` / `tlsReceive` / `tlsError` / `tlsClose`, backed by
|
||||
`org.love2d.android.TlsSocket`.
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Authentication;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace Gen1Tls;
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking TLS client with the same handle/poll contract as the Android
|
||||
/// TlsSocket bridge. Connect and handshake run on a background thread; send
|
||||
/// queues until the stream is ready; receive drains a chunk queue. Certificate
|
||||
/// validation and SNI are SslStream's defaults (platform trust store).
|
||||
/// </summary>
|
||||
public static class TlsDialer
|
||||
{
|
||||
public const int StatusConnecting = 0;
|
||||
public const int StatusOpen = 1;
|
||||
public const int StatusClosed = 2;
|
||||
|
||||
const int ConnectTimeoutMs = 15000;
|
||||
const int ReadChunk = 16384;
|
||||
const int MaxBuffered = 4 * 1024 * 1024;
|
||||
|
||||
static readonly ConcurrentDictionary<int, Conn> Live = new();
|
||||
static int NextHandle = 1;
|
||||
|
||||
sealed class Conn
|
||||
{
|
||||
public required string Host;
|
||||
public required int Port;
|
||||
public int Status = StatusConnecting;
|
||||
public string? Error;
|
||||
public bool Closing;
|
||||
public SslStream? Stream;
|
||||
public TcpClient? Client;
|
||||
|
||||
public readonly object InLock = new();
|
||||
public readonly Queue<byte[]> InChunks = new();
|
||||
public int InHeadOffset;
|
||||
public int InAvailable;
|
||||
|
||||
public readonly object OutLock = new();
|
||||
public readonly Queue<byte[]> OutChunks = new();
|
||||
public readonly ManualResetEventSlim OutPulse = new(false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- C ABI
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_open")]
|
||||
public static int Open(nint hostPtr, int port)
|
||||
{
|
||||
if (hostPtr == 0 || port <= 0 || port > 65535) return -1;
|
||||
string? host = Marshal.PtrToStringUTF8(hostPtr);
|
||||
if (string.IsNullOrEmpty(host)) return -1;
|
||||
|
||||
int handle = Interlocked.Increment(ref NextHandle);
|
||||
var conn = new Conn { Host = host, Port = port };
|
||||
if (!Live.TryAdd(handle, conn)) return -1;
|
||||
|
||||
var dialer = new Thread(() => Dial(conn))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "gen1tls-dial-" + handle,
|
||||
};
|
||||
dialer.Start();
|
||||
return handle;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_status")]
|
||||
public static int Status(int handle)
|
||||
=> Live.TryGetValue(handle, out var conn) ? conn.Status : -1;
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_send")]
|
||||
public static int Send(int handle, nint dataPtr, int length)
|
||||
{
|
||||
if (!Live.TryGetValue(handle, out var conn) || dataPtr == 0) return -1;
|
||||
if (conn.Status == StatusClosed) return -1;
|
||||
if (length <= 0) return 0;
|
||||
|
||||
var copy = new byte[length];
|
||||
Marshal.Copy(dataPtr, copy, 0, length);
|
||||
lock (conn.OutLock)
|
||||
{
|
||||
conn.OutChunks.Enqueue(copy);
|
||||
conn.OutPulse.Set();
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_receive")]
|
||||
public static int Receive(int handle, nint bufPtr, int max)
|
||||
{
|
||||
if (!Live.TryGetValue(handle, out var conn) || bufPtr == 0 || max <= 0)
|
||||
return 0;
|
||||
return Take(conn, bufPtr, max);
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_error")]
|
||||
public static int Error(int handle, nint bufPtr, int max)
|
||||
{
|
||||
if (!Live.TryGetValue(handle, out var conn)) return 0;
|
||||
string? err = conn.Error;
|
||||
if (string.IsNullOrEmpty(err) || bufPtr == 0 || max <= 1) return 0;
|
||||
|
||||
byte[] utf8 = Encoding.UTF8.GetBytes(err);
|
||||
int n = Math.Min(utf8.Length, max - 1);
|
||||
Marshal.Copy(utf8, 0, bufPtr, n);
|
||||
Marshal.WriteByte(bufPtr, n, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_close")]
|
||||
public static void Close(int handle)
|
||||
{
|
||||
if (Live.TryRemove(handle, out var conn))
|
||||
Shutdown(conn, null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
static void Dial(Conn conn)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(conn.Host, conn.Port);
|
||||
if (!connect.Wait(ConnectTimeoutMs))
|
||||
throw new TimeoutException($"connect to {conn.Host}:{conn.Port} timed out");
|
||||
connect.GetAwaiter().GetResult();
|
||||
client.NoDelay = true;
|
||||
conn.Client = client;
|
||||
|
||||
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
|
||||
// AuthenticateAsClient sets SNI from targetHost and validates against
|
||||
// the platform trust store -- the whole reason this dialer exists.
|
||||
var auth = ssl.AuthenticateAsClientAsync(conn.Host);
|
||||
if (!auth.Wait(ConnectTimeoutMs))
|
||||
throw new TimeoutException($"TLS handshake with {conn.Host} timed out");
|
||||
auth.GetAwaiter().GetResult();
|
||||
|
||||
conn.Stream = ssl;
|
||||
if (conn.Closing) { Shutdown(conn, null); return; }
|
||||
conn.Status = StatusOpen;
|
||||
|
||||
var writer = new Thread(() => PumpOut(conn))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "gen1tls-write",
|
||||
};
|
||||
writer.Start();
|
||||
PumpIn(conn);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Shutdown(conn, Describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
static void PumpIn(Conn conn)
|
||||
{
|
||||
var stream = conn.Stream;
|
||||
if (stream == null) return;
|
||||
var buf = new byte[ReadChunk];
|
||||
try
|
||||
{
|
||||
while (!conn.Closing)
|
||||
{
|
||||
int n = stream.Read(buf, 0, buf.Length);
|
||||
if (n <= 0) break;
|
||||
var chunk = new byte[n];
|
||||
Buffer.BlockCopy(buf, 0, chunk, 0, n);
|
||||
lock (conn.InLock)
|
||||
{
|
||||
// A stalled Lua pump must not grow forever; drop the
|
||||
// connection rather than the room's backlog.
|
||||
if (conn.InAvailable + n > MaxBuffered)
|
||||
throw new InvalidOperationException("TLS receive buffer overflow");
|
||||
conn.InChunks.Enqueue(chunk);
|
||||
conn.InAvailable += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!conn.Closing) Shutdown(conn, Describe(ex));
|
||||
return;
|
||||
}
|
||||
Shutdown(conn, null);
|
||||
}
|
||||
|
||||
static void PumpOut(Conn conn)
|
||||
{
|
||||
var stream = conn.Stream;
|
||||
if (stream == null) return;
|
||||
try
|
||||
{
|
||||
while (!conn.Closing)
|
||||
{
|
||||
byte[]? chunk = null;
|
||||
lock (conn.OutLock)
|
||||
{
|
||||
if (conn.OutChunks.Count == 0)
|
||||
{
|
||||
conn.OutPulse.Reset();
|
||||
// fall through to wait outside the lock
|
||||
}
|
||||
else
|
||||
{
|
||||
chunk = conn.OutChunks.Dequeue();
|
||||
}
|
||||
}
|
||||
if (chunk == null)
|
||||
{
|
||||
conn.OutPulse.Wait(250);
|
||||
continue;
|
||||
}
|
||||
stream.Write(chunk, 0, chunk.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!conn.Closing) Shutdown(conn, Describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
static int Take(Conn conn, nint bufPtr, int max)
|
||||
{
|
||||
lock (conn.InLock)
|
||||
{
|
||||
if (conn.InAvailable == 0 || conn.InChunks.Count == 0) return 0;
|
||||
int copied = 0;
|
||||
while (copied < max && conn.InChunks.Count > 0)
|
||||
{
|
||||
byte[] head = conn.InChunks.Peek();
|
||||
int avail = head.Length - conn.InHeadOffset;
|
||||
int n = Math.Min(avail, max - copied);
|
||||
Marshal.Copy(head, conn.InHeadOffset, bufPtr + copied, n);
|
||||
copied += n;
|
||||
conn.InHeadOffset += n;
|
||||
conn.InAvailable -= n;
|
||||
if (conn.InHeadOffset >= head.Length)
|
||||
{
|
||||
conn.InChunks.Dequeue();
|
||||
conn.InHeadOffset = 0;
|
||||
}
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
}
|
||||
|
||||
static void Shutdown(Conn conn, string? why)
|
||||
{
|
||||
if (conn.Closing && why == null && conn.Status == StatusClosed) return;
|
||||
conn.Closing = true;
|
||||
if (why != null) conn.Error = why;
|
||||
conn.Status = StatusClosed;
|
||||
conn.OutPulse.Set();
|
||||
try { conn.Stream?.Dispose(); } catch { /* ignore */ }
|
||||
try { conn.Client?.Dispose(); } catch { /* ignore */ }
|
||||
conn.Stream = null;
|
||||
conn.Client = null;
|
||||
}
|
||||
|
||||
static string Describe(Exception ex)
|
||||
{
|
||||
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||
{
|
||||
if (e is AuthenticationException) return e.Message;
|
||||
if (e is SocketException se) return se.Message;
|
||||
if (e is TimeoutException) return e.Message;
|
||||
if (e is IOException) return e.Message;
|
||||
}
|
||||
return ex.GetBaseException().Message;
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,9 @@ APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||
# A fresh source checkout normally supplies this through Git. This URL is
|
||||
# deliberately only a last resort for incomplete source exports: the manifest
|
||||
# contains extraction metadata, never a ROM or extracted game data.
|
||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
@@ -130,6 +129,54 @@ ensure_yellow_manifest() {
|
||||
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
||||
}
|
||||
|
||||
gold_manifest_is_valid() {
|
||||
local path="$1"
|
||||
python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
try:
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
|
||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||
"d8b8a3600a465308c9953dfa04f0081c05bdcb94" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_gold_manifest() {
|
||||
local manifest="$ROOT/$GOLD_MANIFEST_RELATIVE"
|
||||
local staged
|
||||
staged="$(mktemp)"
|
||||
|
||||
if gold_manifest_is_valid "$manifest"; then
|
||||
rm -f "$staged"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Gold import manifest is missing or invalid; recovering it before packaging"
|
||||
if git -C "$ROOT" show "HEAD:$GOLD_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||
&& gold_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "restored Gold import manifest from this checkout's Git data"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$GOLD_MANIFEST_URL" \
|
||||
&& gold_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "downloaded Gold import manifest from the project repository"
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "$staged"
|
||||
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -194,6 +241,7 @@ PY
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
ensure_yellow_manifest
|
||||
ensure_gold_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Build a Windows win64 Gen1Recomp zip with native TLS (gen1tls.dll).
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\build_windows.ps1 [-Version 0.1.77]
|
||||
#
|
||||
# Produces:
|
||||
# dist\win\gen1recomp-<version>-windows.zip
|
||||
# gen1recomp.exe fused LÖVE + game.love
|
||||
# gen1tls.dll Native AOT TLS dialer (Schannel via SslStream)
|
||||
#
|
||||
# Requires: .NET 8 SDK (for Native AOT), Git Bash, and tools/winbuild on PATH
|
||||
# for pack_love.sh under Git Bash on Windows.
|
||||
|
||||
param(
|
||||
[string]$Version = "0.1.77"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not (Test-Path (Join-Path $Root "main.lua"))) {
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
}
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
|
||||
throw "Version must be X.Y.Z (got '$Version')"
|
||||
}
|
||||
|
||||
$Cache = Join-Path $Root ".bazinga\cache"
|
||||
$Work = Join-Path $Root ".bazinga\work"
|
||||
$OutDir = Join-Path $Work "gen1recomp-win64"
|
||||
$DistDir = Join-Path $Root "dist\win"
|
||||
$NativeOut = Join-Path $Root "dist\native\win-x64"
|
||||
$LoveZip = Join-Path $Cache "love-11.5-win64.zip"
|
||||
$LoveUrl = "https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip"
|
||||
$Bash = "C:\Program Files\Git\bin\bash.exe"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Cache, $Work, $DistDir, $NativeOut | Out-Null
|
||||
|
||||
# Put zip/python shims first so pack_love.sh works in Git Bash on Windows.
|
||||
$env:PATH = "$(Join-Path $Root 'tools\winbuild');$env:PATH"
|
||||
|
||||
Write-Host "==> building gen1tls.dll (Native AOT)"
|
||||
dotnet publish (Join-Path $Root "native\tls_dial\Gen1Tls.csproj") `
|
||||
-c Release -r win-x64 -o $NativeOut
|
||||
if ($LASTEXITCODE -ne 0) { throw "gen1tls publish failed" }
|
||||
$TlsDll = Join-Path $NativeOut "gen1tls.dll"
|
||||
if (-not (Test-Path $TlsDll)) { throw "gen1tls.dll missing after publish" }
|
||||
|
||||
if (-not (Test-Path $LoveZip) -or (Get-Item $LoveZip).Length -lt 1MB) {
|
||||
Write-Host "==> downloading love-11.5-win64.zip"
|
||||
Invoke-WebRequest -Uri $LoveUrl -OutFile $LoveZip -UseBasicParsing
|
||||
}
|
||||
|
||||
Write-Host "==> packing game.love (engine $Version)"
|
||||
if (-not (Test-Path $Bash)) { throw "Git Bash not found at $Bash" }
|
||||
$RootPosix = ($Root -replace '\\', '/') -replace '^([A-Za-z]):', '/$1'
|
||||
& $Bash -lc "cd '$RootPosix' && PATH=`"`$PWD/tools/winbuild:`$PATH`" scripts/pack_love.sh --output .bazinga/work/game.love --listing .bazinga/work/love-listing.txt --version $Version"
|
||||
if ($LASTEXITCODE -ne 0) { throw "pack_love failed" }
|
||||
|
||||
Write-Host "==> fusing gen1recomp.exe"
|
||||
$Extract = Join-Path $Work "love-win64"
|
||||
Remove-Item -Recurse -Force $Extract, $OutDir -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Extract, $OutDir | Out-Null
|
||||
Expand-Archive -Path $LoveZip -DestinationPath $Extract -Force
|
||||
$LoveDir = Get-ChildItem $Extract -Directory | Select-Object -First 1
|
||||
Copy-Item (Join-Path $LoveDir.FullName "*.dll") $OutDir
|
||||
Copy-Item (Join-Path $LoveDir.FullName "license.txt") $OutDir -ErrorAction SilentlyContinue
|
||||
Copy-Item $TlsDll $OutDir
|
||||
|
||||
$out = [IO.File]::Create((Join-Path $OutDir "gen1recomp.exe"))
|
||||
try {
|
||||
$a = [IO.File]::OpenRead((Join-Path $LoveDir.FullName "love.exe"))
|
||||
try { $a.CopyTo($out) } finally { $a.Dispose() }
|
||||
$b = [IO.File]::OpenRead((Join-Path $Work "game.love"))
|
||||
try { $b.CopyTo($out) } finally { $b.Dispose() }
|
||||
} finally { $out.Dispose() }
|
||||
|
||||
@"
|
||||
Gen1Recomp Windows (engine $Version) — native TLS
|
||||
|
||||
gen1tls.dll sits next to gen1recomp.exe and exposes a non-blocking TLS
|
||||
client (Windows Schannel via .NET SslStream, Native AOT). Mods can load it
|
||||
through LuaJIT FFI, or call love.system.tls* on Android.
|
||||
|
||||
Keep gen1tls.dll beside the executable when you redistribute the zip.
|
||||
"@ | Set-Content (Join-Path $OutDir "README-TLS.txt") -Encoding UTF8
|
||||
|
||||
$ZipOut = Join-Path $DistDir "gen1recomp-$Version-windows.zip"
|
||||
Remove-Item $ZipOut -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $OutDir -DestinationPath $ZipOut -Force
|
||||
|
||||
Write-Host "==> built $ZipOut"
|
||||
Get-Item $ZipOut | ForEach-Object { " {0:N1} MB" -f ($_.Length / 1MB) }
|
||||
Get-ChildItem $OutDir | ForEach-Object { " $($_.Name)" }
|
||||
@@ -357,6 +357,9 @@ cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
cp "$IN/game.love" "$APPDIR/game.love"
|
||||
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
||||
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||
|
||||
@@ -167,5 +167,7 @@ trap 'rm -rf "$temp_dir"' EXIT
|
||||
unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
||||
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|
||||
|| fail "shared payload version was not stamped"
|
||||
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||
|
||||
say "Linux arm64 self-test passed"
|
||||
|
||||
@@ -77,6 +77,8 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
run_tier "T4 title checkpoint cold restart" \
|
||||
bash tests/integration/title_checkpoint_cold_start.sh
|
||||
|
||||
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
||||
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Shared settled supported player-decision predicate. Checkpoint capture and
|
||||
-- the public auxiliary action deliberately use this one engine-owned rule so
|
||||
-- a tool cannot open at a phase that it could not subsequently checkpoint.
|
||||
-- It exposes no controller; callers receive only the result/reason.
|
||||
|
||||
local BattleSafety = {}
|
||||
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
local function nonempty(value)
|
||||
return type(value) == "table" and next(value) ~= nil
|
||||
end
|
||||
|
||||
local function running(runner)
|
||||
return runner and runner.isRunning and runner:isRunning()
|
||||
end
|
||||
|
||||
local function scriptsBusy(overworld)
|
||||
return running(overworld and overworld.runner)
|
||||
or nonempty(overworld and overworld.parallelRunners)
|
||||
or nonempty(overworld and overworld.pendingScripts)
|
||||
or nonempty(overworld and overworld.parallelQueue)
|
||||
or nonempty(overworld and overworld.scriptMoves)
|
||||
end
|
||||
|
||||
function BattleSafety.inspect(game, battle)
|
||||
if type(game) ~= "table" or type(game.save) ~= "table"
|
||||
or type(game.save.version) ~= "string" then
|
||||
return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough."
|
||||
end
|
||||
if type(battle) ~= "table" then
|
||||
return nil, "not_battle", "No battle is active."
|
||||
end
|
||||
if battle.kind == "link" then
|
||||
return nil, "link_battle_unsupported", "Network battles cannot be checkpointed."
|
||||
end
|
||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then
|
||||
return nil, "battle_variant_unsupported",
|
||||
"This battle variant does not have a checkpoint contract."
|
||||
end
|
||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||
return nil, "battle_variant_unsupported",
|
||||
"This battle kind does not have a checkpoint contract."
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
local scriptedOrigin = type(origin) == "table"
|
||||
and origin.kind == "script_battle"
|
||||
if type(origin) ~= "table"
|
||||
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely."
|
||||
end
|
||||
local overworld = game.overworld or {}
|
||||
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
||||
or (overworld.runner
|
||||
and overworld.runner.isCheckpointBattle
|
||||
and overworld.runner:isCheckpointBattle(battle)))
|
||||
local otherScriptWork = nonempty(overworld.parallelRunners)
|
||||
or nonempty(overworld.pendingScripts) or nonempty(overworld.parallelQueue)
|
||||
or nonempty(overworld.scriptMoves)
|
||||
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
||||
or (not scriptedOrigin and scriptsBusy(overworld)) then
|
||||
return nil, "script_busy", "A suspended or queued script cannot be checkpointed."
|
||||
end
|
||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||
return nil, "battle_phase_busy",
|
||||
"Wait for the player command menu before creating a checkpoint."
|
||||
end
|
||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||
if battle[field] ~= nil and battle[field] ~= false then
|
||||
return nil, "battle_phase_busy", "Wait for the current battle action to finish."
|
||||
end
|
||||
end
|
||||
if not battle.player or not battle.enemy or not battle.player.mon
|
||||
or battle.player.mon.hp <= 0
|
||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||
return nil, "battle_phase_busy",
|
||||
"Wait for a supported player decision before creating a checkpoint."
|
||||
end
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
if not battler.mon or battler.shownHP ~= battler.mon.hp
|
||||
or battler.shownStatus ~= battler.mon.status
|
||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||
or battler.faintQueued then
|
||||
return nil, "battle_phase_busy",
|
||||
"Wait for battle status and HP presentation to settle."
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return BattleSafety
|
||||
+63
-10
@@ -21,12 +21,14 @@ local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local BattleSafety = require("src.battle.BattleSafety")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Status = require("src.battle.Status")
|
||||
local Timing = require("src.core.Timing")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local UIVisibility = require("src.battle.UIVisibility")
|
||||
local RomText = require("src.core.RomText")
|
||||
local Strings = require("src.core.Strings")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
@@ -134,9 +136,7 @@ function BattleState:sgbPalettes()
|
||||
end
|
||||
|
||||
function BattleState:bottomUIVisible()
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) ~= false
|
||||
return UIVisibility.bottomVisible(self, true)
|
||||
end
|
||||
|
||||
function BattleState:statusHUDVisible()
|
||||
@@ -145,6 +145,17 @@ function BattleState:statusHUDVisible()
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
function BattleState:caughtMarkerVisible()
|
||||
local dex = self.game and self.game.save and self.game.save.pokedex
|
||||
if not self.enemy or (self.kind ~= "wild" and self.kind ~= "safari")
|
||||
or not (dex and dex.owned and dex.owned[self.enemy.mon.species]) then
|
||||
return false
|
||||
end
|
||||
if not Runtime.wantsHook("battle.caught_marker_visible") then return false end
|
||||
return Runtime.call("battle.caught_marker_visible",
|
||||
function() return false end, self) == true
|
||||
end
|
||||
|
||||
function BattleState:moveGridNavigation()
|
||||
if self:wideLayout() then return true end
|
||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||
@@ -1871,9 +1882,18 @@ function BattleState:update(dt)
|
||||
-- its own has no slide to wait for.
|
||||
if (self.introSlide or 0) > 0 then return end
|
||||
if not self:updateQueue() then
|
||||
if self.afterQueue == "menu" then
|
||||
local destination = self.afterQueue
|
||||
-- These fields are queue/presentation cursors, not durable battle
|
||||
-- state. Once the queue has drained, keeping their terminal values
|
||||
-- makes the real command menu look busy to BattleSafety even though
|
||||
-- every message, wait and intro animation has settled.
|
||||
self.afterQueue = nil
|
||||
self.nextInsert = nil
|
||||
self.waitFrames = nil
|
||||
if destination == "menu" then
|
||||
self.introSlide = nil
|
||||
self.phase = "menu"
|
||||
elseif self.afterQueue == "finish" then
|
||||
elseif destination == "finish" then
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
@@ -1942,6 +1962,17 @@ function BattleState:update(dt)
|
||||
self:resolveTurn(locked)
|
||||
return
|
||||
end
|
||||
-- START has no vanilla action at a settled supported player-decision
|
||||
-- boundary. A tool mod may claim this semantic auxiliary action through
|
||||
-- the public hook, receiving only game plus a data-only kind. The shared
|
||||
-- safety predicate keeps every unsupported/forced/animated phase inert.
|
||||
if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then
|
||||
local safe = BattleSafety.inspect(self.game, self)
|
||||
if safe and Runtime.call("battle.menu_auxiliary", function() return false end,
|
||||
self.game, { kind = self.kind }) == true then
|
||||
return
|
||||
end
|
||||
end
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") then
|
||||
@@ -2194,8 +2225,13 @@ function BattleState:openOldManBag()
|
||||
self.afterQueue = "menu"
|
||||
self:ui(function()
|
||||
local list
|
||||
-- The canned bag (POKE_BALL, not read from the player's real
|
||||
-- inventory) differs by version: pokered's OldManItemList has 50
|
||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||
-- the Viridian tutorial and Oak's catch, has one.
|
||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||
list = ListMenu.new(game, "ITEMS", {
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = "x50" },
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||
}, {
|
||||
script = function(l)
|
||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||
@@ -4779,7 +4815,7 @@ end
|
||||
-- Party pokeball row (SetupPokeballs tiles: ball / status ball /
|
||||
-- fainted ball / empty), 6 slots stepping dx from (x,y).
|
||||
local ballQuads
|
||||
function BattleState:drawBallRow(party, x, y, dx)
|
||||
local function balls()
|
||||
if ballQuads == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
|
||||
if ok then
|
||||
@@ -4791,11 +4827,23 @@ function BattleState:drawBallRow(party, x, y, dx)
|
||||
ballQuads = false
|
||||
end
|
||||
end
|
||||
if not ballQuads then return end
|
||||
return ballQuads or nil
|
||||
end
|
||||
|
||||
function BattleState:drawCaughtBall(x, y)
|
||||
local quads = balls()
|
||||
if not quads then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(quads.img, quads[0], x, y)
|
||||
end
|
||||
|
||||
function BattleState:drawBallRow(party, x, y, dx)
|
||||
local quads = balls()
|
||||
if not quads then return end
|
||||
for i = 1, 6 do
|
||||
local mon = party[i]
|
||||
local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0
|
||||
love.graphics.draw(ballQuads.img, ballQuads[tile], x + (i - 1) * dx, y)
|
||||
love.graphics.draw(quads.img, quads[tile], x + (i - 1) * dx, y)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5511,7 +5559,12 @@ function BattleState:drawHUDs(slide)
|
||||
love.graphics.translate(hudShake, 0)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
|
||||
local enemyNameX = nameX(1, self.enemy.name)
|
||||
local enemyNameWidth = Font.draw(self.enemy.name, enemyNameX, 0)
|
||||
if self:caughtMarkerVisible() then
|
||||
self:drawCaughtBall(enemyNameX + enemyNameWidth, 0)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if self.enemy.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||
else
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Shared visibility rules for battle-owned UI states. Text and choice
|
||||
-- overlays live above BattleState on the state stack, but they are still part
|
||||
-- of its bottom UI layer and must inherit that layer's visibility.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local UIVisibility = {}
|
||||
|
||||
local function enclosingBattle(state)
|
||||
local stack = state and state.game and state.game.stack
|
||||
local states = stack and stack.states
|
||||
local found = false
|
||||
for i = #(states or {}), 1, -1 do
|
||||
local candidate = states[i]
|
||||
if candidate == state then found = true end
|
||||
if found and candidate and candidate.isBattle then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- queryState keeps the existing TextBox contract: a mod may still decide
|
||||
-- visibility for that individual box. ChoiceBox only inherits the enclosing
|
||||
-- battle decision, so field YES/NO prompts never become battle-hook states.
|
||||
function UIVisibility.bottomVisible(state, queryState)
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
local battle = enclosingBattle(state)
|
||||
if battle and battle ~= state
|
||||
and Runtime.call("battle.bottom_ui_visible",
|
||||
function() return true end, battle) == false then
|
||||
return false
|
||||
end
|
||||
if queryState or battle == state then
|
||||
return Runtime.call("battle.bottom_ui_visible",
|
||||
function() return true end, state) ~= false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return UIVisibility
|
||||
@@ -217,7 +217,7 @@ function Battle.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Battle)
|
||||
self.data = opts.data or {}
|
||||
self.random = opts.random
|
||||
self.random = opts.random or function(n) return rand(nil, n) end
|
||||
self.party = opts.party or {}
|
||||
self.trainer = opts.trainer
|
||||
self.save = opts.save
|
||||
@@ -271,6 +271,13 @@ function Battle.new(opts)
|
||||
self.enemy = self.enemyParty[self.enemyIndex]
|
||||
end
|
||||
|
||||
for _, mon in ipairs(self.party) do
|
||||
Mon.refreshStats(mon, self.data)
|
||||
end
|
||||
for _, mon in ipairs(self.enemyParty or {}) do
|
||||
Mon.refreshStats(mon, self.data)
|
||||
end
|
||||
|
||||
-- Battle RAM opens empty on both sides: NewBattleMonStatus and
|
||||
-- NewEnemyMonStatus run at the first send-out of every battle.
|
||||
self:clearAllVolatiles()
|
||||
@@ -1246,14 +1253,14 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
||||
return damage
|
||||
end
|
||||
|
||||
function Battle:heal(mon, amount)
|
||||
function Battle:heal(mon, amount, opts)
|
||||
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
|
||||
local before = mon.hp or 0
|
||||
mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0)))
|
||||
local healed = mon.hp - before
|
||||
if healed > 0 then
|
||||
self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed,
|
||||
hp = mon.hp })
|
||||
hp = mon.hp, anim = opts and opts.anim })
|
||||
end
|
||||
return healed
|
||||
end
|
||||
@@ -1655,7 +1662,7 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
state.rolloutLock = nil
|
||||
end
|
||||
|
||||
local hits = Effects.hitCount(def.effect, self.random)
|
||||
local hits = Effects.hitCount(def.effect, self:roller())
|
||||
local landed = 0
|
||||
for hit = 1, hits do
|
||||
if (defender.hp or 0) <= 0 then break end
|
||||
@@ -2600,6 +2607,35 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
||||
self.forcedSwitch = true
|
||||
end
|
||||
|
||||
-- BattleCommand_Teleport (engine/battle/move_effects/teleport.asm). Fails
|
||||
-- outright for BATTLETYPE_FORCESHINY/TRAP, for a trapped user, and in any
|
||||
-- TRAINER battle; in a WILD battle the level ladder is identical to
|
||||
-- EFFECT_FORCE_SWITCH's. Without an entry here TELEPORT fell through to
|
||||
-- the (0-power) damage path and never ended the battle.
|
||||
Battle.MOVE_EFFECTS.EFFECT_TELEPORT = function(self, attacker, defender)
|
||||
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
||||
or self.battleType == Battle.BATTLETYPE_TRAP
|
||||
or self:volatile(defender).trapsTarget then
|
||||
return fail(self)
|
||||
end
|
||||
if not self.wild then return fail(self) end
|
||||
|
||||
local userLevel = attacker.level or 1
|
||||
local targetLevel = defender.level or 1
|
||||
local succeeds = userLevel >= targetLevel
|
||||
if not succeeds then
|
||||
local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1))
|
||||
succeeds = roll >= math.floor(targetLevel / 4)
|
||||
end
|
||||
if not succeeds then return fail(self) end
|
||||
|
||||
self.over = true
|
||||
self.outcome = "fled"
|
||||
self.forcedSwitch = true
|
||||
self:emit({ kind = "run", side = self:sideOf(attacker),
|
||||
text = self:monName(attacker) .. " fled from battle!" })
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------- the move effects
|
||||
--
|
||||
-- The three tables above as records, in the shape src/mods/Schemas.lua's
|
||||
@@ -3149,7 +3185,8 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
-- ChangeHappiness is outside the level loop.
|
||||
Happiness.change(mon, "GAINLEVEL")
|
||||
self:emit({ kind = "level", index = index, level = mon.level,
|
||||
text = self:monName(mon) .. " grew to level " .. mon.level .. "!" })
|
||||
text = self:monName(mon) .. " grew to level " .. mon.level .. "!",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true })
|
||||
for _, moveId in ipairs(result.learned) do
|
||||
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
|
||||
local moveDef = self:moveDef(moveId)
|
||||
@@ -3615,7 +3652,7 @@ end
|
||||
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
||||
-- based on the speed ratio and how many times you have tried this battle.
|
||||
-- Trainers never let you run.
|
||||
function Battle:tryRun()
|
||||
function Battle:tryRun(pSpd)
|
||||
-- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone,
|
||||
-- which is what BattleMenu_Run reads to decide whether the turn was spent
|
||||
-- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the
|
||||
@@ -3647,7 +3684,8 @@ function Battle:tryRun()
|
||||
return false
|
||||
end
|
||||
self.runAttempts = (self.runAttempts or 0) + 1
|
||||
if self:runRoll(self:effectiveSpeed(self.player),
|
||||
-- engine/battle/core.asm:2614
|
||||
if self:runRoll(pSpd or self:effectiveSpeed(self.player),
|
||||
self:effectiveSpeed(self.enemy)) then
|
||||
self:emit({ kind = "run", text = "Got away safely!" })
|
||||
self:endBattle("run")
|
||||
@@ -4442,7 +4480,8 @@ function Battle:tickHeldItem(mon)
|
||||
end
|
||||
|
||||
if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then
|
||||
self:heal(mon, parameter > 0 and parameter or 10)
|
||||
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
|
||||
self:heal(mon, parameter > 0 and parameter or 10, { anim = "RECOVER" })
|
||||
mon.item = nil
|
||||
self:emit({ kind = "message",
|
||||
text = name .. " ate the " .. (def.name or "BERRY") .. "!" })
|
||||
|
||||
@@ -100,21 +100,28 @@ end
|
||||
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
|
||||
-- fallthrough in .DetermineNumberOfHits produces.
|
||||
function Effects.multiHitCount(random)
|
||||
local roll = random and random(4) or 0
|
||||
if roll < 2 then return roll + 2 end
|
||||
-- Hits 4 and 5 take a second roll, so each ends up half as likely.
|
||||
local second = random and random(2) or 0
|
||||
return second + 4
|
||||
local function roll(n)
|
||||
if random then return random(n) end
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(n) - 1
|
||||
end
|
||||
return math.random(n) - 1
|
||||
end
|
||||
-- engine/battle/effect_commands.asm:5228
|
||||
local first = roll(4)
|
||||
if first < 2 then return first + 2 end
|
||||
return roll(4) + 2
|
||||
end
|
||||
|
||||
Effects.HIT_COUNTS = {
|
||||
EFFECT_DOUBLE_HIT = 2,
|
||||
EFFECT_POISON_MULTI_HIT = 2,
|
||||
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
|
||||
EFFECT_TRIPLE_KICK = 3,
|
||||
}
|
||||
|
||||
function Effects.hitCount(effect, random)
|
||||
if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then
|
||||
if effect == "EFFECT_MULTI_HIT" then
|
||||
return Effects.multiHitCount(random)
|
||||
end
|
||||
return Effects.HIT_COUNTS[effect] or 1
|
||||
|
||||
+26
-3
@@ -68,7 +68,16 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
baseStats = baseStats or {}
|
||||
dvs = dvs or {}
|
||||
statExp = statExp or {}
|
||||
local hpDv = dvs.hp or Mon.hpDV(dvs)
|
||||
-- engine/pokemon/move_mon.asm:1540
|
||||
local specialDv = dvs.special
|
||||
if specialDv == nil then
|
||||
specialDv = dvs.specialAttack or dvs.specialDefense
|
||||
end
|
||||
-- engine/pokemon/move_mon.asm:1496
|
||||
local hpDv = Mon.hpDV({
|
||||
attack = dvs.attack, defense = dvs.defense,
|
||||
speed = dvs.speed, special = specialDv,
|
||||
})
|
||||
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
|
||||
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
|
||||
+ level + 10
|
||||
@@ -82,13 +91,27 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
|
||||
-- per-stat keys are still read as a fallback for a record written before
|
||||
-- the shared word existed.
|
||||
specialAttack = statValue(baseStats.specialAttack, dvs.special, level,
|
||||
specialAttack = statValue(baseStats.specialAttack, specialDv, level,
|
||||
statExp.special or statExp.specialAttack),
|
||||
specialDefense = statValue(baseStats.specialDefense, dvs.special, level,
|
||||
specialDefense = statValue(baseStats.specialDefense, specialDv, level,
|
||||
statExp.special or statExp.specialDefense),
|
||||
}
|
||||
end
|
||||
|
||||
function Mon.refreshStats(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not (def and def.baseStats) then return mon end
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||
mon.stats = stats
|
||||
mon.maxHp = stats.hp
|
||||
if mon.hp == nil or mon.hp > stats.hp then
|
||||
mon.hp = stats.hp
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
|
||||
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
local BattleCheckpoint = {}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local BUILTIN_RULESETS = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
@@ -158,7 +159,9 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
end
|
||||
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
||||
local scripted = model.origin.kind == "script_battle"
|
||||
if not expectedOrigin
|
||||
or (model.origin.kind ~= expectedOrigin and not scripted)
|
||||
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Battle continuation data is unsupported or inconsistent."
|
||||
@@ -167,7 +170,27 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||
end
|
||||
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
if scripted then
|
||||
local origin = model.origin
|
||||
local row = type(origin.script) == "table" and origin.script[origin.pc]
|
||||
local allowed = { start_battle = true, static_battle = true, rival_battle = true }
|
||||
if type(origin.pc) ~= "number" or origin.pc % 1 ~= 0
|
||||
or type(row) ~= "table" or row[1] ~= origin.command
|
||||
or not allowed[origin.command] or origin.battleKind ~= model.kind
|
||||
or (model.kind == "trainer" and (origin.trainerClass ~= model.oppClass
|
||||
or origin.partyIndex ~= (model.partyIndex or 1)))
|
||||
or (model.kind == "wild" and (origin.wildSpecies ~= model.enemyMon.species
|
||||
or origin.wildLevel ~= model.enemyMon.level))
|
||||
or (origin.npcId ~= nil and type(origin.npcId) ~= "string") then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Script battle continuation data is incomplete or inconsistent."
|
||||
end
|
||||
local problems = ScriptRunner.validate(origin.script)
|
||||
if #problems > 0 then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Script battle continuation commands are unavailable."
|
||||
end
|
||||
elseif model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
or model.origin.trainerClass ~= model.oppClass
|
||||
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
|
||||
+135
-65
@@ -7,6 +7,7 @@ local Version = require("src.core.Version")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local BattleSafety = require("src.battle.BattleSafety")
|
||||
|
||||
local Checkpoint = {}
|
||||
|
||||
@@ -36,61 +37,9 @@ local function scriptsBusy(ow)
|
||||
or nonempty(ow.scriptMoves)
|
||||
end
|
||||
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
local function inspectBattle(ow, battle)
|
||||
if battle.kind == "link" then
|
||||
return refusal("battle", "link_battle_unsupported",
|
||||
"Network battles cannot be checkpointed.")
|
||||
end
|
||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
||||
or battle.noCatch then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle variant does not have a checkpoint contract.")
|
||||
end
|
||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle kind does not have a checkpoint contract.")
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
|
||||
return refusal("battle", "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely.")
|
||||
end
|
||||
if scriptsBusy(ow) then
|
||||
return refusal("battle", "script_busy",
|
||||
"A suspended or queued script cannot be checkpointed.")
|
||||
end
|
||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the player command menu before creating a checkpoint.")
|
||||
end
|
||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||
if battle[field] ~= nil and battle[field] ~= false then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the current battle action to finish.")
|
||||
end
|
||||
end
|
||||
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for an ordinary player decision before creating a checkpoint.")
|
||||
end
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
if battler.shownHP ~= battler.mon.hp
|
||||
or battler.shownStatus ~= battler.mon.status
|
||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||
or battler.faintQueued then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for battle status and HP presentation to settle.")
|
||||
end
|
||||
end
|
||||
local function inspectBattle(game, battle)
|
||||
local allowed, reason, message = BattleSafety.inspect(game, battle)
|
||||
if not allowed then return refusal("battle", reason, message) end
|
||||
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||
end
|
||||
|
||||
@@ -109,7 +58,7 @@ function Checkpoint.inspect(game)
|
||||
end
|
||||
local top = game.stack and game.stack.top and game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
return inspectBattle(ow, top)
|
||||
return inspectBattle(game, top)
|
||||
end
|
||||
if top ~= ow then
|
||||
return refusal("overworld", "screen_busy",
|
||||
@@ -246,7 +195,7 @@ end
|
||||
|
||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function validate(game, checkpoint)
|
||||
local function validate(game, checkpoint, expectedIdentity)
|
||||
if type(checkpoint) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||
end
|
||||
@@ -264,13 +213,16 @@ local function validate(game, checkpoint)
|
||||
end
|
||||
local identity = copy.identity
|
||||
local current = game and game.save
|
||||
local currentId = current and current.meta and current.meta.playthroughId
|
||||
local currentId = expectedIdentity and expectedIdentity.playthroughId
|
||||
or (current and current.meta and current.meta.playthroughId)
|
||||
local currentVersion = expectedIdentity and expectedIdentity.gameVersion
|
||||
or (current and current.version)
|
||||
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||
or type(identity.gameVersion) ~= "string"
|
||||
or type(identity.playthroughId) ~= "string" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||
end
|
||||
if identity.gameVersion ~= current.version then
|
||||
if identity.gameVersion ~= currentVersion then
|
||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||
end
|
||||
if identity.playthroughId ~= currentId then
|
||||
@@ -383,6 +335,49 @@ local function firstDifference(a, b, path)
|
||||
return nil
|
||||
end
|
||||
|
||||
local emitRestored
|
||||
|
||||
-- Persist the current verified checkpoint as the ordinary progress anchor only
|
||||
-- when this playthrough has never had one. This is intentionally idempotent:
|
||||
-- durable checkpoint tools can make a first session resumable without turning
|
||||
-- every later checkpoint into a hidden normal SAVE. The live runtime must still
|
||||
-- match the supplied checkpoint, and the ordinary save.write veto/lifecycle
|
||||
-- remains authoritative through Game:writeSave().
|
||||
function Checkpoint.ensureNormalSave(game, checkpoint, injectedFs)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canCapture then
|
||||
return false, capability.reason, capability.message
|
||||
end
|
||||
local validated, code, message = validate(game, checkpoint)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local info, infoCode, infoMessage =
|
||||
SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||
if not info then return false, infoCode, infoMessage end
|
||||
if info.exists then return true, "already_exists" end
|
||||
|
||||
local current, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
if not current then return false, captureCode, captureMessage end
|
||||
if not equalData(current, validated) then
|
||||
return false, "checkpoint_not_current",
|
||||
"The active runtime changed after this checkpoint was captured."
|
||||
end
|
||||
if type(game.writeSave) ~= "function" then
|
||||
return false, "save_unavailable",
|
||||
"The active runtime cannot persist ordinary progress."
|
||||
end
|
||||
local ok, saved = pcall(game.writeSave, game)
|
||||
if not ok or saved == false then
|
||||
return false, "save_failed", "Could not create the first ordinary progress save."
|
||||
end
|
||||
local verified = SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||
if type(verified) ~= "table" or not verified.exists then
|
||||
return false, "save_verify_failed",
|
||||
"The first ordinary progress save could not be verified."
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Checkpoint.restore(game, checkpoint)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canRestore then
|
||||
@@ -400,12 +395,7 @@ function Checkpoint.restore(game, checkpoint)
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then
|
||||
if ModRuntime.wants("checkpoint.restored") then
|
||||
ModRuntime.emit("checkpoint.restored", {
|
||||
game = game,
|
||||
kind = validated.kind,
|
||||
})
|
||||
end
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
end
|
||||
err = restored and ("restored state differed at "
|
||||
@@ -421,4 +411,84 @@ function Checkpoint.restore(game, checkpoint)
|
||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
emitRestored = function(game, checkpoint)
|
||||
if ModRuntime.wants("checkpoint.restored") then
|
||||
ModRuntime.emit("checkpoint.restored", {
|
||||
game = game,
|
||||
kind = checkpoint.kind,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function isTitleSession(game)
|
||||
local states = game and game.stack and game.stack.states
|
||||
if type(states) ~= "table" then return false end
|
||||
for _, state in ipairs(states) do
|
||||
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function rebuildTitle(game, savedTitle, rng)
|
||||
local save, err = dataCopy(savedTitle)
|
||||
if not save then error("title rollback decode failed: " .. tostring(err), 0) end
|
||||
game.save = save
|
||||
if type(game.adoptSave) == "function" then game:adoptSave(save) end
|
||||
restoreRng(rng)
|
||||
if not (game.stack and game.stack.top and game.stack.pop and game.stack.push
|
||||
and type(game.makeTitleState) == "function") then
|
||||
error("title recovery is unavailable", 0)
|
||||
end
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(game:makeTitleState())
|
||||
end
|
||||
|
||||
-- Reconstruct a validated persistent checkpoint from the title session. This
|
||||
-- is intentionally separate from restore(): title has no live gameplay state
|
||||
-- to capture for rollback. Validation happens before any mutation; a failed
|
||||
-- reconstruction rebuilds a fresh usable title session instead of exposing a
|
||||
-- half-installed overworld or battle.
|
||||
function Checkpoint.resume(game, checkpoint)
|
||||
if not isTitleSession(game) then
|
||||
return false, "not_at_title",
|
||||
"Checkpoint resume is available only from the title session."
|
||||
end
|
||||
local save = game and game.save
|
||||
local playthroughId, identityCode, identityMessage =
|
||||
SaveData.selectedPlaythroughId(save)
|
||||
if type(playthroughId) ~= "string" or playthroughId == "" then
|
||||
return false, identityCode, identityMessage
|
||||
end
|
||||
local expected = { gameVersion = save and save.version, playthroughId = playthroughId }
|
||||
local validated, code, message = validate(game, checkpoint, expected)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local titleSave, titleErr = dataCopy(save)
|
||||
if not titleSave then
|
||||
return false, "title_recovery_unavailable",
|
||||
"Could not preserve the title session: " .. tostring(titleErr)
|
||||
end
|
||||
local titleRng = captureRng()
|
||||
local currentOptions = save.options
|
||||
local ok, err = pcall(apply, game, validated, currentOptions)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
end
|
||||
err = restored and ("resumed state differed at "
|
||||
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||
or ("resumed state could not be captured: " .. tostring(verifyCode))
|
||||
end
|
||||
|
||||
local recovered, recoveryErr = pcall(rebuildTitle, game, titleSave, titleRng)
|
||||
if not recovered then
|
||||
return false, "title_recovery_failed",
|
||||
"Checkpoint resume failed and title recovery failed: " .. tostring(recoveryErr)
|
||||
end
|
||||
return false, "resume_failed", "Checkpoint resume failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
return Checkpoint
|
||||
|
||||
+13
-2
@@ -203,7 +203,8 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||
allowLoops = allowLoops, audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches() })
|
||||
channelPitches = ChipSynth.getChannelPitches(),
|
||||
stereo = ChipSynth.getStereo() })
|
||||
currentMusic = { source = source, gen = gen, threaded = true,
|
||||
started = false, finished = false }
|
||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||
@@ -214,7 +215,8 @@ local function pushChannelMix()
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
volumes = ChipSynth.getChannelVolumes(),
|
||||
pitches = ChipSynth.getChannelPitches() })
|
||||
pitches = ChipSynth.getChannelPitches(),
|
||||
stereo = ChipSynth.getStereo() })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -352,6 +354,15 @@ function ChipAudio.shutdown()
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
function ChipAudio.setStereo(enabled)
|
||||
ChipSynth.setStereo(enabled)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getStereo()
|
||||
return ChipSynth.getStereo()
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
|
||||
+19
-4
@@ -29,6 +29,18 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
-- Gen 2 SOUND option (MONO/STEREO): gates Music_StereoPanning's per-song
|
||||
-- panning byte (audio/engine.asm:1987 wOptions STEREO bit).
|
||||
local stereoEnabled = false
|
||||
|
||||
function ChipSynth.setStereo(enabled)
|
||||
stereoEnabled = not not enabled
|
||||
end
|
||||
|
||||
function ChipSynth.getStereo()
|
||||
return stereoEnabled
|
||||
end
|
||||
|
||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||
@@ -760,11 +772,14 @@ function Channel:nextEventGen2()
|
||||
-- no-op for the PCM renderer
|
||||
elseif command == 0xEE then -- unknownmusic0xee
|
||||
self:word()
|
||||
elseif command == 0xEF then -- stereo_panning (honor always; options.stereo)
|
||||
elseif command == 0xEF then
|
||||
-- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on
|
||||
local packed = self:byte()
|
||||
local mask = bit.lshift(1, self.hardware - 1)
|
||||
local default = bit.bor(bit.lshift(mask, 4), mask)
|
||||
self.tracks = bit.band(packed, default)
|
||||
if stereoEnabled then
|
||||
local mask = bit.lshift(1, self.hardware - 1)
|
||||
local default = bit.bor(bit.lshift(mask, 4), mask)
|
||||
self.tracks = bit.band(packed, default)
|
||||
end
|
||||
elseif command == 0xF0 then -- sfx_toggle_noise
|
||||
if self.noiseSampling then
|
||||
self.noiseSampling = false
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Shared local date/time presentation for engine UI and mods. Preferences
|
||||
-- live in options.lua, never checkpoint progress. "device" uses the process
|
||||
-- time locale when the platform supplies one and otherwise falls back to the
|
||||
-- deterministic DD-MM-YYYY / 24-hour convention.
|
||||
|
||||
local DateTime = {}
|
||||
|
||||
local DATE_FORMATS = {
|
||||
dmy = "%d-%m-%Y",
|
||||
mdy = "%m-%d-%Y",
|
||||
ymd = "%Y-%m-%d",
|
||||
}
|
||||
local TIME_FORMATS = {
|
||||
["24h"] = "%H:%M",
|
||||
["12h"] = "%I:%M %p",
|
||||
}
|
||||
|
||||
local function validTimestamp(value)
|
||||
return type(value) == "number" and value == value and value >= 0
|
||||
and value ~= math.huge and value ~= -math.huge
|
||||
end
|
||||
|
||||
local function render(timestamp, pattern)
|
||||
local ok, value = pcall(os.date, pattern, math.floor(timestamp))
|
||||
if not ok or type(value) ~= "string" or value == "" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function currentLocale()
|
||||
if not os.setlocale then return nil end
|
||||
local ok, value = pcall(os.setlocale, nil, "time")
|
||||
if not ok or type(value) ~= "string" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function deviceAvailable(localeName)
|
||||
return type(localeName) == "string" and localeName ~= ""
|
||||
and localeName ~= "C" and localeName ~= "POSIX"
|
||||
end
|
||||
|
||||
function DateTime.formatWithLocale(timestamp, datePreference, timePreference, localeName)
|
||||
if not validTimestamp(timestamp) then return { date = "----", time = "----" } end
|
||||
local datePattern = DATE_FORMATS[datePreference]
|
||||
local timePattern = TIME_FORMATS[timePreference]
|
||||
if not datePattern then
|
||||
datePattern = deviceAvailable(localeName) and "%x" or DATE_FORMATS.dmy
|
||||
end
|
||||
if not timePattern then
|
||||
if deviceAvailable(localeName) then
|
||||
local sample = render(timestamp, "%X") or ""
|
||||
local marker = render(timestamp, "%p") or ""
|
||||
timePattern = marker ~= "" and sample:find(marker, 1, true)
|
||||
and TIME_FORMATS["12h"] or TIME_FORMATS["24h"]
|
||||
else
|
||||
timePattern = TIME_FORMATS["24h"]
|
||||
end
|
||||
end
|
||||
return {
|
||||
date = render(timestamp, datePattern) or "----",
|
||||
time = render(timestamp, timePattern) or "----",
|
||||
}
|
||||
end
|
||||
|
||||
local function preferences(game)
|
||||
local options = game and game.save and game.save.options
|
||||
options = type(options) == "table" and options or {}
|
||||
return options.dateFormat or "device", options.timeFormat or "device"
|
||||
end
|
||||
|
||||
function DateTime.date(game, timestamp)
|
||||
local datePreference, timePreference = preferences(game)
|
||||
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||
currentLocale()).date
|
||||
end
|
||||
|
||||
function DateTime.time(game, timestamp)
|
||||
local datePreference, timePreference = preferences(game)
|
||||
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||
currentLocale()).time
|
||||
end
|
||||
|
||||
function DateTime.dateTime(game, timestamp)
|
||||
local datePreference, timePreference = preferences(game)
|
||||
local value = DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||
currentLocale())
|
||||
if value.date == "----" or value.time == "----" then return "----" end
|
||||
return value.date .. " " .. value.time
|
||||
end
|
||||
|
||||
return DateTime
|
||||
+19
-6
@@ -159,14 +159,15 @@ function Game:makeTitleState()
|
||||
self:applyOptions(self.save.options)
|
||||
self.stack:push(OverworldState, self.save.player.map,
|
||||
self.save.player.x, self.save.player.y,
|
||||
self.save.player.facing)
|
||||
self.save.player.facing,
|
||||
{ via = "boot", freshBoot = true })
|
||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||
function() end)
|
||||
end,
|
||||
onContinue = function()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
self:restoreSave(loaded, recovered)
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -639,7 +640,12 @@ function Game:keypressed(key)
|
||||
return
|
||||
elseif key == "f2" then
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then self:restoreSave(loaded, recovered) end
|
||||
if loaded then
|
||||
-- F2 jumps straight to the loaded save's map/position, with no
|
||||
-- walking transition -- a hard state teleport like Continue, not a
|
||||
-- smooth warp -- whether pressed at the title screen or mid-session.
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
end
|
||||
return
|
||||
elseif key == "-" then
|
||||
self:zoomStep(-1)
|
||||
@@ -1124,7 +1130,7 @@ function Game:applyOptions(opts)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
if ModRuntime.wants("save.loading") then
|
||||
ModRuntime.emit("save.loading", { raw = loaded })
|
||||
end
|
||||
@@ -1157,8 +1163,12 @@ function Game:restoreSave(loaded, recovered)
|
||||
end
|
||||
-- rebuild the state stack from the save
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||
-- crossfade by default.
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing)
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
-- the report screen is a Screens id so mods (or the ui milestone) own
|
||||
@@ -1187,9 +1197,12 @@ function Game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self:adoptSave(loaded)
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua)
|
||||
-- is this method's only caller, and it is itself gated to the title
|
||||
-- session (isTitleSession).
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "checkpoint", checkpoint = true })
|
||||
{ via = "checkpoint", checkpoint = true, freshBoot = true })
|
||||
end
|
||||
|
||||
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||
|
||||
+30
-39
@@ -109,6 +109,12 @@ local function makeStack()
|
||||
return StateStack
|
||||
end
|
||||
|
||||
local function visibleBaseState(stack)
|
||||
if not stack then return nil end
|
||||
local state = stack.states[stack:visibleBase()]
|
||||
return stack:renderVisible(state) and state or nil
|
||||
end
|
||||
|
||||
local function loadGenerated(path)
|
||||
local chunk = love.filesystem.load(path)
|
||||
if not chunk then return nil end
|
||||
@@ -140,7 +146,7 @@ Game2.anchorNewGameClock = anchorNewGameClock
|
||||
|
||||
function Game2.new()
|
||||
local self = setmetatable({
|
||||
speedOverride = 1,
|
||||
speedOverride = nil,
|
||||
capturePath = nil,
|
||||
world = nil,
|
||||
status = nil,
|
||||
@@ -975,27 +981,9 @@ function Game2:load()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
Pipelines.install(self.data)
|
||||
Pipelines.applyOptions(self.options)
|
||||
-- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the
|
||||
-- world half: its overworld draws straight to the window rather than into a
|
||||
-- canvas the way src/world/OverworldController.lua:4827 hands one to
|
||||
-- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet.
|
||||
-- A restored level for a world-only pipeline is retired rather than left
|
||||
-- switched on, because on Gold it would render nothing AND hold TILT off
|
||||
-- (Pipelines.setLevel's tilt exclusion). The stored level in
|
||||
-- options.pipelines is left untouched, so the mode comes back the day Gold
|
||||
-- grows a world canvas; Tilt is re-applied from the option the exclusion just
|
||||
-- cleared.
|
||||
local retired = false
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.drawWorld and not entry.def.present
|
||||
and Pipelines.level(entry.id) > 0 then
|
||||
Pipelines.setLevel(entry.id, 0)
|
||||
retired = true
|
||||
end
|
||||
end
|
||||
if retired then
|
||||
require("src.render.Tilt").applyOptions(self.options)
|
||||
end
|
||||
-- Both halves run on Gold now: `present` folds over the composite in
|
||||
-- Game2:draw, `drawWorld` owns the world pass in World:drawPipeline. So a
|
||||
-- restored world level stays switched on, as it does for Gen 1.
|
||||
|
||||
-- After the merge, so a font override and a translation mod's catalog
|
||||
-- (#501) are both in Data before the first screen draws a glyph. Gen 1
|
||||
@@ -1112,19 +1100,20 @@ function Game2:update(dt)
|
||||
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
|
||||
-- they are presentational, so fast-forward must not speed them up.
|
||||
require("src.render.Pipelines").update(dt)
|
||||
if self.phase == "boot" then
|
||||
FixedStep.maxAccum = 0.25
|
||||
FixedStep:update(dt)
|
||||
return
|
||||
end
|
||||
if not self.world or not self.world.map then return end
|
||||
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
|
||||
-- audio runs off its own real-time accumulator, so music and sfx keep their
|
||||
-- tempo at every multiplier. speedOverride is the driver/CLI hook and wins
|
||||
-- over the saved option.
|
||||
-- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld
|
||||
local speed = math.max(1,
|
||||
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
|
||||
or 1)
|
||||
if self.phase == "boot" then
|
||||
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
||||
FixedStep:update(dt * speed)
|
||||
return
|
||||
end
|
||||
if not self.world or not self.world.map then return end
|
||||
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
||||
FixedStep:update(dt * speed)
|
||||
end
|
||||
@@ -1458,8 +1447,7 @@ end
|
||||
-- them but the Pokegear, whose paper is RGB 28,31,20. Nil means white, which
|
||||
-- is what Font.drawBox does by default.
|
||||
function Game2:textboxPaper()
|
||||
local base = self.stack and self.stack.states
|
||||
and self.stack.states[self.stack:visibleBase()]
|
||||
local base = visibleBaseState(self.stack)
|
||||
if base and base.paperColor then return base:paperColor() end
|
||||
return nil
|
||||
end
|
||||
@@ -1472,10 +1460,11 @@ function Game2:drawScene(w, h)
|
||||
|
||||
if self:inFillBoot() then
|
||||
local top = self.stack:top()
|
||||
local base = self.stack.states[self.stack:visibleBase()]
|
||||
local base = visibleBaseState(self.stack)
|
||||
-- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and
|
||||
-- name pick paint a paper-white surround via drawWidescreen.
|
||||
local wide = (top and top.drawsWidescreen and top:drawsWidescreen()
|
||||
local wide = (self.stack:renderVisible(top)
|
||||
and top.drawsWidescreen and top:drawsWidescreen()
|
||||
and top.drawWidescreen) and top
|
||||
or (base and base.drawsWidescreen and base:drawsWidescreen()
|
||||
and base.drawWidescreen and base)
|
||||
@@ -1524,8 +1513,9 @@ function Game2:drawScene(w, h)
|
||||
-- PARTY, PACK, #DEX, PC, DAY-CARE, MAILBOX and TRADE screens whenever a
|
||||
-- TextBox goes up over them.
|
||||
local top = self.stack:top()
|
||||
local base = self.stack.states[self.stack:visibleBase()]
|
||||
local wide = (top and top.drawsWidescreen and top:drawsWidescreen()
|
||||
local base = visibleBaseState(self.stack)
|
||||
local wide = (self.stack:renderVisible(top)
|
||||
and top.drawsWidescreen and top:drawsWidescreen()
|
||||
and top.drawWidescreen) and top
|
||||
or (base and base.drawsWidescreen and base:drawsWidescreen()
|
||||
and base.drawWidescreen and base)
|
||||
@@ -1921,11 +1911,12 @@ function Game2:applyOptions()
|
||||
-- the mod pipeline ladder rides options.pipelines and restores with the rest
|
||||
-- of the display block, as it does in src/core/Game.lua:1041
|
||||
require("src.render.Pipelines").applyOptions(options)
|
||||
-- options.touchControls (the launcher editor's per-orientation layouts) and
|
||||
-- options.haptics, the same two keys Gen 1 hands over here
|
||||
-- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a
|
||||
-- player laid out for Red is already the pad Gold draws.
|
||||
TouchControls:applyOptions(options)
|
||||
-- src/core/Game.lua:1121 mirrors this call for Gen 1
|
||||
Input:applyBindings(options.bindings)
|
||||
TouchControls:applyOptions({
|
||||
touchControls = options.touchControls,
|
||||
haptics = options.haptics,
|
||||
})
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.applyOptions(options) and self.save then
|
||||
-- applyOptions returns true when it had to clear an unsupported level.
|
||||
|
||||
@@ -119,6 +119,34 @@ function Input:reset()
|
||||
self.stickAxis = { x = 0, y = 0 }
|
||||
self.stickDir = nil
|
||||
self.hatDirs = {}
|
||||
self.captureArmed = false
|
||||
self.captureEvents = nil
|
||||
end
|
||||
|
||||
function Input:armCapture()
|
||||
self.captureArmed = true
|
||||
self.captureEvents = {}
|
||||
end
|
||||
|
||||
function Input:disarmCapture()
|
||||
self.captureArmed = false
|
||||
self.captureEvents = nil
|
||||
end
|
||||
|
||||
function Input:takeCaptureEvents()
|
||||
local ev = self.captureEvents
|
||||
self.captureEvents = self.captureArmed and {} or nil
|
||||
return ev
|
||||
end
|
||||
|
||||
local function noteCapture(self, kind, phase, value)
|
||||
if not self.captureArmed then return end
|
||||
local ev = self.captureEvents
|
||||
if not ev then
|
||||
ev = {}
|
||||
self.captureEvents = ev
|
||||
end
|
||||
ev[#ev + 1] = { kind = kind, phase = phase, value = value }
|
||||
end
|
||||
|
||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||
@@ -154,6 +182,7 @@ local function release(self, btn, source)
|
||||
end
|
||||
|
||||
function Input:keypressed(key)
|
||||
noteCapture(self, "key", "pressed", key)
|
||||
local btn = self.keyBindings[key]
|
||||
if btn then
|
||||
press(self, btn, "key:" .. key)
|
||||
@@ -161,6 +190,7 @@ function Input:keypressed(key)
|
||||
end
|
||||
|
||||
function Input:keyreleased(key)
|
||||
noteCapture(self, "key", "released", key)
|
||||
local btn = self.keyBindings[key]
|
||||
if btn then
|
||||
release(self, btn, "key:" .. key)
|
||||
@@ -221,6 +251,7 @@ function Input:sourceRelease(btn, source)
|
||||
end
|
||||
|
||||
function Input:gamepadpressed(joystick, button)
|
||||
noteCapture(self, "pad", "pressed", button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
press(self, btn, "pad:" .. button)
|
||||
@@ -228,6 +259,7 @@ function Input:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Input:gamepadreleased(joystick, button)
|
||||
noteCapture(self, "pad", "released", button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
release(self, btn, "pad:" .. button)
|
||||
@@ -249,12 +281,14 @@ end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
noteCapture(self, "joy", "pressed", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
noteCapture(self, "joy", "released", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
@@ -460,6 +460,8 @@ end
|
||||
function Music.applyOptions(opts)
|
||||
Music.setVolumeLevel(opts and opts.musicVol or 7)
|
||||
Music.setFilterLevel(opts and opts.musicFilter or 0)
|
||||
-- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit)
|
||||
require("src.core.ChipAudio").setStereo(opts and opts.sound == "STEREO")
|
||||
end
|
||||
|
||||
local function sourceStopped(src)
|
||||
|
||||
+87
-3
@@ -341,6 +341,12 @@ function SaveData.defaultOptions()
|
||||
-- gets the tick without going looking for the row. Inert wherever the
|
||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
||||
haptics = "light",
|
||||
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
|
||||
-- locale where the platform exposes one; otherwise DateTime falls back to
|
||||
-- DD-MM-YYYY and 24-hour time. Kept in options.lua so checkpoints never
|
||||
-- rewind presentation preferences.
|
||||
dateFormat = "device", -- device | dmy | mdy | ymd
|
||||
timeFormat = "device", -- device | 24h | 12h
|
||||
}
|
||||
end
|
||||
|
||||
@@ -760,6 +766,15 @@ local function tryMigrateLegacy(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = { id }, active = id }
|
||||
-- A tool may have allocated the legacy scope before the player made their
|
||||
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
|
||||
-- same opaque identity; otherwise title-selected mod storage becomes
|
||||
-- unreachable after the migration even though every durable record exists.
|
||||
local ids = opts.playthroughIds and opts.playthroughIds[version]
|
||||
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
|
||||
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
|
||||
ids.legacy = nil
|
||||
end
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return id
|
||||
end
|
||||
@@ -785,9 +800,9 @@ end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||
function saveNames(version)
|
||||
function saveNames(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local fs = persistFs(nil)
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
local slot = activeSlotCache[version]
|
||||
if slot then return slotNames(version, slot) end
|
||||
@@ -1075,7 +1090,17 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
if type(id) ~= "string" or id == "" then return opts, false end
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
opts = opts or SaveData.loadOptions(injectedFs)
|
||||
local persisted = SaveData.loadOptions(injectedFs)
|
||||
if opts then
|
||||
-- Slot selection and opaque playthrough routing are engine-owned launcher
|
||||
-- state. A live game may carry an options snapshot from before a legacy
|
||||
-- save was promoted to slot1; writing that stale snapshot must not erase
|
||||
-- the freshly persisted routing and strand tool storage on next boot.
|
||||
opts.saveSlots = deepCopy(persisted.saveSlots)
|
||||
opts.playthroughIds = deepCopy(persisted.playthroughIds)
|
||||
else
|
||||
opts = persisted
|
||||
end
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
local changed = opts.playthroughIds[version][scope] ~= id
|
||||
@@ -1110,6 +1135,65 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Resolve the already-selected playthrough without changing the supplied save
|
||||
-- or allocating a replacement id. Title tools use this before a normal SAVE:
|
||||
-- Game:load intentionally owns a fresh skeleton there, while the selected
|
||||
-- launcher slot's durable tool data remains bound by the engine-owned mapping.
|
||||
-- This does not make arbitrary identities addressable; callers still need a
|
||||
-- higher-level engine capability that decides when selected resolution is safe.
|
||||
function SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
if type(save) ~= "table" then
|
||||
return nil, "not_in_playthrough", "No selected playthrough is available."
|
||||
end
|
||||
local version = save.version or GameVersion.get()
|
||||
if not knownVersion(version) then
|
||||
return nil, "unknown_game", "The selected game version is unavailable."
|
||||
end
|
||||
local id = save.meta and save.meta.playthroughId
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
-- Resolve the selected scope first. That may perform the one-time legacy
|
||||
-- save-to-slot migration, which also moves the opaque identity mapping; only
|
||||
-- then read options so this lookup never observes the pre-migration table.
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
id = byVersion and byVersion[scope] or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "no_selected_playthrough",
|
||||
"The selected playthrough has no durable tool state."
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
-- Read only the chronology of the ordinary selected save for title tools.
|
||||
-- This intentionally returns no canonical progress, slot id, path, or raw
|
||||
-- save handle. A legacy pre-id normal save is valid when the selected scope's
|
||||
-- engine-owned mapping identifies it; a stamped id must match exactly.
|
||||
function SaveData.selectedNormalSaveInfo(save, injectedFs)
|
||||
local playthroughId, code, message = SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
if not playthroughId then return nil, code, message end
|
||||
local version = save and (save.version or GameVersion.get())
|
||||
local fs = persistFs(injectedFs)
|
||||
local main, backup, staged = saveNames(version, injectedFs)
|
||||
local normal = readTable(fs, main)
|
||||
or readTable(fs, staged)
|
||||
or readTable(fs, backup)
|
||||
if type(normal) ~= "table" or normal.version ~= version then
|
||||
return { exists = false, savedAt = nil }
|
||||
end
|
||||
local normalId = normal.meta and normal.meta.playthroughId
|
||||
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
|
||||
return { exists = false, savedAt = nil }
|
||||
end
|
||||
local savedAt = normal.meta and normal.meta.savedAt
|
||||
if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt
|
||||
or savedAt == math.huge or savedAt == -math.huge then
|
||||
savedAt = nil
|
||||
end
|
||||
return { exists = true, savedAt = savedAt }
|
||||
end
|
||||
|
||||
-- ------- meta
|
||||
|
||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||
|
||||
@@ -211,6 +211,10 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion.isGold and GameVersion.isGold() then
|
||||
margin = math.max(margin, math.min(ww * 0.10, 72))
|
||||
end
|
||||
return {
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
|
||||
@@ -53,6 +53,9 @@ local function handle(cmd)
|
||||
if cmd.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||
end
|
||||
if cmd.stereo ~= nil then
|
||||
ChipSynth.setStereo(cmd.stereo)
|
||||
end
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
@@ -69,6 +72,7 @@ local function handle(cmd)
|
||||
elseif cmd.cmd == "channelMix" then
|
||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||
if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
|
||||
@@ -96,7 +96,7 @@ function Nests.landmark(data, index)
|
||||
end
|
||||
|
||||
local function landmarkOfMap(data, mapId)
|
||||
local def = data and data.maps and data.maps[mapId]
|
||||
local def = data and data.gen2Maps and data.gen2Maps[mapId]
|
||||
return def and def.landmark
|
||||
end
|
||||
|
||||
@@ -131,7 +131,7 @@ function Nests.find(data, species, region, save)
|
||||
out[#out + 1] = landmark
|
||||
end
|
||||
|
||||
local enc = data and data.encounters
|
||||
local enc = data and data.gen2Encounters
|
||||
for _, key in ipairs({ "grass", "water" }) do
|
||||
for mapId, entry in pairs((enc and enc[key]) or {}) do
|
||||
if tableHasSpecies(entry, species) then
|
||||
|
||||
@@ -127,6 +127,9 @@ end
|
||||
Save.filenames = saveNames
|
||||
|
||||
local function fs()
|
||||
-- portable.txt: same standard/portable root as Gen 1 (SaveData.persistenceFs).
|
||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||
if ok and SaveData.persistenceFs then return SaveData.persistenceFs() end
|
||||
return love.filesystem
|
||||
end
|
||||
|
||||
@@ -272,6 +275,8 @@ Save.DEFAULT_OPTIONS = {
|
||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||
sfxVol = 7, -- 0-7
|
||||
musicFilter = 0, -- low-pass steps, 0 = off
|
||||
haptics = "light",
|
||||
touchControls = { enabled = true },
|
||||
}
|
||||
|
||||
function Save.defaultOptions()
|
||||
@@ -716,6 +721,13 @@ end
|
||||
-- copy is the witness that survives a crash mid-replace.
|
||||
function Save.save(save)
|
||||
if type(save) ~= "table" then return false, "no save" end
|
||||
if (save.version or "gold") == "gold" then
|
||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
|
||||
local id = SaveData.createSlot and SaveData.createSlot("gold")
|
||||
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
|
||||
end
|
||||
end
|
||||
local main, backup, tmp = saveNames(save.version)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
|
||||
@@ -241,7 +241,10 @@ function RomExtractorGen2:writeCompressedPic(label, tiles, relative)
|
||||
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
|
||||
while #pixels > byteLength do table.remove(pixels) end
|
||||
pixels = ImageWriter.columnsToRows(pixels, tiles, tiles)
|
||||
self:write2bpp(pixels, size, size, relative)
|
||||
-- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking,
|
||||
-- so matte the white backdrop like Gen 1's writeCompressedPic does.
|
||||
self:save(ImageWriter.matteColor0(
|
||||
ImageWriter.decode2bpp(pixels, size, size)), relative)
|
||||
end
|
||||
|
||||
function RomExtractorGen2:extractConstants()
|
||||
|
||||
@@ -1370,6 +1370,10 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
end
|
||||
|
||||
if GameVersion.VERSIONS[self.tab] then
|
||||
self.modScope = self.tab
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -2627,6 +2631,9 @@ function RomImporter:_switchTab(id)
|
||||
self.tab = id
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
if GameVersion.VERSIONS[id] then
|
||||
self:_setModScope(id)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_toggleFindSearchFocus()
|
||||
@@ -2937,6 +2944,13 @@ function RomImporter:_refreshMods()
|
||||
end
|
||||
end
|
||||
self.mods = LauncherMods.list(self.modScope) or {}
|
||||
if self.modScope then
|
||||
local kept = {}
|
||||
for _, m in ipairs(self.mods) do
|
||||
if m.targetsHere ~= false then kept[#kept + 1] = m end
|
||||
end
|
||||
self.mods = kept
|
||||
end
|
||||
self:_syncModUpdateInfo(false)
|
||||
end
|
||||
|
||||
@@ -3586,7 +3600,7 @@ function RomImporter:_findRows()
|
||||
-- index, query, or category actually changed.
|
||||
local c = self._findRowsCache
|
||||
if c and c.src == all and c.query == self.findQuery
|
||||
and c.category == self.findCategory then
|
||||
and c.category == self.findCategory and c.scope == self.modScope then
|
||||
return c.rows
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
@@ -3594,8 +3608,33 @@ function RomImporter:_findRows()
|
||||
query = self.findQuery,
|
||||
category = self.findCategory,
|
||||
})
|
||||
if self.modScope then
|
||||
local gen = GameVersion.generation(self.modScope)
|
||||
local kept = {}
|
||||
for _, entry in ipairs(rows) do
|
||||
local has1, has2 = false, false
|
||||
local function note(s)
|
||||
s = tostring(s or ""):lower()
|
||||
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
|
||||
or s == "yellow" then
|
||||
has1 = true
|
||||
end
|
||||
if s == "gen2" or s == "gen 2" or s == "gold" then
|
||||
has2 = true
|
||||
end
|
||||
end
|
||||
for _, cat in ipairs(entry.categories or {}) do note(cat) end
|
||||
for _, tag in ipairs(entry.tags or {}) do note(tag) end
|
||||
if (not has1 and not has2)
|
||||
or (gen == 2 and has2)
|
||||
or (gen ~= 2 and has1) then
|
||||
kept[#kept + 1] = entry
|
||||
end
|
||||
end
|
||||
rows = kept
|
||||
end
|
||||
self._findRowsCache = { src = all, query = self.findQuery,
|
||||
category = self.findCategory, rows = rows }
|
||||
category = self.findCategory, scope = self.modScope, rows = rows }
|
||||
return rows
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
local AssetTransform = require("src.mods.AssetTransform")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
@@ -22,6 +23,8 @@ local Loader = {}
|
||||
Loader.__index = Loader
|
||||
|
||||
local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only
|
||||
local OPTION_SCHEMAS_FILENAME = "mod_option_schemas.json"
|
||||
local OPTION_SCHEMAS_VERSION = 1
|
||||
|
||||
-- The working tree's engine version is the "0.0.0-dev" placeholder that CI
|
||||
-- restamps into the packed game.love (src/core/Version.lua:7), and it sorts
|
||||
@@ -338,6 +341,57 @@ function Loader:_saveState()
|
||||
SaveData.saveOptions(options, self.fs)
|
||||
end
|
||||
|
||||
-- Export the runtime option schemas after mod entry chunks have run. This
|
||||
-- is an optional, data-only handoff for native launchers: they must not run
|
||||
-- arbitrary mod code before boot just to discover settings. The snapshot is
|
||||
-- deliberately written beside options.lua so every platform's native shell
|
||||
-- can use the same filesystem contract.
|
||||
function Loader:_writeOptionSchemas()
|
||||
if not self.fs.write then return end
|
||||
|
||||
local mods = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
if mod.enabled and not mod.failed then
|
||||
local schema = self.optionSchemas[id]
|
||||
-- Keep the legacy manifest options_schema path visible to native
|
||||
-- consumers too. ManagerState loads this same data-only chunk on
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Do not create storage on a fresh mod-free boot, but do overwrite an old
|
||||
-- snapshot when the current boot has no schemas so disabled/failed mods do
|
||||
-- not leave stale native settings rows behind.
|
||||
if next(mods) == nil
|
||||
and not (self.fs.getInfo and self.fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, encoded = pcall(Json.encode, {
|
||||
schema_version = OPTION_SCHEMAS_VERSION,
|
||||
mods = mods,
|
||||
})
|
||||
if not ok then
|
||||
Logger.warn("mod option schema export: failed to encode: %s", tostring(encoded))
|
||||
return
|
||||
end
|
||||
local written, err = self.fs.write(OPTION_SCHEMAS_FILENAME, encoded)
|
||||
if not written then
|
||||
Logger.warn("mod option schema export: failed to write: %s", tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
function Loader:setEnabled(id, enabled)
|
||||
if not self.mods[id] then return false end
|
||||
self.disabled[id] = not enabled
|
||||
@@ -917,6 +971,16 @@ function Loader:_api(mod)
|
||||
-- the widget toolkit facade (12 4.5) is one shared surface, not
|
||||
-- per-mod state; each widget inside it loads on first touch
|
||||
ui = ModUI,
|
||||
-- Read-only shared timestamp presentation using current options.lua
|
||||
-- preferences. The live game supplies only the current option context;
|
||||
-- checkpoint/save data never changes as a side effect.
|
||||
datetime = {
|
||||
date = function(_, game, timestamp) return DateTime.date(game, timestamp) end,
|
||||
time = function(_, game, timestamp) return DateTime.time(game, timestamp) end,
|
||||
dateTime = function(_, game, timestamp)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -940,6 +1004,7 @@ function Loader:_api(mod)
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
context = function(_, game) return storage:context(game) end,
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
@@ -953,6 +1018,12 @@ function Loader:_api(mod)
|
||||
restore = function(_, game, checkpoint)
|
||||
return Checkpoint.restore(game, checkpoint)
|
||||
end,
|
||||
resume = function(_, game, checkpoint)
|
||||
return Checkpoint.resume(game, checkpoint)
|
||||
end,
|
||||
ensureNormalSave = function(_, game, checkpoint)
|
||||
return Checkpoint.ensureNormalSave(game, checkpoint, loader.fs)
|
||||
end,
|
||||
},
|
||||
options = {
|
||||
define = function(_, schema)
|
||||
@@ -1403,6 +1474,7 @@ function Loader:load(data)
|
||||
-- which resolves every path to itself (14 §asset resolution).
|
||||
Assets.installLoader(self)
|
||||
self.events:emit("mods.loaded", { loader = self, data = data })
|
||||
self:_writeOptionSchemas()
|
||||
self.initialized = true
|
||||
return #self.errors == 0
|
||||
end
|
||||
|
||||
@@ -79,6 +79,64 @@ function Storage:_scope(game)
|
||||
base = base, fs = fs }
|
||||
end
|
||||
|
||||
local function isTitleSession(game)
|
||||
local states = game and game.stack and game.stack.states
|
||||
if type(states) ~= "table" then return false end
|
||||
for _, state in ipairs(states) do
|
||||
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Bind this mod only to the engine-selected existing playthrough while the
|
||||
-- title session is active. Unlike _scope this must never allocate an identity:
|
||||
-- browsing history before the first normal SAVE is a read of durable state,
|
||||
-- not the start of a New Game. The returned facade closes over its private
|
||||
-- proxy game, so callers cannot substitute another playthrough id or path.
|
||||
function Storage:selected(game)
|
||||
if not isTitleSession(game) then
|
||||
return failure("not_at_title",
|
||||
"Selected playthrough storage is available only from the title session.")
|
||||
end
|
||||
local save = game and game.save
|
||||
local version = save and save.version
|
||||
if not validSegment(version) then
|
||||
return failure("not_in_playthrough",
|
||||
"The title session has no selected game version.")
|
||||
end
|
||||
local playthroughId, code, message =
|
||||
SaveData.selectedPlaythroughId(save, self.injectedFs)
|
||||
if not validSegment(playthroughId) then return failure(code, message) end
|
||||
|
||||
local selectedGame = {
|
||||
save = { version = version, meta = { playthroughId = playthroughId } },
|
||||
}
|
||||
local context = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = version,
|
||||
playthroughId = playthroughId,
|
||||
}
|
||||
local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs)
|
||||
if type(normal) == "table" and normal.savedAt ~= nil then
|
||||
context.normalSavedAt = normal.savedAt
|
||||
end
|
||||
return {
|
||||
context = function()
|
||||
local copy = {
|
||||
engineVersion = context.engineVersion,
|
||||
gameVersion = context.gameVersion,
|
||||
playthroughId = context.playthroughId,
|
||||
}
|
||||
if context.normalSavedAt ~= nil then copy.normalSavedAt = context.normalSavedAt end
|
||||
return copy
|
||||
end,
|
||||
read = function(_, key) return self:read(selectedGame, key) end,
|
||||
write = function(_, key, value) return self:write(selectedGame, key, value) end,
|
||||
list = function(_, prefix) return self:list(selectedGame, prefix) end,
|
||||
delete = function(_, key) return self:delete(selectedGame, key) end,
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:context(game)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-- the text is exhausted and A is pressed, then calls onDone.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local UIVisibility = require("src.battle.UIVisibility")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
@@ -19,6 +19,8 @@ TextBox.isTextBox = true
|
||||
-- construction time, so an unthemed boot stays byte-identical
|
||||
local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6
|
||||
local MAX_COLS = 18
|
||||
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
|
||||
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
|
||||
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
|
||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||
@@ -352,7 +354,8 @@ function TextBox:update(dt)
|
||||
-- typewriter cadence: one character every N frames, N = the OPTION
|
||||
-- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B
|
||||
-- prints every frame like the original's held-button fast path
|
||||
local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3
|
||||
local rawSpeed = self.game.save.options and self.game.save.options.textSpeed
|
||||
local delay = NAME_DELAYS[rawSpeed] or rawSpeed or 3
|
||||
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
|
||||
if input:isDown("a") or input:isDown("b") then delay = 1 end
|
||||
self.charTimer = (self.charTimer or 0) + 1
|
||||
@@ -390,11 +393,7 @@ function TextBox:update(dt)
|
||||
end
|
||||
|
||||
function TextBox:draw()
|
||||
if Runtime.wantsHook("battle.bottom_ui_visible")
|
||||
and Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) == false then
|
||||
return
|
||||
end
|
||||
if not UIVisibility.bottomVisible(self, true) then return end
|
||||
-- The dialogue box belongs against the bottom of the screen, not floating
|
||||
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
||||
-- renderer blits this region to the screen edge and the rest of the UI
|
||||
|
||||
+41
-22
@@ -280,10 +280,44 @@ function Commands.save_end_battle_text(ctx, textId)
|
||||
ctx.endBattleText = TextBox.substitute(ctx.game, text or textId)
|
||||
end
|
||||
|
||||
-- Route scripted battles through the standard entry transition. In the
|
||||
-- originals, InitWildBattle (engine/battle/init_battle.asm) always calls
|
||||
-- DoBattleTransitionAndInitBattleVariables (engine/battle/core.asm), with
|
||||
-- no old-man or Pikachu-demo exception; BattleTransition then selects the
|
||||
-- wipe for the battle kind. Some tests provide only a partial overworld
|
||||
-- double, so retain a logged fallback even though it skips the transition
|
||||
-- and battle music.
|
||||
function Commands.pushBattle(ctx, battle)
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
Logger.warn("pushBattle: no overworld:pushBattle, skipping the transition wipe")
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
end
|
||||
|
||||
-- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex
|
||||
function Commands.start_battle(ctx, kind, a, b)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local runner = ctx.runner
|
||||
local resumed = ctx.resumeBattle
|
||||
if resumed then
|
||||
ctx.resumeBattle = nil
|
||||
local result, restoredBattle = resumed.result, resumed.battle
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
ctx.overworld:afterBattle(result, restoredBattle)
|
||||
end)
|
||||
else
|
||||
ctx.overworld:afterBattle(result, restoredBattle)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
local battle
|
||||
if kind == "wild" then
|
||||
battle = BattleState.newWild(ctx.game, a, b)
|
||||
@@ -293,6 +327,10 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
-- one SaveEndBattleTextPointers arms one battle; leaving it set would leak
|
||||
-- the line into the next scripted fight
|
||||
battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil
|
||||
if runner and runner.battleCheckpointOrigin then
|
||||
battle.checkpointOrigin = runner:battleCheckpointOrigin(battle)
|
||||
if battle.checkpointOrigin then runner.checkpointBattle = battle end
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
@@ -312,19 +350,8 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
-- Every battle enters through the transition wipe, script-driven ones
|
||||
-- included: BattleTransition (engine/battle/battle_transitions.asm:1) runs
|
||||
-- from DoBattleTransitionAndInitBattleVariables for all of them, and
|
||||
-- GetBattleTransitionID_WildOrTrainer picks the style from the battle kind.
|
||||
-- Pushing the BattleState straight onto the stack skipped the wipe
|
||||
-- entirely, so every scripted trainer -- gym leaders, the rival, Giovanni --
|
||||
-- and every scripted wild battle simply cut to the battle screen. The
|
||||
-- trainer-sight path already went through pushBattle; this one did not.
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
-- A direct stack push would skip the battle-entry transition.
|
||||
Commands.pushBattle(ctx, battle)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
@@ -818,15 +845,7 @@ function Commands.old_man_demo(ctx, outcome)
|
||||
local battle = BattleState.newWild(ctx.game, om.species, om.level)
|
||||
battle:makeOldManDemo(nil, outcome == "fail")
|
||||
battle.onFinish = function() runner:resume() end
|
||||
-- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
|
||||
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
|
||||
-- special case -- so the catch tutorial gets the wipe like any other
|
||||
-- wild battle
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
Commands.pushBattle(ctx, battle)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
local Commands = require("src.script.Commands")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
||||
@@ -116,7 +117,7 @@ function ScriptRunner:makeContext(extra)
|
||||
return ctx
|
||||
end
|
||||
|
||||
function ScriptRunner:run(script, extra)
|
||||
function ScriptRunner:run(script, extra, startPc)
|
||||
assert(not self:isRunning(), "script already running")
|
||||
local ctx = self:makeContext(extra)
|
||||
self.ctx = ctx
|
||||
@@ -124,7 +125,7 @@ function ScriptRunner:run(script, extra)
|
||||
Runtime.emit("script.started", { ctx = ctx })
|
||||
end
|
||||
self.co = coroutine.create(function()
|
||||
self:exec(script, ctx)
|
||||
self:exec(script, ctx, startPc)
|
||||
-- Commands can defer a game action until the script's own dialogue is
|
||||
-- done. start_battle uses this for win-path evolutions, which must not
|
||||
-- be covered by post-battle trainer text.
|
||||
@@ -140,11 +141,13 @@ end
|
||||
-- Execute a command list. Supports labels via jump commands: a script is
|
||||
-- an array of rows; control commands return a new program counter, as a
|
||||
-- row number or a label name.
|
||||
function ScriptRunner:exec(script, ctx)
|
||||
function ScriptRunner:exec(script, ctx, startPc)
|
||||
local labels = ScriptRunner.scanLabels(script)
|
||||
local data = self.game and self.game.data
|
||||
local pc = 1
|
||||
local pc = startPc or 1
|
||||
self.script = script
|
||||
while pc <= #script do
|
||||
self.pc = pc
|
||||
local row = script[pc]
|
||||
local name = row[1]
|
||||
local fn, meta = Commands.resolve(data, name)
|
||||
@@ -189,6 +192,60 @@ function ScriptRunner:exec(script, ctx)
|
||||
end
|
||||
end
|
||||
|
||||
local CHECKPOINT_BATTLE_COMMANDS = {
|
||||
start_battle = true,
|
||||
static_battle = true,
|
||||
rival_battle = true,
|
||||
}
|
||||
|
||||
local function dataCopy(value)
|
||||
local ok, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not ok then return nil end
|
||||
return SaveSerializer.decode(encoded)
|
||||
end
|
||||
|
||||
-- Return a detached semantic continuation only for built-in battle commands
|
||||
-- whose post-yield behavior can be replayed from the current row. The live
|
||||
-- coroutine and arbitrary completion callbacks never cross this boundary.
|
||||
function ScriptRunner:battleCheckpointOrigin(battle)
|
||||
local row = self.script and self.script[self.pc]
|
||||
if not self:isRunning() or type(row) ~= "table"
|
||||
or not CHECKPOINT_BATTLE_COMMANDS[row[1]] then
|
||||
return nil
|
||||
end
|
||||
local ctx = self.ctx or {}
|
||||
if ctx.onDone ~= nil and ctx.checkpointOnDone ~= "release_npc" then
|
||||
return nil
|
||||
end
|
||||
local npcId = ctx.npc and ctx.npc.id or nil
|
||||
if ctx.checkpointOnDone == "release_npc" and type(npcId) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local map = self.overworld and self.overworld.map and self.overworld.map.id
|
||||
if type(map) ~= "string" then return nil end
|
||||
local origin = {
|
||||
kind = "script_battle",
|
||||
map = map,
|
||||
script = self.script,
|
||||
pc = self.pc,
|
||||
command = row[1],
|
||||
npcId = npcId,
|
||||
source = ctx.source,
|
||||
battleKind = battle and battle.kind,
|
||||
trainerClass = battle and battle.oppClass or nil,
|
||||
partyIndex = battle and battle.partyIndex or nil,
|
||||
wildSpecies = battle and battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.species or nil,
|
||||
wildLevel = battle and battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.level or nil,
|
||||
}
|
||||
return dataCopy(origin)
|
||||
end
|
||||
|
||||
function ScriptRunner:isCheckpointBattle(battle)
|
||||
return self.checkpointBattle == battle and self:isRunning()
|
||||
end
|
||||
|
||||
-- Called by blocking commands from inside the coroutine.
|
||||
function ScriptRunner:yield()
|
||||
return coroutine.yield()
|
||||
|
||||
@@ -456,7 +456,13 @@ end
|
||||
-- AskRockSmashScript reads it with `ifequal 1, .no`. Transcribing it the
|
||||
-- obvious way round refuses the move for every party that has it.
|
||||
function H.HasRockSmash(ctx)
|
||||
return call(ctx, "partyMoveUser", "ROCK_SMASH") and 0 or 1
|
||||
local mon = call(ctx, "partyMoveUser", "ROCK_SMASH")
|
||||
if mon then
|
||||
-- engine/events/overworld.asm:1339
|
||||
ctx.curPartyMon = mon
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- PutTheRodAway: ClearBox over the text window, then wPlayerAction back to
|
||||
|
||||
+10
-1
@@ -236,6 +236,12 @@ local function runCmd(self, cmd, op)
|
||||
if self.hidePicFn then self.hidePicFn() end
|
||||
elseif op == "writetext" or op == "farwritetext" then
|
||||
self:showText(cmd.text)
|
||||
if self.nextOp == "playsound" then
|
||||
-- pokegold home/joypad.asm PromptButton: the real press this box's
|
||||
-- own close absorbed plays SFX_READ_TEXT_2; drain it before the
|
||||
-- script's own playsound or Sound.lua's priority gate drops it.
|
||||
coroutine.yield({ kind = "waitsfx" })
|
||||
end
|
||||
elseif op == "rawtext" then
|
||||
-- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and
|
||||
-- text.lua only holds strings the extractor reached through a script
|
||||
@@ -384,9 +390,12 @@ local function runCmd(self, cmd, op)
|
||||
local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum)
|
||||
self.scriptVar = scene or 0xff
|
||||
elseif op == "turnobject" then
|
||||
-- engine/events/std_scripts.asm: turnobject LAST_TALKED resolves to the NPC last talked to
|
||||
local facing = Movement.dir(cmd.facing or 0)
|
||||
local object = cmd.object or 0
|
||||
if object == LAST_TALKED then object = self.lastTalked end
|
||||
if self.turnObjectFn then
|
||||
self.turnObjectFn(cmd.object or 0, facing)
|
||||
self.turnObjectFn(object, facing)
|
||||
end
|
||||
elseif op == "applymovement" or op == "applymovementlasttalked" then
|
||||
local object = cmd.object or 0
|
||||
|
||||
+41
-4
@@ -127,6 +127,7 @@ function BindingsMenu:beginCapture(item)
|
||||
self.onKeyReleased = BindingsMenu.captureKeyRelease
|
||||
self.onGamepadReleased = BindingsMenu.capturePadRelease
|
||||
self.onJoystickReleased = BindingsMenu.captureJoyRelease
|
||||
if Input.armCapture then Input:armCapture() end
|
||||
end
|
||||
|
||||
function BindingsMenu:endCapture()
|
||||
@@ -138,6 +139,7 @@ function BindingsMenu:endCapture()
|
||||
self.onKeyReleased = nil
|
||||
self.onGamepadReleased = nil
|
||||
self.onJoystickReleased = nil
|
||||
if Input.disarmCapture then Input:disarmCapture() end
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
@@ -232,7 +234,11 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end
|
||||
|
||||
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
|
||||
@@ -246,7 +252,11 @@ function BindingsMenu:clearBinding(item)
|
||||
end
|
||||
opts.bindings[item.button.id] = nil
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end
|
||||
|
||||
-- START: confirm, then drop the whole overlay (#589). The footer doubles
|
||||
@@ -264,12 +274,39 @@ function BindingsMenu:confirmReset()
|
||||
for _, it in ipairs(self.items) do
|
||||
it.right = boundRight(nil, it.button)
|
||||
end
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
|
||||
function BindingsMenu:drainCapture()
|
||||
local events = Input.takeCaptureEvents and Input:takeCaptureEvents()
|
||||
if not events then return end
|
||||
for i = 1, #events do
|
||||
local ev = events[i]
|
||||
if ev.phase == "pressed" then
|
||||
if ev.kind == "key" then self:captureKey(ev.value)
|
||||
elseif ev.kind == "pad" then self:capturePad(ev.value)
|
||||
elseif ev.kind == "joy" then self:captureJoy(ev.value)
|
||||
end
|
||||
else
|
||||
if ev.kind == "key" then self:captureKeyRelease(ev.value)
|
||||
elseif ev.kind == "pad" then self:capturePadRelease(ev.value)
|
||||
elseif ev.kind == "joy" then self:captureJoyRelease(ev.value)
|
||||
end
|
||||
end
|
||||
if not self.capture then return end
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:update(dt)
|
||||
if self.capture then return end -- the raw capture owns the input
|
||||
if self.capture then
|
||||
self:drainCapture()
|
||||
return
|
||||
end
|
||||
if self.game.input:wasPressed("start") then
|
||||
return self:confirmReset()
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local UIVisibility = require("src.battle.UIVisibility")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
@@ -67,6 +68,7 @@ function ChoiceBox:update(dt)
|
||||
end
|
||||
|
||||
function ChoiceBox:draw()
|
||||
if not UIVisibility.bottomVisible(self, false) then return end
|
||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||
-- rides the same bottom anchor as the dialogue box it sits above, so the
|
||||
-- pair travels together (the anchor keeps each element's gap from the edge)
|
||||
@@ -74,7 +76,10 @@ function ChoiceBox:draw()
|
||||
if r and r.setUIAnchor then
|
||||
r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor)
|
||||
end
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
-- pokegold home/menu.asm YesNoBox: font-page tiles take the screen's own
|
||||
-- BG palette 0 colour 0, same as TextBox.lua's paper fold.
|
||||
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
||||
Font.drawBox(tx, ty, tw, th, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||
|
||||
@@ -12,6 +12,7 @@ local MODULES = {
|
||||
QuantityBox = "src.ui.QuantityBox",
|
||||
NamingScreen = "src.ui.NamingScreen",
|
||||
PicBox = "src.ui.PicBox",
|
||||
PokemonIcon = "src.ui.PokemonIcon",
|
||||
TextBox = "src.render.TextBox",
|
||||
Font = "src.render.Font",
|
||||
Theme = "src.ui.Theme",
|
||||
|
||||
@@ -49,6 +49,30 @@ local Rulesets = {
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
}
|
||||
local FILTERS = { "OFF", "1X", "2X", "3X" }
|
||||
local DATE_FORMATS = {
|
||||
{ "device", "DEVICE" }, { "dmy", "DD-MM-YYYY" },
|
||||
{ "mdy", "MM-DD-YYYY" }, { "ymd", "YYYY-MM-DD" },
|
||||
}
|
||||
local TIME_FORMATS = {
|
||||
{ "device", "DEVICE" }, { "24h", "24 HOUR" }, { "12h", "12 HOUR" },
|
||||
}
|
||||
|
||||
local function preferenceIndex(rows, value)
|
||||
for index, row in ipairs(rows) do
|
||||
if row[1] == value then return index end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function preferenceStep(rows, value, direction)
|
||||
local index = preferenceIndex(rows, value)
|
||||
direction = direction and direction < 0 and -1 or 1
|
||||
return rows[((index - 1 + direction) % #rows) + 1][1]
|
||||
end
|
||||
|
||||
local function preferenceLabel(rows, value)
|
||||
return rows[preferenceIndex(rows, value)][2]
|
||||
end
|
||||
|
||||
local function speedIndex(game)
|
||||
-- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions
|
||||
@@ -439,6 +463,24 @@ local function buildRows(game)
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
{ id = "dateFormat", label = Strings("DATE FORMAT"),
|
||||
value = function(g)
|
||||
return Strings(preferenceLabel(DATE_FORMATS, g.save.options.dateFormat))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
g.save.options.dateFormat = preferenceStep(
|
||||
DATE_FORMATS, g.save.options.dateFormat, dir)
|
||||
return true
|
||||
end },
|
||||
{ id = "timeFormat", label = Strings("TIME FORMAT"),
|
||||
value = function(g)
|
||||
return Strings(preferenceLabel(TIME_FORMATS, g.save.options.timeFormat))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
g.save.options.timeFormat = preferenceStep(
|
||||
TIME_FORMATS, g.save.options.timeFormat, dir)
|
||||
return true
|
||||
end },
|
||||
-- permanent on-screen pad toggle (#327); layout editing stays in the
|
||||
-- launcher. Hidden where the overlay never appears (desktop without
|
||||
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Public read-only Pokemon icon presentation for detached summaries.
|
||||
-- Resolution and rendering deliberately stay engine-owned: PartyMenu already
|
||||
-- composes content icon registrations, per-species definitions, asset
|
||||
-- overrides, and the pokemon.icon hook in one canonical path.
|
||||
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local PokemonIcon = {}
|
||||
|
||||
local function finite(value)
|
||||
return type(value) == "number" and value == value
|
||||
and value ~= math.huge and value ~= -math.huge
|
||||
end
|
||||
|
||||
local function integer(value, minimum)
|
||||
return finite(value) and value % 1 == 0 and value >= minimum
|
||||
end
|
||||
|
||||
function PokemonIcon.draw(game, summary, x, y, opts)
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
if type(game) ~= "table" or type(summary) ~= "table"
|
||||
or type(summary.species) ~= "string" or summary.species == ""
|
||||
or not integer(summary.hp, 0) or not integer(summary.maxHp, 1)
|
||||
or summary.hp > summary.maxHp or not finite(x) or not finite(y)
|
||||
or (opts.selected ~= nil and type(opts.selected) ~= "boolean")
|
||||
or (opts.counter ~= nil and not finite(opts.counter)) then
|
||||
return false, "invalid_pokemon_preview",
|
||||
"Pokemon icon presentation needs species and valid captured HP values."
|
||||
end
|
||||
local ok, message = pcall(PartyMenu.drawIcon, game, {
|
||||
species = summary.species,
|
||||
hp = summary.hp,
|
||||
stats = { hp = summary.maxHp },
|
||||
}, x, y, opts.selected == true, opts.counter or 0)
|
||||
if not ok then
|
||||
return false, "pokemon_icon_failed", tostring(message)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return PokemonIcon
|
||||
@@ -49,17 +49,29 @@ end
|
||||
function Editor.load(opts)
|
||||
opts = opts or {}
|
||||
Editor.onClose = opts.onClose
|
||||
Editor.version = opts.version
|
||||
Editor.hostPoll = opts.hostPoll == true
|
||||
Editor.drag = nil
|
||||
Editor.rects = {}
|
||||
Editor._hostMouse = false
|
||||
Editor._hostTouches = nil
|
||||
Editor.fonts = {
|
||||
title = love.graphics.newFont(28),
|
||||
body = love.graphics.newFont(16),
|
||||
btn = love.graphics.newFont(18),
|
||||
}
|
||||
local optsTbl = SaveData.loadOptions()
|
||||
local applied = optsTbl
|
||||
if opts.version == "gold" then
|
||||
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
|
||||
applied = {
|
||||
touchControls = gold.touchControls,
|
||||
haptics = gold.haptics or optsTbl.haptics,
|
||||
}
|
||||
end
|
||||
TouchControls:init()
|
||||
TouchControls:ensureImages()
|
||||
TouchControls:applyOptions(optsTbl)
|
||||
TouchControls:applyOptions(applied)
|
||||
TouchControls:setPreview(true)
|
||||
Editor.enabled = TouchControls.enabled ~= false
|
||||
PadCursor.reset()
|
||||
@@ -71,17 +83,24 @@ function Editor.unload()
|
||||
PadCursor.reset()
|
||||
Editor.drag = nil
|
||||
Editor.onClose = nil
|
||||
Editor.version = nil
|
||||
Editor._hostMouse = false
|
||||
Editor._hostTouches = nil
|
||||
end
|
||||
|
||||
local function persist()
|
||||
local opts = SaveData.loadOptions()
|
||||
local cfg = TouchControls:config()
|
||||
-- replaces the whole table, so a pre-#633 top-level positions key is
|
||||
-- dropped once the player saves under the new shape
|
||||
opts.touchControls = {
|
||||
local block = {
|
||||
enabled = cfg.enabled,
|
||||
layouts = cfg.layouts,
|
||||
}
|
||||
if Editor.version == "gold" then
|
||||
opts.gold = type(opts.gold) == "table" and opts.gold or {}
|
||||
opts.gold.touchControls = block
|
||||
else
|
||||
opts.touchControls = block
|
||||
end
|
||||
SaveData.saveOptions(opts)
|
||||
end
|
||||
|
||||
@@ -104,9 +123,7 @@ end
|
||||
|
||||
function Editor.update(dt)
|
||||
PadCursor.update(dt or 0)
|
||||
-- drag follows the live pointer when love.touch / mouse / pad is available;
|
||||
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
|
||||
-- path for Android where move events can be thin
|
||||
if Editor.hostPoll then Editor.pollHostPointers() end
|
||||
if not Editor.drag then return end
|
||||
local x, y
|
||||
if Editor.drag.touchId == "pad" then
|
||||
@@ -297,6 +314,45 @@ local function endDrag(id)
|
||||
Editor.drag = nil
|
||||
end
|
||||
|
||||
function Editor.pollHostPointers()
|
||||
local down = love.mouse and love.mouse.isDown and love.mouse.isDown(1)
|
||||
if down then
|
||||
local x, y = love.mouse.getPosition()
|
||||
if not Editor._hostMouse then
|
||||
Editor._hostMouse = true
|
||||
beginDrag("mouse", x, y)
|
||||
else
|
||||
moveDrag("mouse", x, y)
|
||||
end
|
||||
elseif Editor._hostMouse then
|
||||
Editor._hostMouse = false
|
||||
endDrag("mouse")
|
||||
end
|
||||
if not (love.touch and love.touch.getTouches and love.touch.getPosition) then
|
||||
return
|
||||
end
|
||||
Editor._hostTouches = Editor._hostTouches or {}
|
||||
local seen = {}
|
||||
for _, id in ipairs(love.touch.getTouches()) do
|
||||
seen[id] = true
|
||||
local ok, tx, ty = pcall(love.touch.getPosition, id)
|
||||
if ok and tx then
|
||||
if not Editor._hostTouches[id] then
|
||||
Editor._hostTouches[id] = true
|
||||
beginDrag(id, tx, ty)
|
||||
else
|
||||
moveDrag(id, tx, ty)
|
||||
end
|
||||
end
|
||||
end
|
||||
for id in pairs(Editor._hostTouches) do
|
||||
if not seen[id] then
|
||||
Editor._hostTouches[id] = nil
|
||||
endDrag(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Editor.mousepressed(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
-- Finger / mouse tap yields the Joy-Con pointer so the click lands where
|
||||
@@ -391,4 +447,35 @@ function Editor.keypressed(key)
|
||||
end
|
||||
end
|
||||
|
||||
function Editor.new(game)
|
||||
local state = { game = game, isOpaque = true }
|
||||
Editor.hostPoll = true
|
||||
Editor.load({
|
||||
version = "gold",
|
||||
hostPoll = true,
|
||||
onClose = function()
|
||||
Editor.hostPoll = false
|
||||
if game.options then
|
||||
game.options.touchControls = TouchControls:config()
|
||||
end
|
||||
if game.stack and game.stack:top() == state then
|
||||
game.stack:pop()
|
||||
end
|
||||
if game.applyOptions then game:applyOptions() end
|
||||
end,
|
||||
})
|
||||
function state:wantsFillScale() return true end
|
||||
function state:drawsWidescreen() return true end
|
||||
function state:update(dt)
|
||||
Editor.update(dt)
|
||||
local input = self.game and self.game.input
|
||||
if input and input:wasPressed("b") then close() end
|
||||
end
|
||||
function state:draw() end
|
||||
function state:drawWidescreen(_w, _h)
|
||||
Editor.draw()
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
return Editor
|
||||
|
||||
+239
-37
@@ -43,8 +43,9 @@ local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
|
||||
-- How long a message stays before the next event runs, in logic steps. The
|
||||
-- cart waits for A on most lines; holding A skips faster, same as text boxes.
|
||||
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
|
||||
-- positive value means "hold until A/B"; the cart never times these out, so
|
||||
-- the victory jingle can keep looping through the post-win prompts.
|
||||
local MESSAGE_FRAMES = 48
|
||||
|
||||
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
|
||||
@@ -91,6 +92,8 @@ local FAINT_SLIDE_FRAMES_PER_ROW = 2
|
||||
-- (engine/battle/core.asm:3439-3466, data/text/battle.asm:241-249).
|
||||
local TEXT_NO_WILL_TO_FIGHT = "There's no will to battle!"
|
||||
local TEXT_EGG_CANT_BATTLE = "An EGG can't battle!"
|
||||
-- data/text/battle.asm:207
|
||||
local TEXT_USE_NEXT_MON = "Use next POKéMON?"
|
||||
|
||||
-- BattleText_TheMoveIsDisabled / BattleText_TheresNoPPLeftForThisMove
|
||||
-- (data/text/battle.asm:315-322).
|
||||
@@ -244,6 +247,7 @@ function BattleState.new(game, opts)
|
||||
-- was extracted has no `dudeBack`, and falls back to the player's own.
|
||||
self.showPlayerTrainer = true
|
||||
self.playerBackImage = nil
|
||||
self.playerBackTrueColor = false
|
||||
local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud
|
||||
local backPath = hudGfx and hudGfx.playerBack
|
||||
if self.tutorial and hudGfx and hudGfx.dudeBack then
|
||||
@@ -251,7 +255,11 @@ function BattleState.new(game, opts)
|
||||
end
|
||||
-- player.sprite, the same hook and payload Gen 1 raises for its own back pic
|
||||
-- (src/pokemon/Sprites.lua): the Dude's stand-in is the `demo` flag there.
|
||||
backPath = Sprites.playerPic(backPath, {
|
||||
-- Both return values matter here -- a mod's trueColor answer has to survive
|
||||
-- to drawPic, or GbcPalette treats the replacement art as a grayscale 2bpp
|
||||
-- sheet and remaps it through a palette instead of leaving it alone.
|
||||
local backTrueColor
|
||||
backPath, backTrueColor = Sprites.playerPic(backPath, {
|
||||
side = "back", kind = "battle", demo = self.tutorial and true or false,
|
||||
battle = self.battle, data = data,
|
||||
})
|
||||
@@ -262,6 +270,7 @@ function BattleState.new(game, opts)
|
||||
-- Kept so battle_sprite_scales can be looked up for this pic too: it is
|
||||
-- not a species' pic, so its asset path is the only key it has.
|
||||
self.playerBackPath = backPath
|
||||
self.playerBackTrueColor = backTrueColor and true or false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -547,7 +556,10 @@ function BattleState:drawPic(mon, back)
|
||||
-- Before SendOutPlayerMon the player's box holds ChrisBackpic instead, in
|
||||
-- the same 6x6 box at hlcoord 2, 6 that the mon's backpic uses.
|
||||
local trainerBack = back and self.showPlayerTrainer and self.playerBackImage
|
||||
if trainerBack then image, path = trainerBack, self.playerBackPath end
|
||||
if trainerBack then
|
||||
image, path = trainerBack, self.playerBackPath
|
||||
trueColor = self.playerBackTrueColor
|
||||
end
|
||||
-- And the enemy's box holds the trainer's own frontpic until EnemySwitch
|
||||
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
|
||||
local enemyTrainer = (not back) and self.showEnemyTrainer
|
||||
@@ -564,8 +576,10 @@ function BattleState:drawPic(mon, back)
|
||||
local px, py
|
||||
local boxTiles
|
||||
if back then
|
||||
px = BattleState.PLAYER_PIC_TILE_X * 8
|
||||
py = BattleState.PLAYER_PIC_TILE_Y * 8
|
||||
-- pokegold engine/battle/core.asm:8569: 6x6 box, bottom-aligned/centred
|
||||
local box = BattleState.PLAYER_PIC_TILES * 8
|
||||
px = BattleState.PLAYER_PIC_TILE_X * 8 + math.floor((box - w) / 2)
|
||||
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
|
||||
boxTiles = BattleState.PLAYER_PIC_TILES
|
||||
else
|
||||
-- Bottom-aligned and horizontally centred inside the 7x7 box.
|
||||
@@ -872,8 +886,12 @@ function BattleState:startAnim(key, opts)
|
||||
if not self.anims.scripts[key] then return false end
|
||||
-- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the
|
||||
-- move animation entirely, which is the OPTION screen's BATTLE SCENE row.
|
||||
-- The check only applies to a real move id (wFXAnimID+1 == 0); non-move
|
||||
-- ids (isMove unset here) branch straight to .not_move and always run.
|
||||
local options = self.game and self.game.options
|
||||
if options and options.battleScene == false then return false end
|
||||
if options and options.battleScene == false and opts and opts.isMove then
|
||||
return false
|
||||
end
|
||||
opts = opts or {}
|
||||
local data = (self.game and self.game.data) or {}
|
||||
local audio = data.audio or {}
|
||||
@@ -930,11 +948,38 @@ function BattleState:startAnim(key, opts)
|
||||
return true
|
||||
end
|
||||
|
||||
-- wBattleAfterAnim target for this attacker's turn
|
||||
-- (effect_commands.asm:1963-1972): player swing -> enemy shake, and reverse.
|
||||
function BattleState:afterAnimFor(side)
|
||||
if side == "player" then return "ANIM_ENEMY_DAMAGE" end
|
||||
return "ANIM_PLAYER_DAMAGE"
|
||||
end
|
||||
|
||||
function BattleState:animForMove(moveId, side)
|
||||
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
|
||||
return self:startAnim(key, {
|
||||
local started = self:startAnim(key, {
|
||||
turn = self:turnFor(side), animId = moveId, isMove = true,
|
||||
})
|
||||
if started then
|
||||
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
|
||||
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
|
||||
-- Queue it so stepAnim chains without waiting on the next event.
|
||||
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side }
|
||||
end
|
||||
return started
|
||||
end
|
||||
|
||||
-- Kick off a queued after-anim; returns true when one is now running.
|
||||
function BattleState:startPendingAfterAnim()
|
||||
local pending = self.pendingAfterAnim
|
||||
if not pending then return false end
|
||||
self.pendingAfterAnim = nil
|
||||
if self:animForId(pending.name, pending.side) then
|
||||
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
|
||||
self.afterAnimPlayed = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- True while BattleAnimClearHud has that side's HUD blanked.
|
||||
@@ -960,10 +1005,16 @@ function BattleState:stepAnim(input)
|
||||
-- tilemap is whatever they had got to and nothing is latched -- the
|
||||
-- explicit latches (a catch) are the only ones that survive a skip.
|
||||
self.anim = nil
|
||||
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
||||
-- of the move should not drop the hit shake that follows it.
|
||||
if self:startPendingAfterAnim() then return end
|
||||
return self:endSendOutAnim()
|
||||
end
|
||||
if not self.anim:step() then
|
||||
self.anim = nil
|
||||
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
|
||||
-- the OAM outlives the script, so keep the runner for drawing too.
|
||||
if not self.anim.keepSprites then self.anim = nil end
|
||||
if self:startPendingAfterAnim() then return end
|
||||
return self:endSendOutAnim()
|
||||
end
|
||||
end
|
||||
@@ -1094,7 +1145,14 @@ function BattleState:advanceQueue()
|
||||
-- until the next damage or heal event moved it.
|
||||
local battle = self.battle
|
||||
local mon = battle and battle.party and battle.party[event.index]
|
||||
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
|
||||
-- gets the stats box, not just the mon currently on the field.
|
||||
self.pendingStatsMon = mon
|
||||
-- engine/battle/core.asm:7044
|
||||
if mon and mon == battle.player then
|
||||
event.text = nil
|
||||
event.sfx = nil
|
||||
event.waitSfx = nil
|
||||
if self.shownHp then
|
||||
self.shownHp.player = mon.hp or 0
|
||||
if self.hpAnim and self.hpAnim.side == "player" then
|
||||
@@ -1214,6 +1272,14 @@ function BattleState:advanceQueue()
|
||||
return self:askNickname(event.mon)
|
||||
end
|
||||
if event.kind == "choose-switch" then
|
||||
-- engine/battle/core.asm:2590
|
||||
if self.battle and self.battle.wild then
|
||||
self.nextMonIndex = 1
|
||||
self.phase = "ask-next-mon"
|
||||
self.message = TEXT_USE_NEXT_MON
|
||||
self.messageTimer = 0
|
||||
return
|
||||
end
|
||||
-- A fainted lead: force a switch before anything else runs.
|
||||
self.phase = "forced-switch"
|
||||
self.message = "Choose a POKéMON."
|
||||
@@ -1248,7 +1314,19 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
if event.text then
|
||||
self.message = event.text
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
-- Lines that must not hold the queue for A/B:
|
||||
-- move UsedMoveText -> text_end, then moveanim
|
||||
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
|
||||
-- box's WaitPressAorB is the real hold
|
||||
-- experience keeps the wait: _ExpPointsText ends in `prompt`
|
||||
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
|
||||
-- so the bar crawls under the line and A dismisses it before the battle
|
||||
-- can end.
|
||||
if event.kind == "move" or event.kind == "level" then
|
||||
self.messageTimer = 0
|
||||
else
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
end
|
||||
-- BattleStartMessage's own line: the enemy HUD comes up on the step after
|
||||
-- it returns (engine/battle/core.asm:7808-7817), not with it.
|
||||
if event.intro then self.introTextShown = true end
|
||||
@@ -1268,14 +1346,25 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
end
|
||||
-- The move's own animation plays over its "used X!" line, which is where
|
||||
-- PlayBattleAnim sits in the effect command list. A damage event that
|
||||
-- follows gets the shared hit animation instead.
|
||||
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
|
||||
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
|
||||
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
|
||||
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
|
||||
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
|
||||
-- event.missed on every wAttackMissed path.
|
||||
if event.kind == "move" and not event.missed then
|
||||
self:animForMove(event.move, event.side)
|
||||
self.afterAnimPlayed = nil
|
||||
self.pendingAfterAnim = nil
|
||||
if not self:animForMove(event.move, event.side) then
|
||||
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
|
||||
-- (anim_commands.asm:55-72 .disabled fallthrough).
|
||||
local options = self.game and self.game.options
|
||||
if options and options.battleScene == false then
|
||||
if self:animForId(self:afterAnimFor(event.side), event.side) then
|
||||
self.afterAnimPlayed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif event.kind == "damage" and event.side then
|
||||
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
|
||||
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
|
||||
@@ -1284,10 +1373,25 @@ function BattleState:advanceQueue()
|
||||
if event.animMove then
|
||||
self:animForMove(event.animMove, from)
|
||||
elseif event.anim ~= false then
|
||||
self:animForId(event.anim
|
||||
local hit = event.anim
|
||||
or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE"
|
||||
or "ANIM_PLAYER_DAMAGE"), from)
|
||||
or "ANIM_PLAYER_DAMAGE")
|
||||
-- Already played as the move's after-anim; do not shake twice.
|
||||
if self.afterAnimPlayed
|
||||
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
|
||||
self.afterAnimPlayed = nil
|
||||
else
|
||||
self:animForId(hit, from)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Status moves still chain the after-anim but emit no damage event to
|
||||
-- consume the latch; drop it before the next unrelated line.
|
||||
self.afterAnimPlayed = nil
|
||||
end
|
||||
if event.kind == "heal" and event.anim and event.side then
|
||||
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
|
||||
self:animForMove(event.anim, event.side)
|
||||
elseif event.kind == "send" and event.side then
|
||||
-- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation
|
||||
-- (engine/battle/core.asm:3354) -- the faint replacement out of
|
||||
@@ -1584,7 +1688,9 @@ function BattleState:update(_dt)
|
||||
|
||||
-- An animation owns the screen for as long as it runs, exactly the way
|
||||
-- RunBattleAnimScript owns the main loop.
|
||||
if self.anim then
|
||||
-- pokegold data/moves/animations.asm .Click: a finished keepsprites run
|
||||
-- no longer owns the loop, just the OAM the draw path still reads.
|
||||
if self.anim and not (self.anim:done() and self.anim.keepSprites) then
|
||||
self:stepAnim(input)
|
||||
return
|
||||
end
|
||||
@@ -1602,32 +1708,45 @@ function BattleState:update(_dt)
|
||||
if Sound.isPlaying(self.waitSfx) then return end
|
||||
self.waitSfx = nil
|
||||
end
|
||||
-- AnimateExpBar sits right after PrintText Text_MonGainedExpPoint
|
||||
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
|
||||
-- before any PromptButton wait so the bar does not sit frozen until A.
|
||||
if self:stepExpAnim() then return end
|
||||
if self.messageTimer > 0 then
|
||||
if self.tutorial then
|
||||
-- PromptButton really does wait for the button; MESSAGE_FRAMES is this
|
||||
-- screen's shortcut for that, and the tutorial cannot take it. The
|
||||
-- DUDE's A lands on frame 0x51 (DudeAutoInput_A), so a line that timed
|
||||
-- out at 48 would hand his press to whichever screen came next.
|
||||
-- PromptButton waits for the button; the tutorial cannot press it, so
|
||||
-- DudeAutoInput_A (frame 0x51) answers. Never auto-timeout here: a
|
||||
-- 48-frame skip would hand his press to the next screen.
|
||||
self:dudeInput(CatchTutorial.PROMPT_STREAM,
|
||||
"prompt:" .. tostring(self.message))
|
||||
else
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
end
|
||||
-- A is the page-advance, exactly like a text box.
|
||||
-- PromptButton (home/text.asm): A/B pages; no frame countdown.
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
-- AnimateExpBar is called from GiveExperiencePoints AFTER
|
||||
-- Text_MonGainedExpPoint has been read (engine/battle/core.asm:6884-6888),
|
||||
-- so the crawl runs with that line still standing and the "grew to level"
|
||||
-- line waits behind it.
|
||||
if self:stepExpAnim() then return end
|
||||
-- pokegold engine/battle/core.asm:7057-7069: the stats box shows once
|
||||
-- the "grew to level" line has finished, held for A/B.
|
||||
if self.pendingStatsMon then
|
||||
self.statsBoxMon = self.pendingStatsMon
|
||||
self.pendingStatsMon = nil
|
||||
self.phase = "stats-box"
|
||||
return
|
||||
end
|
||||
self:advanceQueue()
|
||||
return
|
||||
end
|
||||
|
||||
-- pokegold engine/battle/core.asm:7069 (WaitPressAorB_BlinkCursor).
|
||||
if self.phase == "stats-box" then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.statsBoxMon = nil
|
||||
self.phase = "resolving"
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the
|
||||
-- cart falls straight into ParsePlayerAction, whose .locked_in arm has no
|
||||
-- MoveSelectionScreen in front of it.
|
||||
@@ -1644,6 +1763,8 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "menu" then
|
||||
-- 2x2 grid: left/right swap the column, up/down the row.
|
||||
-- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2
|
||||
-- on A/B only, never on D-pad.
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1
|
||||
or self.menuIndex - 1
|
||||
@@ -1651,6 +1772,7 @@ function BattleState:update(_dt)
|
||||
self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2
|
||||
or self.menuIndex - 2
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
local choice = MENU[self.menuIndex]
|
||||
if choice == "FIGHT" then
|
||||
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
|
||||
@@ -1701,11 +1823,13 @@ function BattleState:update(_dt)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
-- B leaves the list, and a mark never survives it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
elseif input:wasPressed("a") then
|
||||
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
|
||||
-- cancels a pending swap rather than performing it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
local move = moves[self.moveIndex]
|
||||
if not move then return end
|
||||
@@ -1724,7 +1848,6 @@ function BattleState:update(_dt)
|
||||
-- AskGiveNicknameText ends on `done`, so the line stands while the box is
|
||||
-- up rather than paging away from under it.
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1745,7 +1868,6 @@ function BattleState:update(_dt)
|
||||
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
|
||||
if self.phase == "ask-shift" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1764,9 +1886,38 @@ function BattleState:update(_dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:2590
|
||||
if self.phase == "ask-next-mon" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.nextMonIndex = self.nextMonIndex == 1 and 2 or 1
|
||||
elseif input:wasPressed("b") then
|
||||
return self:answerUseNextMon(false)
|
||||
elseif input:wasPressed("a") then
|
||||
return self:answerUseNextMon(self.nextMonIndex == 1)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if self.phase == "cant-escape-then-switch" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
self.message = "Choose a POKéMON."
|
||||
self.phase = "forced-switch"
|
||||
return
|
||||
end
|
||||
|
||||
if self.phase == "refuse-shift" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1788,7 +1939,6 @@ function BattleState:update(_dt)
|
||||
-- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry.
|
||||
if self.phase == "refuse-switch" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1807,7 +1957,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "refuse-move" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1820,7 +1969,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "learn-intro" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1834,7 +1982,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "ask-forget" or self.phase == "stop-learning" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1855,7 +2002,6 @@ function BattleState:update(_dt)
|
||||
-- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints
|
||||
-- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197).
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -2327,6 +2473,31 @@ function BattleState:offerShiftSwitch(mon)
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
end
|
||||
|
||||
function BattleState:answerUseNextMon(yes)
|
||||
if yes then
|
||||
self.phase = "forced-switch"
|
||||
self.message = "Choose a POKéMON."
|
||||
return
|
||||
end
|
||||
local battle = self.battle
|
||||
if not battle then
|
||||
self.phase = "forced-switch"
|
||||
return
|
||||
end
|
||||
local lead = battle.party and battle.party[1]
|
||||
local pSpd = (lead and lead.stats and lead.stats.speed) or 0
|
||||
-- engine/battle/core.asm:2614
|
||||
if battle:tryRun(pSpd) then
|
||||
self:pushAll(battle:takeEvents())
|
||||
self.phase = "resolving"
|
||||
return self:advanceQueue()
|
||||
end
|
||||
battle:takeEvents()
|
||||
self.message = "Can't escape!"
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
self.phase = "cant-escape-then-switch"
|
||||
end
|
||||
|
||||
-- SetUpBattlePartyMenu + PickSwitchMonInBattle (core.asm:3307-3308), which is
|
||||
-- PARTYMENUACTION_SWITCH and carries no submenu; a cancel is `.canceled_switch`
|
||||
-- and answers exactly like NO (:3327).
|
||||
@@ -3067,22 +3238,53 @@ function BattleState:drawPanel()
|
||||
-- stands.
|
||||
local asking = self.phase == "ask-nickname" or self.phase == "ask-forget"
|
||||
or self.phase == "stop-learning" or self.phase == "ask-shift"
|
||||
or self.phase == "ask-next-mon"
|
||||
if asking and (self.messageTimer or 0) <= 0 then
|
||||
-- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at
|
||||
-- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410).
|
||||
local left = self.phase == "ask-shift" and 1 or 14
|
||||
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
|
||||
and 1 or 14
|
||||
Chrome.box(left, 7, 6, 5)
|
||||
Chrome.print("YES", left + 2, 8)
|
||||
Chrome.print("NO", left + 2, 10)
|
||||
local index = self.phase == "ask-nickname" and self.nicknameIndex
|
||||
or self.phase == "ask-shift" and self.shiftIndex
|
||||
or self.phase == "ask-next-mon" and self.nextMonIndex
|
||||
or self.forgetChoice
|
||||
Chrome.cursor(left + 1, index == 1 and 8 or 10)
|
||||
end
|
||||
end
|
||||
if self.phase == "stats-box" and self.statsBoxMon then
|
||||
self:drawStatsBox(self.statsBoxMon)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- pokegold engine/pokemon/mon_stats.asm:118-124 (PrintTempMonStats.StatNames).
|
||||
local STATS_BOX_ROWS = {
|
||||
{ "ATTACK", "attack" }, { "DEFENSE", "defense" },
|
||||
{ "SPCL.ATK", "specialAttack" }, { "SPCL.DEF", "specialDefense" },
|
||||
{ "SPEED", "speed" },
|
||||
}
|
||||
|
||||
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
|
||||
function BattleState:drawStatsBox(mon)
|
||||
if not mon then return end
|
||||
local stats = mon.stats
|
||||
local data = self.game and self.game.data
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if def and def.baseStats then
|
||||
stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
|
||||
end
|
||||
if not stats then return end
|
||||
Chrome.textbox(9, 0, 9, 10)
|
||||
for i, row in ipairs(STATS_BOX_ROWS) do
|
||||
local ty = 1 + (i - 1) * 2
|
||||
Chrome.print(Strings(row[1]), 11, ty)
|
||||
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- The BG layer, plus whatever the animation is doing to it, plus the OBJ
|
||||
-- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn
|
||||
-- after the scanline blit rather than into the canvas with everything else.
|
||||
|
||||
+54
-20
@@ -43,6 +43,7 @@
|
||||
local Assets = require("src.render.Assets")
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
@@ -74,6 +75,14 @@ local PARTY_BOX = 0
|
||||
-- can destroy a mon.
|
||||
local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
|
||||
|
||||
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
|
||||
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
|
||||
|
||||
function BoxMenu:submenuRows()
|
||||
if self.mode == "move" then return MOVE_SUBMENU end
|
||||
return WITHDRAW_SUBMENU
|
||||
end
|
||||
|
||||
-- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while
|
||||
-- the mon is written into its new home. It stays up here until a button
|
||||
-- clears it, because it is also the only confirmation the player gets that the
|
||||
@@ -164,14 +173,16 @@ end
|
||||
-- The cart's own prompts (PCString_*): short, because the box they print in
|
||||
-- is one row of 18 columns.
|
||||
function BoxMenu:prompt()
|
||||
if self.mode == "deposit" then return "Deposit which one?" end
|
||||
-- engine/pokemon/bills_pc.asm:356-369: PrepSubmenu places PCString_WhatsUp.
|
||||
if self.phase == "submenu" then return "What's up?" end
|
||||
if self.mode == "move" then
|
||||
-- .Init, .PrepSubmenu and .PrepInsertCursor each place their own string.
|
||||
-- .Init and .PrepInsertCursor each place their own string.
|
||||
if self.phase == "insert" then return "Move to where?" end
|
||||
if self.phase == "submenu" then return "What's up?" end
|
||||
return "Choose a <PK><MN>."
|
||||
end
|
||||
return "Choose a POKéMON."
|
||||
-- PCString_ChooseaPKMN: _DepositPKMN.Init and BillsPC_Withdraw.Init both
|
||||
-- place this exact string (engine/pokemon/bills_pc.asm:2185).
|
||||
return "Choose a <PK><MN>."
|
||||
end
|
||||
|
||||
function BoxMenu:total()
|
||||
@@ -208,21 +219,15 @@ function BoxMenu:act()
|
||||
if self.onClose then self.onClose() end
|
||||
return
|
||||
end
|
||||
-- .a_button: the move screen never acts on the list itself. It checks that
|
||||
-- the row really is a mon and steps to $2, .PrepSubmenu.
|
||||
if self.mode == "move" then
|
||||
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
|
||||
if self.mode == "move" or self.mode == "withdraw" then
|
||||
if not self:selected() then return end
|
||||
self.phase = "submenu"
|
||||
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
|
||||
self.submenuIndex = 1
|
||||
return
|
||||
end
|
||||
local ok, result
|
||||
if self.mode == "deposit" then
|
||||
ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
else
|
||||
ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
|
||||
end
|
||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
if not ok then
|
||||
self.message = result
|
||||
return
|
||||
@@ -294,12 +299,28 @@ function BoxMenu:openStats()
|
||||
})
|
||||
end
|
||||
|
||||
-- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu.
|
||||
function BoxMenu:doWithdraw()
|
||||
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
|
||||
if not ok then
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
end
|
||||
|
||||
function BoxMenu:chooseSubmenu()
|
||||
local row = MOVE_SUBMENU[self.submenuIndex]
|
||||
local row = self:submenuRows()[self.submenuIndex]
|
||||
if row == "MOVE" then
|
||||
self:beginMove()
|
||||
elseif row == "WITHDRAW" then
|
||||
self:doWithdraw()
|
||||
elseif row == "STATS" then
|
||||
self:openStats()
|
||||
elseif row == "RELEASE" then
|
||||
self:askRelease()
|
||||
else
|
||||
-- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`.
|
||||
self.phase = nil
|
||||
@@ -417,11 +438,12 @@ function BoxMenu:update(_dt)
|
||||
|
||||
-- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry.
|
||||
if self.phase == "submenu" then
|
||||
local submenu = self:submenuRows()
|
||||
if input:wasPressed("up") then
|
||||
self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1
|
||||
or #MOVE_SUBMENU
|
||||
or #submenu
|
||||
elseif input:wasPressed("down") then
|
||||
self.submenuIndex = self.submenuIndex < #MOVE_SUBMENU
|
||||
self.submenuIndex = self.submenuIndex < #submenu
|
||||
and self.submenuIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self:chooseSubmenu()
|
||||
@@ -518,6 +540,7 @@ function BoxMenu:askRelease()
|
||||
return
|
||||
end
|
||||
self.message = name .. " was released."
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
@@ -644,12 +667,12 @@ end
|
||||
|
||||
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
|
||||
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
|
||||
-- left at pixel (71, 31), stepping 16 pixels per row. Those cursor tiles are
|
||||
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
|
||||
-- not extracted, so the frame is drawn as an outline at exactly those pixels,
|
||||
-- which is what the sprite frame looks like.
|
||||
function BoxMenu:drawSelectionFrame(row)
|
||||
local G = love.graphics
|
||||
local x, y = 71, 31 + (row - 1) * 16
|
||||
local x, y = 71, 25 + (row - 1) * 16
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.setLineWidth(1)
|
||||
G.rectangle("line", x + 0.5, y + 0.5, 80 - 1, 16 - 1)
|
||||
@@ -679,6 +702,9 @@ function BoxMenu:panelMon()
|
||||
end
|
||||
|
||||
function BoxMenu:drawPanel()
|
||||
-- BillsPC_InitGFX loads FontsBattleExtra once for the whole screen and
|
||||
-- never restores the standard font (engine/pokemon/bills_pc.asm:2169).
|
||||
local wasBattle = Font.useBattleExtra(true)
|
||||
Chrome.clear()
|
||||
|
||||
-- Box name header, then the list box hanging off it. BillsPC_BoxName is a
|
||||
@@ -686,6 +712,11 @@ function BoxMenu:drawPanel()
|
||||
Chrome.box(8, 0, 12, 3)
|
||||
Chrome.print(self:title(), 10, 1)
|
||||
Chrome.box(8, 2, 12, 12)
|
||||
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
|
||||
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
|
||||
-- off the name box above it.
|
||||
Font.drawCode(Font.BORDER.bl, 8 * 8, 2 * 8)
|
||||
Font.drawCode(Font.BORDER.br, 19 * 8, 2 * 8)
|
||||
|
||||
local list = self:list()
|
||||
local inserting = self.phase == "insert"
|
||||
@@ -724,7 +755,9 @@ function BoxMenu:drawPanel()
|
||||
self:drawEggPic(mon)
|
||||
else
|
||||
self:drawPic(mon)
|
||||
Chrome.print(":L" .. tostring(mon.level or 1), PIC_X, 12)
|
||||
-- PrintLevel always writes the single bold glyph, not ":L"
|
||||
-- (home/pokemon.asm:178-183).
|
||||
Chrome.print("<LV>" .. tostring(mon.level or 1), PIC_X, 12)
|
||||
if mon.gender == "male" then
|
||||
Chrome.print("\xe2\x99\x82", 5, 12)
|
||||
elseif mon.gender == "female" then
|
||||
@@ -756,13 +789,14 @@ function BoxMenu:drawPanel()
|
||||
-- top spacing puts MOVE at (11,6), one row per two tiles.
|
||||
if self.phase == "submenu" then
|
||||
Chrome.box(9, 4, 11, 10)
|
||||
for i, label in ipairs(MOVE_SUBMENU) do
|
||||
for i, label in ipairs(self:submenuRows()) do
|
||||
local ty = 6 + (i - 1) * 2
|
||||
if i == self.submenuIndex then Chrome.cursor(10, ty) end
|
||||
Chrome.print(label, 11, ty)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.useBattleExtra(wasBattle)
|
||||
end
|
||||
|
||||
function BoxMenu:draw()
|
||||
|
||||
@@ -75,7 +75,8 @@ local SFX_TRANSACTION = "Sfx_Transaction"
|
||||
|
||||
local MartMenu = {}
|
||||
MartMenu.__index = MartMenu
|
||||
MartMenu.isOpaque = true
|
||||
-- engine/items/mart.asm:54
|
||||
MartMenu.isOpaque = false
|
||||
|
||||
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
|
||||
-- ahead of the word mart id, and MartTypeDialogs is indexed by it.
|
||||
@@ -330,9 +331,6 @@ local function extractedText(text, base, labels)
|
||||
return out
|
||||
end
|
||||
|
||||
function MartMenu:wantsFillScale() return true end
|
||||
function MartMenu:drawsWidescreen() return true end
|
||||
|
||||
-- data/generated/marts.lua is what the ROM extractor will write out of `Marts`
|
||||
-- (data/items/marts.asm): `lists` is a 1-based array in MART_* order, each
|
||||
-- entry an array of item ids, and `bargain` is BargainShopData's own
|
||||
@@ -896,6 +894,7 @@ local function printPriceOpaque(amount, ty)
|
||||
end
|
||||
|
||||
function MartMenu:drawBuyList()
|
||||
-- engine/items/mart.asm:542
|
||||
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
|
||||
for row = 1, VISIBLE_ROWS do
|
||||
local i = row + self.scroll
|
||||
@@ -953,18 +952,14 @@ end
|
||||
function MartMenu:drawUnder()
|
||||
local phase = self.phase
|
||||
if phase == "top" then
|
||||
Chrome.clear()
|
||||
self:drawTopMenu()
|
||||
self:drawTextBox(self.topLines)
|
||||
elseif phase == "buy" or phase == "buyQuantity" then
|
||||
Chrome.clear()
|
||||
self:drawMoneyBox()
|
||||
self:drawBuyList()
|
||||
self:drawDescription()
|
||||
elseif phase == "sell" or phase == "sellQuantity" then
|
||||
if self.pack then self.pack:drawPanel() end
|
||||
else
|
||||
Chrome.clear()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1003,17 +998,4 @@ function MartMenu:draw()
|
||||
self:drawPanel()
|
||||
end
|
||||
|
||||
function MartMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
end
|
||||
|
||||
return MartMenu
|
||||
|
||||
@@ -119,7 +119,13 @@ function NamingScreen.new(game, opts)
|
||||
-- The header icon is an OBJ on the cart, so it wears a real palette; without
|
||||
-- one it would draw in raw DMG shades next to a colored world.
|
||||
self.iconColors = opts.iconColors
|
||||
self.gfx = opts.menuGfx
|
||||
local data = game and game.data or {}
|
||||
self.gfx = opts.menuGfx or data.gen2MenuGfx
|
||||
if self.gfx and self.gfx.naming then self.gfx = self.gfx.naming end
|
||||
-- engine/menus/naming_screen.asm:47
|
||||
-- engine/gfx/cgb_layouts.asm:488
|
||||
local diploma = data.gen2Diploma
|
||||
self.palette = diploma and diploma.palettes and diploma.palettes[1]
|
||||
self.tiles = {}
|
||||
if self.gfx then
|
||||
for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do
|
||||
@@ -315,6 +321,10 @@ function NamingScreen:update(_dt)
|
||||
end
|
||||
end
|
||||
|
||||
function NamingScreen:paperColor()
|
||||
return GbcPalette.color(self.palette, 1)
|
||||
end
|
||||
|
||||
-- The backdrop: one patterned tile repeated over the whole screen. Without
|
||||
-- menu_gfx.lua (older cache) fall back to a flat mid gray, which keeps the
|
||||
-- cleared panels readable.
|
||||
@@ -322,17 +332,25 @@ function NamingScreen:drawBackdrop()
|
||||
local G = love.graphics
|
||||
local tile = self.tiles.border
|
||||
if not tile then
|
||||
G.setColor(0.62, 0.62, 0.62, 1)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, 160, 144)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for ty = 0, Chrome.SCREEN_H - 1 do
|
||||
for tx = 0, Chrome.SCREEN_W - 1 do
|
||||
G.draw(tile, tx * 8, ty * 8)
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for ty = 0, Chrome.SCREEN_H - 1 do
|
||||
for tx = 0, Chrome.SCREEN_W - 1 do
|
||||
G.draw(tile, tx * 8, ty * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.palette and GbcPalette.available() then
|
||||
GbcPalette.with(self.palette, blit)
|
||||
else
|
||||
blit()
|
||||
end
|
||||
end
|
||||
|
||||
-- The cursor (data/sprite_anims/oam.asm .OAMData_TextEntryCursor and
|
||||
@@ -410,7 +428,8 @@ end
|
||||
|
||||
function NamingScreen:clearPanel(tx, ty, tw, th)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
@@ -472,12 +491,13 @@ function NamingScreen:drawPanel()
|
||||
G.draw(self.iconImage, quad, 16, 16)
|
||||
end
|
||||
end
|
||||
local pal = self.palette
|
||||
if self.monName then
|
||||
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?".
|
||||
Chrome.print(self.monName .. "'S", 5, 2)
|
||||
Chrome.print("NICKNAME?", 5, 4)
|
||||
Chrome.printThrough(self.monName .. "'S", 5, 2, pal)
|
||||
Chrome.printThrough("NICKNAME?", 5, 4, pal)
|
||||
else
|
||||
Chrome.print(self.prompt, 5, 2)
|
||||
Chrome.printThrough(self.prompt, 5, 2, pal)
|
||||
end
|
||||
self:drawEntry(5, self.isBox and 4 or 6)
|
||||
|
||||
@@ -489,17 +509,22 @@ function NamingScreen:drawPanel()
|
||||
for col = 0, 8 do
|
||||
local ch = line[col + 1]
|
||||
if ch and ch ~= " " and ch ~= "" then
|
||||
Chrome.print(ch, 2 + col * 2, keyboardTop + row * 2)
|
||||
Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal)
|
||||
end
|
||||
end
|
||||
end
|
||||
local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS
|
||||
local bottomY = keyboardTop + bottom * 2
|
||||
for i, label in ipairs(labels) do
|
||||
Chrome.print(label, BOTTOM_LABEL_TX[i], bottomY)
|
||||
Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal)
|
||||
end
|
||||
|
||||
self:drawCursorBox(self:cursorTile())
|
||||
local function cursor() self:drawCursorBox(self:cursorTile()) end
|
||||
if pal and GbcPalette.available() then
|
||||
GbcPalette.with(pal, cursor)
|
||||
else
|
||||
cursor()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -511,23 +536,32 @@ function NamingScreen:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
-- The naming screen's own patterned backdrop is the surround: extend it to
|
||||
-- the window edges so a widescreen boot has no black pillarbox.
|
||||
self:drawBackdrop()
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.setColor(0.62, 0.62, 0.62, 1)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
if self.tiles.border then
|
||||
local tilesX = math.ceil(winW / (8 * scale))
|
||||
local tilesY = math.ceil(winH / (8 * scale))
|
||||
G.push()
|
||||
G.scale(scale, scale)
|
||||
for ty = 0, tilesY do
|
||||
for tx = 0, tilesX do
|
||||
G.draw(self.tiles.border, tx * 8, ty * 8)
|
||||
local tile = self.tiles.border
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.push()
|
||||
G.scale(scale, scale)
|
||||
for ty = 0, tilesY do
|
||||
for tx = 0, tilesX do
|
||||
G.draw(tile, tx * 8, ty * 8)
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
end
|
||||
if self.palette and GbcPalette.available() then
|
||||
GbcPalette.with(self.palette, blit)
|
||||
else
|
||||
blit()
|
||||
end
|
||||
G.pop()
|
||||
end
|
||||
G.push()
|
||||
G.translate(ox, oy)
|
||||
|
||||
@@ -86,6 +86,10 @@ local ROWS = {
|
||||
--
|
||||
-- The two volume rows clamp at the ends rather than wrapping, the way
|
||||
-- pokered's text-speed cursor does, so holding left reaches OFF and stays.
|
||||
{ id = "controls", label = "CONTROLS", port = true,
|
||||
activate = function(game)
|
||||
require("src.ui.Screens").push(game, "BindingsMenu")
|
||||
end },
|
||||
{ label = "MUSIC VOL", key = "musicVol", port = true,
|
||||
cycle = function(options, delta)
|
||||
options.musicVol = stepVolume(options.musicVol, delta)
|
||||
@@ -168,6 +172,34 @@ local ROWS = {
|
||||
text = function(options)
|
||||
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
|
||||
end },
|
||||
{ id = "touchControls", label = "TOUCH PAD", port = true,
|
||||
text = function(options)
|
||||
local tc = options.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and "ON" or "OFF"
|
||||
end,
|
||||
cycle = function(options, _delta, game)
|
||||
local tc = type(options.touchControls) == "table" and options.touchControls or {}
|
||||
tc.enabled = tc.enabled == false
|
||||
options.touchControls = tc
|
||||
require("src.core.TouchControls"):applyOptions(options)
|
||||
if game and game.persistOptions then game:persistOptions() end
|
||||
end },
|
||||
{ id = "touchLayout", label = "TOUCH LAYOUT", port = true,
|
||||
activate = function(game)
|
||||
game.stack:push(require("src.ui.TouchControlsEditor").new(game))
|
||||
end },
|
||||
{ id = "haptics", label = "VIBRATION", port = true,
|
||||
text = function(options)
|
||||
return require("src.core.TouchControls").hapticLabel(options.haptics)
|
||||
end,
|
||||
cycle = function(options, delta, game)
|
||||
local TC = require("src.core.TouchControls")
|
||||
options.haptics = TC.cycleHaptics(options.haptics, delta)
|
||||
TC:applyOptions(options)
|
||||
TC.buzz(options.haptics)
|
||||
if game and game.persistOptions then game:persistOptions() end
|
||||
end },
|
||||
{ label = "CANCEL", cancel = true },
|
||||
}
|
||||
|
||||
@@ -192,11 +224,18 @@ local function sameRows(_, rows) return rows end
|
||||
-- COLOR) simply appear in the list the hook receives.
|
||||
local function buildRows()
|
||||
local rows = {}
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local showTouch = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
for i, row in ipairs(ROWS) do
|
||||
local copy = {}
|
||||
for key, value in pairs(row) do copy[key] = value end
|
||||
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
|
||||
rows[i] = copy
|
||||
if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout"
|
||||
and row.id ~= "haptics") then
|
||||
local copy = {}
|
||||
for key, value in pairs(row) do copy[key] = value end
|
||||
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
|
||||
rows[#rows + 1] = copy
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -287,6 +326,9 @@ end
|
||||
|
||||
function OptionsMenu:leave_()
|
||||
if self.onDone then self.onDone(self.options) end
|
||||
if self.game and self.game.stack and self.game.stack:top() == self then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
end
|
||||
|
||||
function OptionsMenu:update(_dt)
|
||||
|
||||
@@ -354,6 +354,12 @@ function PackMenu:useSelected()
|
||||
end
|
||||
return
|
||||
end
|
||||
-- engine/items/tmhm.asm:73
|
||||
local def = self.items and self.items[row.id]
|
||||
if def and def.teaches then
|
||||
self:openTeachParty(row)
|
||||
return
|
||||
end
|
||||
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
|
||||
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
|
||||
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
|
||||
@@ -586,6 +592,66 @@ function PackMenu:giveToSlot(slot, row)
|
||||
held:giveItem(row.id)
|
||||
end
|
||||
|
||||
-- engine/items/tmhm.asm:73
|
||||
function PackMenu:openTeachParty(row)
|
||||
local game = self.game
|
||||
local party = (self.save and self.save.party) or {}
|
||||
if #party == 0 then
|
||||
self.message = NO_POKEMON
|
||||
return
|
||||
end
|
||||
if not (game and game.stack) then return end
|
||||
if not pcall(Screens.get, game, "Gen2PartyMenu") then return end
|
||||
local def = self.items and self.items[row.id]
|
||||
local moveId = def and def.teaches
|
||||
local moves = game.data and game.data.moves
|
||||
local moveDef = moves and moves[moveId]
|
||||
local moveName = (moveDef and moveDef.name) or moveId
|
||||
self.staleRows = true
|
||||
Screens.push(game, "Gen2PartyMenu", {
|
||||
save = self.save,
|
||||
prompt = "teach",
|
||||
tmhm = { move = moveId },
|
||||
onCancel = function()
|
||||
game.stack:pop()
|
||||
self:rebuild()
|
||||
end,
|
||||
onChoose = function(_slot, mon)
|
||||
game.stack:pop()
|
||||
local species = game.data and game.data.pokemon
|
||||
and game.data.pokemon[mon.species]
|
||||
local allowed = false
|
||||
for _, id in ipairs((species and species.tmhm) or {}) do
|
||||
if id == moveId then allowed = true end
|
||||
end
|
||||
if not allowed then
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then
|
||||
if game.say then
|
||||
game:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
if not game.learnMoveOn then return end
|
||||
game:learnMoveOn(mon, moveId, function(learned)
|
||||
if not learned then return end
|
||||
if tostring(row.id):sub(1, 3) == "HM_" then return end
|
||||
require("src.core.gen2.Happiness").change(mon, "LEARNMOVE")
|
||||
if game.consumeItem then game:consumeItem(row.id) end
|
||||
self:rebuild()
|
||||
end)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
function PackMenu:update(_dt)
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
|
||||
@@ -22,6 +22,7 @@ local GbcPalette = require("src.render.GbcPalette")
|
||||
local HpBar = require("src.battle.gen2.HpBar")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
@@ -90,11 +91,20 @@ function PartyMenu.new(game, opts)
|
||||
self.save = save
|
||||
self.party = opts.party or (save and save.party) or {}
|
||||
local data = game and game.data or {}
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
for i = 1, #self.party do
|
||||
Mon.refreshStats(self.party[i], data)
|
||||
end
|
||||
self.icons = opts.icons or data.gen2Icons
|
||||
self.palettes = opts.palettes or data.gen2Palettes
|
||||
self.pokemon = opts.pokemon or data.pokemon
|
||||
self.prompt = PartyMenu.PROMPTS[opts.prompt or "choose"] or opts.prompt
|
||||
or PartyMenu.PROMPTS.choose
|
||||
-- engine/pokemon/party_menu.asm:297
|
||||
self.tmhm = opts.tmhm
|
||||
if self.tmhm and (opts.prompt == nil or opts.prompt == "teach") then
|
||||
self.prompt = PartyMenu.PROMPTS.teach
|
||||
end
|
||||
self.onChoose = opts.onChoose
|
||||
self.onCancel = opts.onCancel
|
||||
self.moves = opts.moves or data.moves
|
||||
@@ -469,7 +479,14 @@ function PartyMenu:update(_dt)
|
||||
elseif self.wantsSubmenu or self.wantsBattleSubmenu then
|
||||
self:openSubmenu()
|
||||
elseif self.onChoose then
|
||||
self.onChoose(self.index, self.party[self.index])
|
||||
local mon = self.party[self.index]
|
||||
if self.tmhm and mon and mon.isEgg then
|
||||
-- engine/items/tmhm.asm:104
|
||||
local world = self.game and self.game.world
|
||||
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
|
||||
return
|
||||
end
|
||||
self.onChoose(self.index, mon)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
self:storeCursor()
|
||||
@@ -674,6 +691,18 @@ function PartyMenu.rowFor(mon)
|
||||
}
|
||||
end
|
||||
|
||||
-- engine/pokemon/party_menu.asm:331
|
||||
function PartyMenu:tmhmAble(mon)
|
||||
if not mon or mon.isEgg then return nil end
|
||||
local move = self.tmhm and self.tmhm.move
|
||||
if not move then return nil end
|
||||
local species = self.pokemon and self.pokemon[mon.species]
|
||||
for _, id in ipairs((species and species.tmhm) or {}) do
|
||||
if id == move then return "ABLE" end
|
||||
end
|
||||
return "NOT ABLE"
|
||||
end
|
||||
|
||||
-- WritePartyMenuTilemap, jumptable entry by jumptable entry. Every coordinate
|
||||
-- below is the hlcoord the matching PARTYMENUQUALITY_* routine uses, and each
|
||||
-- steps 2 * SCREEN_WIDTH per mon:
|
||||
@@ -712,10 +741,15 @@ function PartyMenu:drawPanel()
|
||||
self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i))
|
||||
local row = PartyMenu.rowFor(mon)
|
||||
Chrome.print(row.name, 3, nameY)
|
||||
if row.hp then Chrome.print(row.hp, 13, nameY) end
|
||||
if self.tmhm then
|
||||
local able = self:tmhmAble(mon)
|
||||
if able then Chrome.print(able, 12, dataY) end
|
||||
else
|
||||
if row.hp then Chrome.print(row.hp, 13, nameY) end
|
||||
if row.hp then self:drawHpBar(mon, 11, dataY) end
|
||||
end
|
||||
if row.status then Chrome.print(row.status, 5, dataY) end
|
||||
if row.level then Chrome.print(row.level, 8, dataY) end
|
||||
if row.hp then self:drawHpBar(mon, 11, dataY) end
|
||||
end
|
||||
|
||||
-- .end does `dec hl` twice from the row past the last nickname, so CANCEL
|
||||
|
||||
@@ -188,6 +188,22 @@ function PokedexMenu.new(game, opts)
|
||||
self.dexPalette = gfx.palette
|
||||
end
|
||||
|
||||
-- pokegold engine/pokegear/pokegear.asm Pokedex_GetArea: the AREA page
|
||||
-- draws through the Pokegear's own town-map tiles and TownMapPals, not the
|
||||
-- dex's PokedexLZ sheet.
|
||||
local mapGfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
|
||||
self.mapGfx = mapGfx
|
||||
if mapGfx then
|
||||
self.mapSheet = TileSheet.new({
|
||||
path = mapGfx.tiles, wide = mapGfx.tilesWide or 16, firstTile = 0,
|
||||
paletteFor = function(tile)
|
||||
if not mapGfx.palettes then return nil end
|
||||
if tile >= 0x60 then return mapGfx.palettes[1] end
|
||||
return mapGfx.palettes[(mapGfx.palMap and mapGfx.palMap[tile + 1]) or 1]
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Pokedex_LoadUnownFont: 27 tiles at vTiles2 tile FIRST_UNOWN_CHAR, live
|
||||
-- only while UNOWN MODE is on screen. It is a sheet rather than a font
|
||||
-- page (see PokedexMenu:unownGlyph), and it draws through the dex palette
|
||||
@@ -793,7 +809,7 @@ end
|
||||
function PokedexMenu:playerLandmark()
|
||||
local save = self.game and self.game.save
|
||||
local mapId = save and save.position and save.position.map
|
||||
local def = mapId and self.data and self.data.maps and self.data.maps[mapId]
|
||||
local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId]
|
||||
return def and def.landmark
|
||||
end
|
||||
|
||||
@@ -803,11 +819,13 @@ end
|
||||
-- substitutes them), and borrowing Pokegear's would freeze the map's ink.
|
||||
function PokedexMenu:drawTilemap(cells)
|
||||
if type(cells) ~= "table" then return end
|
||||
local sheet = self.mapSheet
|
||||
if not sheet then return end
|
||||
local i = 1
|
||||
for ty = 0, Chrome.SCREEN_H - 1 do
|
||||
for tx = 0, Chrome.SCREEN_W - 1 do
|
||||
local id = cells[i]
|
||||
if id then self:tile(id, tx, ty) end
|
||||
if id then sheet:draw(id, tx, ty) end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
@@ -837,7 +855,7 @@ function PokedexMenu:drawArea()
|
||||
-- uses. Without it (a cache imported before the town map was extracted) the
|
||||
-- page still lists the landmark NAMES, which is the information the screen
|
||||
-- exists to convey.
|
||||
local maps = self.gfx and self.gfx.maps
|
||||
local maps = self.mapGfx and self.mapGfx.maps
|
||||
local cells = maps and maps[region]
|
||||
if cells then
|
||||
self:drawTilemap(cells)
|
||||
|
||||
+40
-15
@@ -1084,16 +1084,20 @@ function Pokegear:update(_dt)
|
||||
-- answers -1 rather than backing out to the strip.
|
||||
if self.fly then return self:updateFlyMap(input) end
|
||||
if self.mode == "strip" then
|
||||
if input:wasPressed("left") then
|
||||
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
|
||||
elseif input:wasPressed("right") then
|
||||
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.mode = "card"
|
||||
elseif input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
local stripCard = self:card()
|
||||
if not (stripCard and stripCard.id == "phone") then
|
||||
if input:wasPressed("left") then
|
||||
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
|
||||
elseif input:wasPressed("right") then
|
||||
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.mode = "card"
|
||||
elseif input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
end
|
||||
return
|
||||
end
|
||||
return
|
||||
self.mode = "card"
|
||||
end
|
||||
-- Inside a card.
|
||||
local card = self:card()
|
||||
@@ -1105,6 +1109,19 @@ function Pokegear:update(_dt)
|
||||
-- submenu is waiting for.
|
||||
local phoneBusy = card and card.id == "phone"
|
||||
and (self.call ~= nil or self.phoneSubmenu ~= nil)
|
||||
-- engine/pokegear/pokegear.asm:799
|
||||
if card and card.id == "phone" and not phoneBusy then
|
||||
if input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
return
|
||||
elseif input:wasPressed("left") then
|
||||
self:switchCard("map", "clock")
|
||||
return
|
||||
elseif input:wasPressed("right") then
|
||||
self:switchCard("radio")
|
||||
return
|
||||
end
|
||||
end
|
||||
if not phoneBusy and input:wasPressed("b") then
|
||||
self.mode = "strip"
|
||||
self:stopRadio()
|
||||
@@ -1537,6 +1554,9 @@ function Pokegear:callContact(id)
|
||||
text = self:phoneText("GearOutOfService") }
|
||||
return
|
||||
end
|
||||
-- pokegold engine/pokegear/pokegear.asm:883-889: SFX_CALL rings before the call connects.
|
||||
local world = self.game and self.game.world
|
||||
if world then world:playSfxNamed("Sfx_Call", 106) end
|
||||
local call = Phone.call(self.save, id, context)
|
||||
local name, className = Phone.contactName(id, self.trainers)
|
||||
call.name, call.className = name, className
|
||||
@@ -1562,6 +1582,11 @@ end
|
||||
|
||||
-- HangUp: the click, the boops, and back to "Whom do you want to call?".
|
||||
function Pokegear:hangUp()
|
||||
-- pokegold engine/phone/phone.asm:517-519: HangUp_Beep plays SFX_HANG_UP.
|
||||
if self.call and self.call.kind ~= "nosignal" then
|
||||
local world = self.game and self.game.world
|
||||
if world then world:playSfxNamed("Sfx_HangUp", 107) end
|
||||
end
|
||||
self.call = nil
|
||||
end
|
||||
|
||||
@@ -1768,14 +1793,13 @@ function Pokegear:loadArrowSheet()
|
||||
self.arrow = false
|
||||
local gfx = self.gfx
|
||||
if gfx and gfx.sprites then
|
||||
self:loadPlayerIcon()
|
||||
self.arrow = TileSheet.new({
|
||||
path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0,
|
||||
-- The icon strip's palette (cream / orange / brown / black), not BG
|
||||
-- palette 0's greys: the arrow is an OBJ and the cart tints it to match
|
||||
-- the card icons it points at. The extract carries the gear's BG
|
||||
-- palettes only, and palMap gives every icon-strip tile this same index,
|
||||
-- so it is the one that reproduces the cart rather than a guess.
|
||||
palette = gfx.palettes and (gfx.palettes[4] or gfx.palettes[1]),
|
||||
-- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's
|
||||
-- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED.
|
||||
palette = (self.playerIcon and self.playerIcon.objColors)
|
||||
or (gfx.palettes and gfx.palettes[1]),
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -2050,6 +2074,7 @@ function Pokegear:drawPlayerIcon(x, y)
|
||||
local beat = math.floor((self.iconTimer or 0) / 8)
|
||||
-- .OAMData_RedWalk (data/sprite_anims/oam.asm:314-319) hangs its four tiles
|
||||
-- at -8,-8; camY of -4 undoes the world's sprite lift.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.playerIcon:draw(x - 8, y - 8, 0, -4, "down",
|
||||
beat % 2, beat == 3)
|
||||
return true
|
||||
|
||||
@@ -261,6 +261,8 @@ function SummaryMenu.new(game, opts)
|
||||
math.min(opts.index or 1, math.max(1, #self.party)))
|
||||
self.mon = self.party[self.index]
|
||||
end
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
Mon.refreshStats(self.mon, data)
|
||||
self.page = opts.page or PINK_PAGE
|
||||
-- ManagePokemonMoves opens straight onto MoveScreenLoop's screen; SELECT off
|
||||
-- the green page reaches the same view with the stats pages still behind it.
|
||||
@@ -678,6 +680,8 @@ function SummaryMenu:switchMon(delta)
|
||||
if next_ < 1 or next_ > #self.party then return false end
|
||||
self.index = next_
|
||||
self.mon = self.party[next_]
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
Mon.refreshStats(self.mon, self.game and self.game.data)
|
||||
self.moveIndex = 1
|
||||
self:playCry()
|
||||
return true
|
||||
@@ -694,6 +698,8 @@ function SummaryMenu:switchMonPastEggs(delta)
|
||||
if next_ < 1 or next_ > #self.party then return false end
|
||||
self.index = next_
|
||||
self.mon = self.party[next_]
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
Mon.refreshStats(self.mon, self.game and self.game.data)
|
||||
self.moveIndex = 1
|
||||
self:playCry()
|
||||
return true
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
local Anim = require("src.core.gen2.TradeAnim")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Music = require("src.core.Music")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
@@ -202,6 +203,15 @@ local TEMPLATE_ROWS = {
|
||||
{ row = 6, text = "<ID>№." },
|
||||
}
|
||||
|
||||
-- gfx/sgb/predef.pal:29
|
||||
local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end
|
||||
local TRADE_TUBE_PAL = {
|
||||
{ scale5(31), scale5(31), scale5(31) },
|
||||
{ scale5(18), scale5(20), scale5(27) },
|
||||
{ scale5(11), scale5(15), scale5(23) },
|
||||
{ 0, 0, 0 },
|
||||
}
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Construction
|
||||
--------------------------------------------------------------------------
|
||||
@@ -367,8 +377,22 @@ end
|
||||
-- reading through.
|
||||
-- `index` is a GB shade, 0 (white) to 3 (black); GbcPalette.color counts from
|
||||
-- 1, as the palettes themselves do.
|
||||
function TradeAnimView:bgColors()
|
||||
local id = (self.beat or {}).id or ""
|
||||
local pan = id:find("_pan_", 1, true)
|
||||
if not pan then
|
||||
return Palettes.textColors(self.palettes)
|
||||
end
|
||||
local colors = TRADE_TUBE_PAL
|
||||
-- engine/movie/trade_animation.asm:1271
|
||||
if math.floor((self.frame or 0) / 8) % 2 == 1 then
|
||||
colors = { colors[1], colors[3], colors[2], colors[4] }
|
||||
end
|
||||
return colors
|
||||
end
|
||||
|
||||
function TradeAnimView:shade(index)
|
||||
local colors = Palettes.textColors(self.palettes)
|
||||
local colors = self:bgColors()
|
||||
local rgb = GbcPalette.color(colors, index + 1)
|
||||
or ({ { 255, 255, 255 }, { 168, 168, 168 }, { 96, 96, 96 },
|
||||
{ 0, 0, 0 } })[index + 1]
|
||||
@@ -384,7 +408,7 @@ end
|
||||
-- and GbcPalette maps them. A driver with no shader draws the greys, which
|
||||
-- is the DMG ramp and not a black frame.
|
||||
function TradeAnimView:through(body)
|
||||
local colors = Palettes.textColors(self.palettes)
|
||||
local colors = self:bgColors()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
@@ -538,8 +562,12 @@ function TradeAnimView:drawStats(record, offset)
|
||||
G.push()
|
||||
G.translate(offset, WINDOW_Y)
|
||||
Chrome.textbox(PANEL_X, PANEL_Y, PANEL_INNER_W, PANEL_INNER_H)
|
||||
-- pokegold engine/movie/trade_animation.asm:883-897,925-929: PlaceString
|
||||
-- and PrintNum overwrite the border's own tile at cols 4-12, row 0.
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", (PANEL_X + 1) * 8, PANEL_Y * 8, 9 * 8, 8)
|
||||
for _, row in ipairs(TEMPLATE_ROWS) do
|
||||
Chrome.print(row.text, PANEL_X + 1, row.row)
|
||||
Chrome.print(Strings(row.text), PANEL_X + 1, row.row)
|
||||
end
|
||||
Chrome.print(Chrome.number(record.dex or 0, 3, true), PANEL_X + 7, 0)
|
||||
Chrome.print(record.name, PANEL_X + 1, 2)
|
||||
@@ -763,6 +791,8 @@ end
|
||||
function TradeAnimView:drawPanel()
|
||||
local id = (self.beat or {}).id
|
||||
local t = self.offset or 0
|
||||
-- engine/movie/trade_animation.asm:151
|
||||
local wasBattle = Font.useBattleExtra(true)
|
||||
Chrome.clear()
|
||||
|
||||
if GIVE_BEATS[id] then
|
||||
@@ -790,6 +820,7 @@ function TradeAnimView:drawPanel()
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.useBattleExtra(wasBattle)
|
||||
end
|
||||
|
||||
function TradeAnimView:drawTubeBeat(id, t)
|
||||
|
||||
@@ -453,16 +453,27 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- walks out of the warp, not beside him (#863)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
-- keepMusicOnce is the play_music opts.keep one-shot of the same bit
|
||||
-- opts.keepMusic preserves the Oak-escort song across the lab warp,
|
||||
-- matching BIT_NO_MAP_MUSIC: MUSIC_MUSEUM_GUY in Yellow and
|
||||
-- MUSIC_MEET_PROF_OAK in Red/Blue. keepMusicOnce is the equivalent
|
||||
-- one-shot set by play_music opts.keep.
|
||||
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
||||
self.keepMusicOnce = nil
|
||||
if not keepMusic then
|
||||
-- ..(home/overworld.asm ln 2346)
|
||||
local Music = require("src.core.Music")
|
||||
-- opts.freshBoot: switch instantly instead of cross-fading, like every
|
||||
-- other map's PlayDefaultMusic on real hardware -- set only by
|
||||
-- Game.lua's hard state teleports (onContinue, New Game, F2,
|
||||
-- restoreCheckpointSave). Deliberately separate from opts.via ==
|
||||
-- "boot" itself: dev tooling (src/dev/Console.lua's warp verb,
|
||||
-- src/dev/HotReload.lua's reloadMap) reuses that same default for the
|
||||
-- surf-restore/fresh-npc-pool branches above and must keep the
|
||||
-- ordinary crossfade.
|
||||
local fade = Music.MAP_FADE
|
||||
if opts and opts.freshBoot then fade = nil end
|
||||
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||
Music.MAP_FADE)
|
||||
fade)
|
||||
end
|
||||
|
||||
-- forced bike/surf tiles fire the moment the player is placed on the
|
||||
@@ -3394,6 +3405,7 @@ function OverworldState:showMapText(textConst, npc, onDone)
|
||||
-- the winning contribution's rows run as their owner (09 §4.4): mod:
|
||||
-- field routing, strict dispatch and error reports all read the source
|
||||
self.runner:run(script, { npc = npc, onDone = onDone,
|
||||
checkpointOnDone = onDone and "release_npc" or nil,
|
||||
source = mapScripts.talkSource(self.map.id, textConst) })
|
||||
return
|
||||
end
|
||||
@@ -4066,6 +4078,30 @@ function OverworldState:restoreBattleContinuation(battle, origin)
|
||||
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||
return true
|
||||
end
|
||||
if origin.kind == "script_battle" then
|
||||
if origin.battleKind ~= battle.kind
|
||||
or (battle.kind == "trainer" and (origin.trainerClass ~= battle.oppClass
|
||||
or origin.partyIndex ~= (battle.partyIndex or 1)))
|
||||
or type(origin.script) ~= "table" or type(origin.pc) ~= "number" then
|
||||
return false
|
||||
end
|
||||
local npc = origin.npcId and self.npcPool and self.npcPool[origin.npcId] or nil
|
||||
if origin.npcId and not npc then return false end
|
||||
battle.onFinish = function(result)
|
||||
local runner = self.runner
|
||||
if not runner or runner:isRunning() then
|
||||
runner = ScriptRunner.new(game, self)
|
||||
self.runner = runner
|
||||
end
|
||||
runner:run(origin.script, {
|
||||
npc = npc,
|
||||
source = origin.source,
|
||||
resumeBattle = { result = result, battle = battle },
|
||||
}, origin.pc)
|
||||
end
|
||||
battle.checkpointScriptContinuation = true
|
||||
return true
|
||||
end
|
||||
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
|
||||
or origin.trainerClass ~= battle.oppClass
|
||||
or origin.partyIndex ~= (battle.partyIndex or 1)
|
||||
|
||||
@@ -69,6 +69,22 @@ local function mapTileRows(map)
|
||||
return rows, detailRows
|
||||
end
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
local runner = ow and ow.runner
|
||||
return ow and stack and stack.top and stack:top() == ow
|
||||
and not ow.transitioning and not ow.flyAnim and not ow.teleportOut
|
||||
and not ow.engaging and not ow.emote and not ow.pikaHop and not ow.healAnim
|
||||
and not (ow.player and (ow.player.moving or ow.player.inputLocked))
|
||||
and not (runner and runner.isRunning and runner:isRunning())
|
||||
and #(ow.scriptMoves or {}) == 0
|
||||
end
|
||||
|
||||
local function validPartySlot(party, slot)
|
||||
return type(slot) == "number" and slot == math.floor(slot)
|
||||
and party[slot] ~= nil
|
||||
end
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
end
|
||||
@@ -98,6 +114,31 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
-- Companion UIs may offer party ordering while the player is in free roam.
|
||||
-- The same guard that makes opening a menu safe keeps scripts, transitions,
|
||||
-- movement and screens above the overworld from observing a mid-action swap.
|
||||
function WorldAPI:canReorderParty()
|
||||
local game, ow = self.game, self:overworld()
|
||||
local party = game and game.save and game.save.party or {}
|
||||
return #party > 1 and not not acceptsMenuInput(game, ow)
|
||||
end
|
||||
|
||||
function WorldAPI:reorderParty(fromSlot, toSlot)
|
||||
local game, ow = self.game, self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
|
||||
local party = game.save and game.save.party or {}
|
||||
if not validPartySlot(party, fromSlot)
|
||||
or not validPartySlot(party, toSlot) then
|
||||
return nil, "invalid party slot"
|
||||
end
|
||||
if fromSlot ~= toSlot then
|
||||
party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot]
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
@@ -442,6 +442,27 @@ function NPC:updateTreeShake()
|
||||
return true
|
||||
end
|
||||
|
||||
function NPC:scriptRockSmash(frames)
|
||||
-- engine/overworld/map_objects.asm:1462
|
||||
self.rockSmash = {
|
||||
frame = 0,
|
||||
frames = frames or 10,
|
||||
}
|
||||
self.frozen = true
|
||||
return true
|
||||
end
|
||||
|
||||
function NPC:updateRockSmash()
|
||||
local st = self.rockSmash
|
||||
if not st then return false end
|
||||
st.frame = st.frame + 1
|
||||
if st.frame >= st.frames then
|
||||
self.rockSmash = nil
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- `passable` is the follower's escape (src/world/gen2/Follower.lua), the same
|
||||
-- name and meaning src/world/Collision.lua:20 gives it under Gen 1.
|
||||
local function occupied(entities, tx, ty, self)
|
||||
@@ -514,6 +535,10 @@ function NPC:update(map, entities)
|
||||
self:updateTreeShake()
|
||||
return
|
||||
end
|
||||
if self.rockSmash then
|
||||
self:updateRockSmash()
|
||||
return
|
||||
end
|
||||
-- NPC_CHANGE_FACING (src/world/NPC.lua:71): one walk cycle in place, no
|
||||
-- translation. Above the moving arm because it has no targetX to reach,
|
||||
-- and the arm below would assign cellX = nil a frame later.
|
||||
@@ -726,6 +751,15 @@ function NPC:draw(ox, oy, scale)
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
facing, 0, false, false, q == 3)
|
||||
elseif self.rockSmash then
|
||||
-- engine/overworld/map_objects.asm:1462
|
||||
if (self.rockSmash.frame % 2) == 0 then
|
||||
G.pop()
|
||||
return
|
||||
end
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
self.facing, self:walkPhase(), self.stepFlip)
|
||||
else
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
|
||||
@@ -175,6 +175,20 @@ function Permissions.currentDirection(coll)
|
||||
return CURRENT_DIR[coll % 4]
|
||||
end
|
||||
|
||||
-- DoPlayerMovement .CheckTile, HI_NYBBLE_WARPS arm (.warps): landing on a
|
||||
-- door/staircase/cave forces a walk DOWN off it (engine/overworld/player_movement.asm).
|
||||
local DOOR_FORCED = {
|
||||
[0x71] = true, -- COLL_DOOR
|
||||
[0x79] = true, -- COLL_DOOR_79 (unused)
|
||||
[0x7a] = true, -- COLL_STAIRCASE
|
||||
[0x7b] = true, -- COLL_CAVE
|
||||
}
|
||||
|
||||
function Permissions.doorForcedDirection(coll)
|
||||
if DOOR_FORCED[coll] then return "down" end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is
|
||||
-- allowed to swing at. Both grasses are in it, which is why CUT mows a patch
|
||||
-- of tall grass down to bare ground and not only trees.
|
||||
|
||||
@@ -17,6 +17,12 @@ local TURN_FRAMES = 4
|
||||
-- at the walking rate, which is what stops a bike step flickering the legs.
|
||||
Player.STEP_FRAMES = STEP_FRAMES
|
||||
|
||||
-- engine/overworld/map_objects.asm:1815
|
||||
local JUMP_Y = {
|
||||
-4, -6, -8, -10, -11, -12, -12, -12,
|
||||
-11, -10, -9, -8, -6, -4, 0, 0,
|
||||
}
|
||||
|
||||
function Player.new(cx, cy, facing, spriteDef)
|
||||
local self = setmetatable({
|
||||
cellX = cx, cellY = cy,
|
||||
@@ -140,6 +146,9 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
-- pokegold engine/overworld/map_objects.asm StepFunction_Turn: forces the
|
||||
-- walking leg frame for the whole 4-frame turn-in-place.
|
||||
if self.turnTimer > 0 then return 1 end
|
||||
if not self.moving then return 0 end
|
||||
local p = self.animClock % STEP_FRAMES
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -167,9 +176,11 @@ function Player:update()
|
||||
self.px = self.cellX * 16 + dx * adv
|
||||
self.py = self.cellY * 16 + dy * adv
|
||||
if self.jumping then
|
||||
-- The hop arc. Cosmetic: the grid position is the straight-line
|
||||
-- interpolation above, only the drawn pixels rise.
|
||||
self.py = self.py - math.floor(6 * math.sin(math.pi * self.progress / frames))
|
||||
-- engine/overworld/map_objects.asm:1815
|
||||
local idx = math.floor((self.progress - 1) / 2) + 1
|
||||
if idx < 1 then idx = 1 end
|
||||
if idx > #JUMP_Y then idx = #JUMP_Y end
|
||||
self.spriteYOffset = JUMP_Y[idx]
|
||||
end
|
||||
if self.progress >= frames then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
@@ -177,6 +188,7 @@ function Player:update()
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.jumping = nil
|
||||
self.spriteYOffset = 0
|
||||
self.stepFlip = not self.stepFlip
|
||||
return true
|
||||
end
|
||||
@@ -190,6 +202,15 @@ function Player:draw(ox, oy, scale)
|
||||
-- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off
|
||||
-- lift both ride this one byte.
|
||||
local yOffset = self.spriteYOffset or 0
|
||||
if self.jumping then
|
||||
-- engine/overworld/map_objects.asm:1995
|
||||
local gx = ox + self.px * scale
|
||||
local gy = oy + self.py * scale
|
||||
local s = 16 * scale
|
||||
G.setColor(0, 0, 0, 0.4)
|
||||
G.ellipse("fill", gx + s * 0.5, gy + s * 0.85, s * 0.35, s * 0.12)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.sprite then
|
||||
G.push()
|
||||
G.translate(ox, oy)
|
||||
|
||||
+200
-55
@@ -46,6 +46,7 @@ local Music = require("src.core.Music")
|
||||
local NPC = require("src.world.gen2.Npc")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
local Roamers = require("src.core.gen2.Roamers")
|
||||
@@ -620,6 +621,8 @@ function World.new(game)
|
||||
-- which is why a cut tree is back the next time you walk in. Restoring
|
||||
-- these at the top of setMap is that refill.
|
||||
blockEdits = {},
|
||||
-- engine/overworld/map_setup.asm:78
|
||||
objectSpawns = {},
|
||||
-- A field move that is mid-flow (the used-X text, then its effect).
|
||||
fieldMove = nil,
|
||||
-- ---- state the script VM owns ------------------------------------------
|
||||
@@ -1818,6 +1821,15 @@ function World:moveObject(objectId, cellX, cellY)
|
||||
local def = self.map and self.map.def
|
||||
local obj = def and def.objects and def.objects[index]
|
||||
if not (obj and cellX and cellY) then return end
|
||||
local mapId = self.map and self.map.id
|
||||
local key = obj.index or index
|
||||
if mapId then
|
||||
self.objectSpawns = self.objectSpawns or {}
|
||||
self.objectSpawns[mapId] = self.objectSpawns[mapId] or {}
|
||||
if not self.objectSpawns[mapId][key] then
|
||||
self.objectSpawns[mapId][key] = { obj.x, obj.y }
|
||||
end
|
||||
end
|
||||
obj.x, obj.y = cellX, cellY
|
||||
local npc = self:objectEntity(objectId)
|
||||
if npc and npc ~= self.player then
|
||||
@@ -3714,6 +3726,16 @@ function World:updateMovement()
|
||||
while st.i <= #st.bytes do
|
||||
local b = st.bytes[st.i]
|
||||
st.i = st.i + 1
|
||||
-- engine/overworld/movement.asm:163
|
||||
if b == 0x57 then
|
||||
local duration = st.bytes[st.i] or 0
|
||||
st.i = st.i + 1
|
||||
if ent.scriptRockSmash then
|
||||
ent:scriptRockSmash(duration)
|
||||
end
|
||||
st.sleep = duration
|
||||
return
|
||||
end
|
||||
local act = Movement.decodeByte(b)
|
||||
if act.kind == "end" then
|
||||
-- SLIDING_F is an object flag, not a stream one, so a stream that never
|
||||
@@ -5236,6 +5258,38 @@ function World:restoreBlocks()
|
||||
return any
|
||||
end
|
||||
|
||||
function World:restoreObjectSpawns()
|
||||
-- engine/overworld/map_setup.asm:78
|
||||
local spawns = self.objectSpawns
|
||||
if not spawns then return end
|
||||
for mapId, byIndex in pairs(spawns) do
|
||||
local def = self.maps and self.maps[mapId]
|
||||
local objects = def and def.objects
|
||||
if objects then
|
||||
for key, xy in pairs(byIndex) do
|
||||
local obj
|
||||
for _, row in ipairs(objects) do
|
||||
if (row.index or 0) == key then obj = row break end
|
||||
end
|
||||
if obj then
|
||||
obj.x, obj.y = xy[1], xy[2]
|
||||
end
|
||||
local npc = self.npcPool
|
||||
and self.npcPool[string.format("%s_obj_%d", mapId, key)]
|
||||
if npc then
|
||||
npc.cellX, npc.cellY = xy[1], xy[2]
|
||||
npc.px, npc.py = xy[1] * 16, xy[2] * 16
|
||||
npc.homeX, npc.homeY = xy[1], xy[2]
|
||||
npc.moving = false
|
||||
npc.progress = 0
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
spawns[mapId] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop the loaded map's baked canvases and bake again. Same shape as what
|
||||
-- pollTimeOfDay does when the clock rolls the palette over; a block edit
|
||||
-- invalidates the bake for the same reason a palette change does. A world with
|
||||
@@ -5359,8 +5413,10 @@ end
|
||||
-- picked, and TextBox reads it back off game.stringBuffer.
|
||||
function World:setNickname(mon)
|
||||
if not self.game then return end
|
||||
self.game.stringBuffer =
|
||||
(mon and (mon.nickname or mon.name or mon.species)) or ""
|
||||
local name = (mon and (mon.nickname or mon.name or mon.species)) or ""
|
||||
self.game.stringBuffer = name
|
||||
-- engine/events/overworld.asm:1339
|
||||
if self.vm then self.vm.stringBuffer = name end
|
||||
end
|
||||
|
||||
function World:playMonCry(mon)
|
||||
@@ -8303,6 +8359,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
|
||||
-- and WHIRLPOOL swapped out goes back: a cut tree is standing again the next
|
||||
-- time the map is loaded, and this has to happen before Map.new reads them.
|
||||
self:restoreBlocks()
|
||||
self:restoreObjectSpawns()
|
||||
-- HandleNewMap (home/map.asm:216-228) runs ResetMapBufferEventFlags before
|
||||
-- anything else that touches state: event flags 0-7
|
||||
-- (EVENT_TEMPORARY_UNTIL_MAP_RELOAD) die on every map load, which is what
|
||||
@@ -8783,7 +8840,8 @@ function World:tryLedgeJump(dir)
|
||||
-- ShakeGrass (engine/overworld/movement.asm:741-770).
|
||||
p.inGrass, p.grassShake = false, nil
|
||||
p.progress = 0
|
||||
p.stepFrames = Player.STEP_FRAMES
|
||||
-- engine/overworld/map_objects.asm:1163
|
||||
p.stepFrames = Player.STEP_FRAMES * 2
|
||||
self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE)
|
||||
return true
|
||||
end
|
||||
@@ -9555,11 +9613,13 @@ function World:stepBody()
|
||||
-- is a scripted step under World:busy, which returns above this line.
|
||||
local dir = self.heldDir
|
||||
if not p.moving then
|
||||
local current = Permissions.currentDirection(self:playerCollision())
|
||||
local coll = self:playerCollision()
|
||||
local current = Permissions.currentDirection(coll)
|
||||
or Permissions.doorForcedDirection(coll)
|
||||
if current then
|
||||
dir = current
|
||||
elseif self.turningDirection
|
||||
and Permissions.isIce(self:playerCollision()) then
|
||||
and Permissions.isIce(coll) then
|
||||
dir = self.turningDirection
|
||||
elseif not dir then
|
||||
self.turningDirection = nil
|
||||
@@ -9615,7 +9675,17 @@ function World:drawGround(s)
|
||||
-- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and
|
||||
-- the connection strips and the map draw straight over the top of it.
|
||||
if self.map then
|
||||
local bw, bh = G.getDimensions()
|
||||
-- Destination size must be the CURRENT canvas (tilt grows it past the
|
||||
-- window). getDimensions() is always the window, so a grown tilt capture
|
||||
-- used to tile the void against the wrong view and the fill drifted off
|
||||
-- the map grid as the camera moved.
|
||||
local canvas = G.getCanvas()
|
||||
local bw, bh
|
||||
if canvas then
|
||||
bw, bh = canvas:getDimensions()
|
||||
else
|
||||
bw, bh = G.getDimensions()
|
||||
end
|
||||
BorderFill.draw(self, self:borderImageFor(self.map.id),
|
||||
cam.x, cam.y, bw, bh, s, self.map.id)
|
||||
end
|
||||
@@ -9691,45 +9761,51 @@ function World:drawPeople(s, billboard)
|
||||
end
|
||||
end
|
||||
|
||||
if self.emote and self.emote.image then
|
||||
local e = self.emote
|
||||
local ex = math.floor((e.entity.px - cam.x) * s)
|
||||
local ey = math.floor((e.entity.py - 16 - cam.y) * s)
|
||||
-- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the
|
||||
-- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the
|
||||
-- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB
|
||||
-- 13,13,13 / black). That row is byte-identical in all four daytime
|
||||
-- blocks, so the bubble is the same at any hour, but it still goes through
|
||||
-- the daytime lookup because that is what LoadMapPals does and it keeps the
|
||||
-- emote on the same path as every other OW sprite. Blitting the extracted
|
||||
-- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead
|
||||
-- of white: the Gen 2 repeat of #505.
|
||||
local emoteColors = Palettes.spritePalette(self.palettes,
|
||||
self.daytime or Palettes.daytimeFor(self.map and self.map.def,
|
||||
self:hour(), self.flashUsed),
|
||||
{ paletteId = 5 })
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(e.image, ex, ey, 0, s, s)
|
||||
end
|
||||
local function body()
|
||||
-- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still
|
||||
-- have to collapse the row to their own ramps, and it restores whatever
|
||||
-- shader the billboard pass had set rather than assuming none.
|
||||
if emoteColors and GbcPalette.available() then
|
||||
GbcPalette.with(emoteColors, blit)
|
||||
else
|
||||
blit()
|
||||
end
|
||||
end
|
||||
if billboard then
|
||||
billboard(ex + 8 * s, ey + 32 * s, body)
|
||||
self:drawEmote(s, billboard)
|
||||
self:drawHealAnim(s, billboard)
|
||||
end
|
||||
|
||||
-- Split out of drawPeople so World:drawPipeline composites the one copy the
|
||||
-- flat and tilt paths draw, not a second transcription of it.
|
||||
function World:drawEmote(s, billboard)
|
||||
if not (self.emote and self.emote.image) then return end
|
||||
local G = love.graphics
|
||||
local cam = self.camera
|
||||
local e = self.emote
|
||||
local ex = math.floor((e.entity.px - cam.x) * s)
|
||||
local ey = math.floor((e.entity.py - 16 - cam.y) * s)
|
||||
-- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the
|
||||
-- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the
|
||||
-- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB
|
||||
-- 13,13,13 / black). That row is byte-identical in all four daytime
|
||||
-- blocks, so the bubble is the same at any hour, but it still goes through
|
||||
-- the daytime lookup because that is what LoadMapPals does and it keeps the
|
||||
-- emote on the same path as every other OW sprite. Blitting the extracted
|
||||
-- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead
|
||||
-- of white: the Gen 2 repeat of #505.
|
||||
local emoteColors = Palettes.spritePalette(self.palettes,
|
||||
self.daytime or Palettes.daytimeFor(self.map and self.map.def,
|
||||
self:hour(), self.flashUsed),
|
||||
{ paletteId = 5 })
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(e.image, ex, ey, 0, s, s)
|
||||
end
|
||||
local function body()
|
||||
-- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still
|
||||
-- have to collapse the row to their own ramps, and it restores whatever
|
||||
-- shader the billboard pass had set rather than assuming none.
|
||||
if emoteColors and GbcPalette.available() then
|
||||
GbcPalette.with(emoteColors, blit)
|
||||
else
|
||||
body()
|
||||
blit()
|
||||
end
|
||||
end
|
||||
|
||||
self:drawHealAnim(s, billboard)
|
||||
if billboard then
|
||||
billboard(ex + 8 * s, ey + 32 * s, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
end
|
||||
|
||||
function World:drawWorldBody(s)
|
||||
@@ -9737,6 +9813,52 @@ function World:drawWorldBody(s)
|
||||
self:drawPeople(s)
|
||||
end
|
||||
|
||||
-- Gold's half of the world-pipeline seam: same ctx keys, same order and the
|
||||
-- same nil-falls-back-to-2D rule as src/world/OverworldController.lua:4867.
|
||||
function World:drawPipeline(id, w, h, s)
|
||||
local G = love.graphics
|
||||
local cam = self.camera
|
||||
local ctx = {
|
||||
state = self, cam = cam,
|
||||
vw = self.viewW, vh = self.viewH,
|
||||
-- No BG-only shake here: World:draw slides the whole frame through
|
||||
-- camera.y, so the ground row IS the camera row.
|
||||
bgY = cam.y,
|
||||
width = w, height = h, scale = s,
|
||||
level = Pipelines.level(id),
|
||||
-- imageFor keys its bakes by GbcPalette.mode, so the colour is already in
|
||||
-- the art: nil, like Gen 1 returns in its true-colour modes.
|
||||
paletteFor = function() return nil end,
|
||||
spriteColors = function() return nil end,
|
||||
-- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay,
|
||||
-- and Gen 1's `at` skips a nil body, so those keys are simply absent.
|
||||
fx = {
|
||||
emote = function() self:drawEmote(1, nil) end,
|
||||
heal = function() self:drawHealAnim(1, nil) end,
|
||||
},
|
||||
}
|
||||
-- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the
|
||||
-- closures out in world pixels off the flat foot, the unit Gen 1 uses.
|
||||
ctx.drawFx = function(project, scale)
|
||||
scale = scale or s
|
||||
local function at(fx, fy, body)
|
||||
local sx, sy = project(fx + cam.x, fy + cam.y)
|
||||
if not sx then return end -- behind the camera
|
||||
G.push()
|
||||
G.scale(scale, scale)
|
||||
G.translate(sx / scale - fx, sy / scale - fy)
|
||||
body()
|
||||
G.pop()
|
||||
end
|
||||
self:drawEmote(1, at)
|
||||
self:drawHealAnim(1, at)
|
||||
end
|
||||
local override = Pipelines.drawWorld(id, ctx)
|
||||
-- world post-processes fold in here, so they never touch the text box on top
|
||||
if override then override = Pipelines.worldPresent(override, ctx) end
|
||||
return override
|
||||
end
|
||||
|
||||
-- The perspective quad TILT draws the ground onto. The shader and the
|
||||
-- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1
|
||||
-- world pass uses, so there is no reason for a second copy of either.
|
||||
@@ -9748,21 +9870,25 @@ function World:tiltMesh()
|
||||
return mesh, shader
|
||||
end
|
||||
|
||||
function World:drawTilted(w, h, s)
|
||||
-- `gw, gh` are the grown capture size from World:draw (Tilt.viewGrowth),
|
||||
-- matching Renderer:worldViewSize. Camera is already followed for that view.
|
||||
function World:drawTilted(w, h, s, gw, gh)
|
||||
local mesh, shader = self:tiltMesh()
|
||||
if not mesh then
|
||||
self:drawWorldBody(s)
|
||||
return
|
||||
end
|
||||
local G = love.graphics
|
||||
gw = gw or w
|
||||
gh = gh or h
|
||||
-- Linear sampling on the tilt canvas softens the shimmer the perspective
|
||||
-- warp would otherwise put on every pixel edge; the flat path keeps nearest.
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= w
|
||||
or self.tiltCanvas:getHeight() ~= h then
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= gw
|
||||
or self.tiltCanvas:getHeight() ~= gh then
|
||||
if self.tiltCanvas and self.tiltCanvas.release then
|
||||
self.tiltCanvas:release()
|
||||
end
|
||||
self.tiltCanvas = G.newCanvas(w, h)
|
||||
self.tiltCanvas = G.newCanvas(gw, gh)
|
||||
self.tiltCanvas:setFilter("linear", "linear")
|
||||
end
|
||||
|
||||
@@ -9778,11 +9904,14 @@ function World:drawTilted(w, h, s)
|
||||
G.setCanvas(previous)
|
||||
|
||||
mesh:setTexture(self.tiltCanvas)
|
||||
mesh:setVertices(Tilt.meshCorners(w, h))
|
||||
mesh:setVertices(Tilt.meshCorners(gw, gh))
|
||||
G.push()
|
||||
G.translate((w - gw) / 2, (h - gh) / 2)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.setShader(shader)
|
||||
G.draw(mesh)
|
||||
G.setShader()
|
||||
G.pop()
|
||||
|
||||
-- ...and the standing things over it, each translated from its flat foot
|
||||
-- onto that foot's projection. Nothing here is sheared or resized: tilt
|
||||
@@ -9791,10 +9920,10 @@ function World:drawTilted(w, h, s)
|
||||
-- The ground quad carries the flat canvas and nothing else, so a foot
|
||||
-- outside it has no ground under it; drawing it anyway put NPCs from two
|
||||
-- screens away over the border fill, where the map stops being drawn.
|
||||
if not Tilt.onGround(fx, fy, w, h, 32 * s) then return end
|
||||
local sx, sy = Tilt.groundPoint(fx, fy, w, h)
|
||||
if not Tilt.onGround(fx, fy, gw, gh, 32 * s) then return end
|
||||
local sx, sy = Tilt.groundPoint(fx, fy, gw, gh)
|
||||
G.push()
|
||||
G.translate(sx - fx, sy - fy)
|
||||
G.translate(sx - fx + (w - gw) / 2, sy - fy + (h - gh) / 2)
|
||||
body()
|
||||
G.pop()
|
||||
end)
|
||||
@@ -9832,8 +9961,18 @@ function World:draw()
|
||||
end
|
||||
|
||||
local s = self:zoomScale()
|
||||
local vw = math.ceil(w / s)
|
||||
local vh = math.ceil(h / s)
|
||||
-- Decided before sizing the view: a world pipeline wins over tilt, and tilt
|
||||
-- grows the capture the way Renderer:worldViewSize does on Gen 1 so the
|
||||
-- camera, BorderFill and tilt canvas all share one grid.
|
||||
local pipelineId = Pipelines.worldPipeline()
|
||||
local tilt = (not pipelineId) and Tilt.active() and self:tiltMesh() ~= nil
|
||||
local gw, gh = w, h
|
||||
if tilt then
|
||||
local g = Tilt.viewGrowth()
|
||||
gw, gh = math.ceil(w * g), math.ceil(h * g)
|
||||
end
|
||||
local vw = math.ceil(gw / s)
|
||||
local vh = math.ceil(gh / s)
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||
if vw ~= self.viewW or vh ~= self.viewH then
|
||||
@@ -9850,12 +9989,18 @@ function World:draw()
|
||||
self.camera.y = self.camera.y + (self.shake.phase or 0)
|
||||
end
|
||||
|
||||
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
|
||||
|
||||
-- TILT projects the finished world frame, so with it on the map, people and
|
||||
-- emote go into a canvas first and that canvas is drawn as a perspective
|
||||
-- quad. Everything after -- the encounter pic and the survey HUD -- stays
|
||||
-- flat, the same split the Gen 1 renderer makes.
|
||||
if Tilt.active() and self:tiltMesh() then
|
||||
self:drawTilted(w, h, s)
|
||||
-- flat, the same split the Gen 1 renderer makes; a pipeline's finished image
|
||||
-- lands in exactly the same place.
|
||||
if override then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(override, 0, 0)
|
||||
elseif tilt then
|
||||
self:drawTilted(w, h, s, gw, gh)
|
||||
else
|
||||
self:drawWorldBody(s)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
-- Gold's world-pipeline seam (World:drawPipeline), the Gen 2 peer of the
|
||||
-- render_pipelines path src/world/OverworldController.lua:4867 gives Gen 1.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_pipeline_shots.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-pipeline (default)
|
||||
--
|
||||
-- Registers a pipeline that paints an unmistakable magenta field, switches it
|
||||
-- on, and asserts what the seam is supposed to guarantee: drawWorld owns the
|
||||
-- frame, ctx carries the Gen 1 keys, ctx.drawFx anchors the standing FX,
|
||||
-- worldPresent folds over the result, tilt is forced off, and a declined
|
||||
-- frame falls back to the vanilla 2D draw instead of a blank screen.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pipeline"
|
||||
local failures = 0
|
||||
|
||||
local function ok(label, condition, detail)
|
||||
if condition then
|
||||
print("[pipeline] ok " .. label)
|
||||
else
|
||||
failures = failures + 1
|
||||
print("[pipeline] FAIL " .. label .. " " .. tostring(detail))
|
||||
end
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world:setMap("ROUTE_30", 10, 10, "down")
|
||||
U.wait(10)
|
||||
U.shot(game, out .. "/00-vanilla.png")
|
||||
|
||||
-- ---------------------------------------------------------------- record
|
||||
local seen, canvas = {}, nil
|
||||
local decline = false
|
||||
local pipeline = {
|
||||
label = "TESTPIPE",
|
||||
levels = { "OFF", "ON" },
|
||||
drawWorld = function(ctx)
|
||||
seen.ctx = ctx
|
||||
seen.drawWorld = (seen.drawWorld or 0) + 1
|
||||
if decline then return nil end
|
||||
local G = love.graphics
|
||||
local w, h = ctx.width, ctx.height
|
||||
if not canvas or canvas:getWidth() ~= w or canvas:getHeight() ~= h then
|
||||
canvas = G.newCanvas(w, h)
|
||||
end
|
||||
local previous = G.getCanvas()
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setCanvas(canvas)
|
||||
G.clear(0.8, 0.1, 0.6, 1)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for x = 0, w, 32 do G.rectangle("fill", x, 0, 1, h) end
|
||||
for y = 0, h, 32 do G.rectangle("fill", 0, y, w, 1) end
|
||||
-- the standing FX, anchored under this pipeline's own (identity) camera
|
||||
ctx.drawFx(function(wx, wy)
|
||||
return (wx - ctx.cam.x) * ctx.scale, (wy - ctx.cam.y) * ctx.scale
|
||||
end, ctx.scale)
|
||||
G.setCanvas(previous)
|
||||
G.pop()
|
||||
seen.drew = true
|
||||
return canvas
|
||||
end,
|
||||
worldPresent = function(image, ctx)
|
||||
seen.worldPresent = (seen.worldPresent or 0) + 1
|
||||
seen.presentCtx = ctx
|
||||
return image
|
||||
end,
|
||||
}
|
||||
|
||||
-- A fresh table so Pipelines.list()'s identity-keyed memo re-sorts; the
|
||||
-- mod merge hands it a new one for the same reason.
|
||||
game.data.render_pipelines = { testpipe = pipeline }
|
||||
Pipelines.install(game.data)
|
||||
ok("registered", Pipelines.get("testpipe") ~= nil, "not in the registry")
|
||||
|
||||
-- ------------------------------------------------------------ switched on
|
||||
Tilt.setLevel(1)
|
||||
Pipelines.setLevel("testpipe", 1)
|
||||
ok("tilt forced off", Tilt.level == 0, "tilt still " .. tostring(Tilt.level))
|
||||
ok("world pipeline claimed", Pipelines.worldPipeline() == "testpipe",
|
||||
tostring(Pipelines.worldPipeline()))
|
||||
U.wait(5)
|
||||
U.shot(game, out .. "/01-pipeline-on.png")
|
||||
|
||||
ok("drawWorld ran", (seen.drawWorld or 0) > 0, "never called")
|
||||
ok("drawWorld drew", seen.drew == true, "declined every frame")
|
||||
ok("worldPresent ran", (seen.worldPresent or 0) > 0, "never called")
|
||||
|
||||
local ctx = seen.ctx
|
||||
ok("ctx.state is the world", ctx and ctx.state == world, "wrong state")
|
||||
ok("ctx.cam is the camera", ctx and ctx.cam == world.camera, "wrong camera")
|
||||
ok("ctx.scale is zoomScale", ctx and ctx.scale == world:zoomScale(),
|
||||
ctx and tostring(ctx.scale))
|
||||
ok("ctx.bgY is the camera row", ctx and ctx.bgY == world.camera.y,
|
||||
ctx and tostring(ctx.bgY))
|
||||
ok("ctx.vw/vh are the view", ctx and ctx.vw == world.viewW
|
||||
and ctx.vh == world.viewH, ctx and tostring(ctx.vw))
|
||||
ok("ctx.level is the ladder", ctx and ctx.level == 1, ctx and tostring(ctx.level))
|
||||
ok("ctx.width/height are the window",
|
||||
ctx and ctx.width == love.graphics.getWidth()
|
||||
and ctx.height == love.graphics.getHeight(), "mismatch")
|
||||
ok("ctx.paletteFor is nil-valued (art is baked)",
|
||||
ctx and ctx.paletteFor and ctx.paletteFor(world.map) == nil, "returned colours")
|
||||
ok("ctx.spriteColors is nil-valued",
|
||||
ctx and ctx.spriteColors and ctx.spriteColors() == nil, "returned colours")
|
||||
ok("ctx.fx has Gold's two effects",
|
||||
ctx and ctx.fx and type(ctx.fx.emote) == "function"
|
||||
and type(ctx.fx.heal) == "function", "missing fx")
|
||||
ok("ctx.drawFx is callable", ctx and type(ctx.drawFx) == "function", "missing")
|
||||
ok("worldPresent got the same ctx", seen.presentCtx == seen.ctx, "different ctx")
|
||||
|
||||
-- ------------------------------------------------- the FX composite path
|
||||
-- An emote over the player exercises ctx.drawFx end to end: it must be the
|
||||
-- pipeline that composites it, and the vanilla drawPeople must not also.
|
||||
local sheet
|
||||
for _, img in pairs(world.emoteImages or {}) do sheet = img break end
|
||||
if sheet then
|
||||
world.emote = { image = sheet, entity = world.player, left = 240 }
|
||||
end
|
||||
U.wait(4)
|
||||
ok("emote is up", world.emote ~= nil, "no emote sheet loaded")
|
||||
local fxOk = pcall(function()
|
||||
-- the same call the pipeline made, run again outside the guard so a throw
|
||||
-- surfaces here rather than only retiring the pipeline
|
||||
seen.ctx.drawFx(function(wx, wy) return wx, wy end, 1)
|
||||
end)
|
||||
ok("drawFx composites without throwing", fxOk, "threw")
|
||||
U.shot(game, out .. "/02-pipeline-emote.png")
|
||||
|
||||
-- ----------------------------------------------------- a declined frame
|
||||
decline = true
|
||||
U.wait(5)
|
||||
U.shot(game, out .. "/03-pipeline-declined.png")
|
||||
ok("declined frames still call drawWorld", (seen.drawWorld or 0) > 1, "stopped")
|
||||
decline = false
|
||||
|
||||
-- ------------------------------------------------------------ switched off
|
||||
Pipelines.setLevel("testpipe", 0)
|
||||
local before = seen.drawWorld
|
||||
U.wait(5)
|
||||
ok("off means not called", seen.drawWorld == before,
|
||||
"still drawing at level 0")
|
||||
U.shot(game, out .. "/04-pipeline-off.png")
|
||||
|
||||
if failures == 0 then
|
||||
print("[pipeline] PASS")
|
||||
else
|
||||
print("[pipeline] FAILURES: " .. failures)
|
||||
end
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
-- In-game Gold SAVE with no launcher slot must leave a file CONTINUE can see.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local before = SaveData.listSlots("gold")
|
||||
local hadFile = false
|
||||
for _, slot in ipairs(before) do
|
||||
if slot.exists then hadFile = true break end
|
||||
end
|
||||
if not hadFile then
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots.gold = nil
|
||||
SaveData.saveOptions(opts)
|
||||
SaveData.resetSlotState()
|
||||
end
|
||||
|
||||
local ok, err = game:writeSave()
|
||||
if not ok then
|
||||
U.log("FAIL gold writeSave:", tostring(err))
|
||||
else
|
||||
local after = SaveData.listSlots("gold")
|
||||
local found, path
|
||||
for _, slot in ipairs(after) do
|
||||
if slot.exists then
|
||||
found = slot.id
|
||||
path = SaveData.slotDiskPath("gold", slot.id)
|
||||
break
|
||||
end
|
||||
end
|
||||
if found then
|
||||
U.log("PASS gold save is launcher-visible:", found, path or "")
|
||||
else
|
||||
U.log("FAIL gold save wrote but listSlots has no file")
|
||||
end
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Herb shop intro over the Goldenrod Underground map (#1169).
|
||||
-- pokegold engine/items/mart.asm:54 HerbShop, maps/GoldenrodUnderground.asm:158
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR")
|
||||
or "/tmp/bug1169"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
-- maps/GoldenrodUnderground.asm:52
|
||||
world.clockDay = 0
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
-- maps/GoldenrodUnderground.asm:679
|
||||
assert(world:setMap("GOLDENROD_UNDERGROUND", 6, 21, "right"),
|
||||
"setMap GOLDENROD_UNDERGROUND failed")
|
||||
U.wait(8)
|
||||
|
||||
local granny
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_GRANNY" then granny = npc break end
|
||||
end
|
||||
if not granny then
|
||||
U.log("FAIL granny not on the map (need Sunday)")
|
||||
end
|
||||
|
||||
local MartMenu = require("src.ui.gen2.MartMenu")
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
for _ = 1, 90 do
|
||||
if getmetatable(game.stack:top()) == MartMenu then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= MartMenu then
|
||||
U.log("FAIL mart did not open")
|
||||
elseif top.isOpaque then
|
||||
U.log("FAIL mart is opaque")
|
||||
else
|
||||
U.log("herb shop intro over the map")
|
||||
end
|
||||
U.shot(game, out .. "/herbshop_intro.png")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Kurt stays at moveobject (11, 6) for the visit, then ROM spawn (16, 14)
|
||||
-- on the next load.
|
||||
-- #1184
|
||||
-- maps/SlowpokeWellB1F.asm:48 / engine/overworld/map_setup.asm:78
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local KURT_ID = 8
|
||||
local ROM_X, ROM_Y = 16, 14
|
||||
local MOVE_X, MOVE_Y = 11, 6
|
||||
local FLAG_KURT = 1856
|
||||
local FLAG_ROCKETS = 1788
|
||||
|
||||
local function findKurt(world)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_KURT" then return npc end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function kurtDef(world)
|
||||
local objects = world.map and world.map.def and world.map.def.objects
|
||||
return objects and objects[KURT_ID - 1]
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
world.mapScenes.PLAYERS_HOUSE_1F = 1
|
||||
world.mapScenes.NEW_BARK_TOWN = 1
|
||||
world.events:set(FLAG_KURT, false)
|
||||
world.events:set(FLAG_ROCKETS, true)
|
||||
|
||||
assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"),
|
||||
"SLOWPOKE_WELL_B1F did not load")
|
||||
U.wait(4)
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function claim(ok, text)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", text)
|
||||
end
|
||||
|
||||
local kurt = findKurt(world)
|
||||
claim(kurt ~= nil, "Kurt is on the well map")
|
||||
claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y,
|
||||
("Kurt ROM spawn is (%d, %d), saw (%s, %s)")
|
||||
:format(ROM_X, ROM_Y,
|
||||
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
|
||||
|
||||
world:moveObject(KURT_ID, MOVE_X, MOVE_Y)
|
||||
world:appearObject(KURT_ID)
|
||||
U.wait(4)
|
||||
|
||||
kurt = findKurt(world)
|
||||
claim(kurt and kurt.cellX == MOVE_X and kurt.cellY == MOVE_Y,
|
||||
("after moveobject Kurt is at (%d, %d), saw (%s, %s)")
|
||||
:format(MOVE_X, MOVE_Y,
|
||||
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
|
||||
|
||||
assert(world:setMap("PLAYERS_HOUSE_1F", 3, 3, "down"),
|
||||
"PLAYERS_HOUSE_1F did not load")
|
||||
U.wait(2)
|
||||
world.events:set(FLAG_KURT, false)
|
||||
assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"),
|
||||
"SLOWPOKE_WELL_B1F did not reload")
|
||||
U.wait(4)
|
||||
|
||||
local def = kurtDef(world)
|
||||
kurt = findKurt(world)
|
||||
claim(def and def.x == ROM_X and def.y == ROM_Y,
|
||||
("reload restored Kurt def to (%d, %d), saw (%s, %s)")
|
||||
:format(ROM_X, ROM_Y,
|
||||
tostring(def and def.x), tostring(def and def.y)))
|
||||
claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y,
|
||||
("reload put Kurt at (%d, %d), not the blocking cell (%d, %d); saw (%s, %s)")
|
||||
:format(ROM_X, ROM_Y, MOVE_X, MOVE_Y,
|
||||
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
U.log("Kurt should be at the well entrance, not on the inner path.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Fury Attack / Barrage hit 2-5 times, Twineedle stays at 2. Issue #1168.
|
||||
-- pokegold engine/battle/effect_commands.asm:5228 (BattleCommand_EndLoop).
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love .
|
||||
-- Do not add POKEPORT_SPEED: the per-strike HP steps are what you are judging.
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Effects = require("src.battle.gen2.Effects")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function claim(ok, text)
|
||||
print((ok and "[multihit] PASS " or "[multihit] FAIL ") .. text)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
if not (world and world.map) then
|
||||
print("[multihit] FAIL gold world did not boot")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local fury = game.data.moves and game.data.moves.FURY_ATTACK
|
||||
local twine = game.data.moves and game.data.moves.TWINEEDLE
|
||||
claim(fury ~= nil, "FURY_ATTACK is in the move table")
|
||||
claim(fury and fury.effect == "EFFECT_MULTI_HIT",
|
||||
"FURY_ATTACK is EFFECT_MULTI_HIT")
|
||||
claim(twine ~= nil, "TWINEEDLE is in the move table")
|
||||
claim(twine and twine.effect == "EFFECT_POISON_MULTI_HIT",
|
||||
"TWINEEDLE is EFFECT_POISON_MULTI_HIT")
|
||||
claim(Effects.hitCount("EFFECT_DOUBLE_HIT") == 2, "DOUBLE_HIT is 2")
|
||||
claim(Effects.hitCount("EFFECT_POISON_MULTI_HIT") == 2,
|
||||
"POISON_MULTI_HIT (Twineedle) is 2")
|
||||
|
||||
local seq, si = { 0, 1, 2, 0, 3, 3 }, 0
|
||||
local function scripted()
|
||||
si = si + 1
|
||||
return seq[si] or 0
|
||||
end
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2, "roll 0 -> 2 hits")
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 3, "roll 1 -> 3 hits")
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2,
|
||||
"roll 2 then 0 -> 2 hits")
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 5,
|
||||
"roll 3 then 3 -> 5 hits")
|
||||
|
||||
local function fiveHitRandom(n)
|
||||
if n == 4 then return 3 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local headP = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
local headW = Mon.new(game.data, "SNORLAX", 20)
|
||||
if headP and headW then
|
||||
headP.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } }
|
||||
local b = Battle.new({
|
||||
data = game.data, party = { headP }, wild = headW,
|
||||
random = fiveHitRandom,
|
||||
})
|
||||
local landed, hitLine = 0, nil
|
||||
for _, ev in ipairs(b:takeTurn({ kind = "move", move = "FURY_ATTACK" })) do
|
||||
if ev.kind == "damage" and ev.side == "enemy" then
|
||||
landed = landed + 1
|
||||
end
|
||||
if ev.kind == "message" and ev.text then
|
||||
local n = ev.text:match("Hit (%d+) time")
|
||||
if n then hitLine, landed = ev.text, tonumber(n) end
|
||||
end
|
||||
end
|
||||
claim(landed == 5,
|
||||
("scripted Fury Attack hit %d times (want 5, not 2)"):format(landed))
|
||||
if hitLine then print("[multihit] " .. hitLine) end
|
||||
else
|
||||
claim(false, "could not build a headless Fury Attack pair")
|
||||
end
|
||||
|
||||
local player = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
if not player then
|
||||
print("[multihit] FAIL could not build CYNDAQUIL")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
player.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } }
|
||||
game.save.party = { player }
|
||||
|
||||
local wild = Mon.new(game.data, "SNORLAX", 20)
|
||||
if not wild then
|
||||
print("[multihit] FAIL could not build SNORLAX")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
if not world:startBattle({ wild = wild }) then
|
||||
print("[multihit] FAIL startBattle failed")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local screen
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then screen = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not (screen and screen.battle) then
|
||||
print("[multihit] FAIL battle screen never came up")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local left = 2
|
||||
screen.battle.random = function(n)
|
||||
if n == 4 and left > 0 then
|
||||
left = left - 1
|
||||
return 3
|
||||
end
|
||||
if left > 0 then return 0 end
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(n) - 1
|
||||
end
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
if screen.phase ~= "menu" then
|
||||
print("[multihit] FAIL never reached the battle menu")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
tap("a")
|
||||
U.wait(6)
|
||||
tap("a")
|
||||
print("[multihit] Fury Attack should strike five times on this turn.")
|
||||
print("[multihit] later turns are 2-5. Twineedle would stay at 2.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Radio Tower 5F director returns to the office after a downstairs/upstairs
|
||||
-- reload, not on the stair warp at (12, 0).
|
||||
-- #1164 / #1188
|
||||
-- maps/RadioTower5F.asm:115 / engine/overworld/map_setup.asm:78
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local DIRECTOR_ID = 2
|
||||
local OFFICE_X, OFFICE_Y = 3, 6
|
||||
local STAIR_X, STAIR_Y = 12, 0
|
||||
local FLAG_ROCKETS = 1742
|
||||
local FLAG_CIVILIANS = 1744
|
||||
|
||||
local function findDirector(world)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_GENTLEMAN" then return npc end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function directorDef(world)
|
||||
local objects = world.map and world.map.def and world.map.def.objects
|
||||
return objects and objects[DIRECTOR_ID - 1]
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
world.mapScenes.RADIO_TOWER_5F = 2
|
||||
world.mapScenes.RADIO_TOWER_4F = 0
|
||||
world.events:set(FLAG_ROCKETS, true)
|
||||
world.events:set(FLAG_CIVILIANS, false)
|
||||
|
||||
assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"),
|
||||
"RADIO_TOWER_5F did not load")
|
||||
U.wait(4)
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function claim(ok, text)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", text)
|
||||
end
|
||||
|
||||
local director = findDirector(world)
|
||||
claim(director ~= nil, "director is on 5F")
|
||||
claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y,
|
||||
("director office spawn is (%d, %d), saw (%s, %s)")
|
||||
:format(OFFICE_X, OFFICE_Y,
|
||||
tostring(director and director.cellX),
|
||||
tostring(director and director.cellY)))
|
||||
|
||||
world:moveObject(DIRECTOR_ID, STAIR_X, STAIR_Y)
|
||||
world:appearObject(DIRECTOR_ID)
|
||||
U.wait(4)
|
||||
|
||||
director = findDirector(world)
|
||||
claim(director and director.cellX == STAIR_X and director.cellY == STAIR_Y,
|
||||
("after moveobject director is at the stairs (%d, %d), saw (%s, %s)")
|
||||
:format(STAIR_X, STAIR_Y,
|
||||
tostring(director and director.cellX),
|
||||
tostring(director and director.cellY)))
|
||||
|
||||
assert(world:setMap("RADIO_TOWER_4F", 12, 4, "down"),
|
||||
"RADIO_TOWER_4F did not load")
|
||||
U.wait(2)
|
||||
assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"),
|
||||
"RADIO_TOWER_5F did not reload")
|
||||
U.wait(4)
|
||||
|
||||
local def = directorDef(world)
|
||||
director = findDirector(world)
|
||||
claim(def and def.x == OFFICE_X and def.y == OFFICE_Y,
|
||||
("reload restored director def to (%d, %d), saw (%s, %s)")
|
||||
:format(OFFICE_X, OFFICE_Y,
|
||||
tostring(def and def.x), tostring(def and def.y)))
|
||||
claim(not (director and director.cellX == STAIR_X and director.cellY == STAIR_Y),
|
||||
"director is not standing on the stair warp")
|
||||
claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y,
|
||||
("reload put director in the office (%d, %d); saw (%s, %s)")
|
||||
:format(OFFICE_X, OFFICE_Y,
|
||||
tostring(director and director.cellX),
|
||||
tostring(director and director.cellY)))
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
U.log("The gentleman should be in the office. The stairs must be clear.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Route 40 smashable rocks stay on their cell through rock_smash, then a
|
||||
-- seamless Olivine round trip.
|
||||
-- #1173
|
||||
-- engine/overworld/movement.asm:163 / maps/Route40.asm:291
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local ROCK_ID = 6
|
||||
local ROCK_X, ROCK_Y = 12, 8
|
||||
local SMASH = { 0x57, 10, 0x47 }
|
||||
|
||||
local function findRock(world, x, y)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_ROCK"
|
||||
and npc.cellX == x and npc.cellY == y then
|
||||
return npc
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function rockAt(world, x, y)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_ROCK"
|
||||
and npc.cellX == x and npc.cellY == y then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
world.mapScenes.ROUTE_40 = 0
|
||||
world.mapScenes.OLIVINE_CITY = 0
|
||||
|
||||
assert(world:setMap("ROUTE_40", 12, 9, "up"), "ROUTE_40 did not load")
|
||||
U.wait(4)
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function claim(ok, text)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", text)
|
||||
end
|
||||
|
||||
local rock = findRock(world, ROCK_X, ROCK_Y)
|
||||
claim(rock ~= nil, ("rock is at (%d, %d)"):format(ROCK_X, ROCK_Y))
|
||||
|
||||
world:beginMovement(ROCK_ID, SMASH)
|
||||
for _ = 1, 40 do
|
||||
if not world.moveState then break end
|
||||
U.wait(1)
|
||||
end
|
||||
claim(world.moveState == nil, "rock_smash stream finished")
|
||||
|
||||
rock = findRock(world, ROCK_X, ROCK_Y)
|
||||
claim(rock ~= nil,
|
||||
("after rock_smash the rock is still at (%d, %d), not one cell left")
|
||||
:format(ROCK_X, ROCK_Y))
|
||||
claim(not rockAt(world, ROCK_X - 1, ROCK_Y),
|
||||
"no smashable rock slid one cell left")
|
||||
|
||||
assert(world:setMap("OLIVINE_CITY", 18, 15, "down", { seamless = true }),
|
||||
"OLIVINE_CITY did not load")
|
||||
U.wait(2)
|
||||
assert(world:setMap("ROUTE_40", 12, 9, "up", { seamless = true }),
|
||||
"ROUTE_40 did not reload")
|
||||
U.wait(4)
|
||||
|
||||
claim(rockAt(world, ROCK_X, ROCK_Y),
|
||||
("after Olivine round trip the rock is still at (%d, %d)")
|
||||
:format(ROCK_X, ROCK_Y))
|
||||
claim(not rockAt(world, ROCK_X - 1, ROCK_Y),
|
||||
"seamless reload did not shift the rock left")
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
U.log("The three rocks should sit on (12, 8), (11, 7), (13, 6).")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
-- After a wild faint, "Use next POKéMON?" yes/no; NO tries to run. Issue #1152.
|
||||
-- pokegold engine/battle/core.asm:2590 (AskUseNextPokemon).
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love .
|
||||
-- Do not add POKEPORT_SPEED: you need the yes/no box to sit there.
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function claim(ok, text)
|
||||
print((ok and "[use-next] PASS " or "[use-next] FAIL ") .. text)
|
||||
return ok
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
if not (world and world.map) then
|
||||
print("[use-next] FAIL gold world did not boot")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local lead = Mon.new(game.data, "SENTRET", 5)
|
||||
local backup = Mon.new(game.data, "CYNDAQUIL", 12)
|
||||
if not (lead and backup) then
|
||||
print("[use-next] FAIL could not build the party")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
lead.hp = 1
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
backup.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, backup }
|
||||
|
||||
local wild = Mon.new(game.data, "PIDGEY", 20)
|
||||
if not wild then
|
||||
print("[use-next] FAIL could not build a wild PIDGEY")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
if not world:startBattle({ wild = wild }) then
|
||||
print("[use-next] FAIL startBattle failed")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local screen
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then screen = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not (screen and screen.battle) then
|
||||
print("[use-next] FAIL battle screen never came up")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
if screen.phase ~= "menu" then
|
||||
print("[use-next] FAIL never reached the battle menu")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
tap("a")
|
||||
U.wait(6)
|
||||
tap("a")
|
||||
|
||||
local sawAsk, sawForced = false, false
|
||||
for _ = 1, 400 do
|
||||
if screen.phase == "ask-next-mon" then sawAsk = true break end
|
||||
if screen.phase == "forced-switch" or screen.phase == "submenu" then
|
||||
sawForced = true
|
||||
break
|
||||
end
|
||||
if screen.battle.over then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
|
||||
claim(sawAsk, "wild faint opened Use next POKéMON?")
|
||||
claim(not sawForced, "wild faint did not skip to the party list")
|
||||
claim(screen.phase == "ask-next-mon",
|
||||
"phase is ask-next-mon (YES switches, NO/B tries to run)")
|
||||
print("[use-next] YES sends you to the party. NO or B tries to run.")
|
||||
print("[use-next] a failed run still opens the party. trainers skip this.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -15,7 +15,8 @@ local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local function makeGame(kind)
|
||||
kind = kind or "wild"
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
@@ -29,12 +30,24 @@ local function makeGame()
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
function overworld:captureSave(progress)
|
||||
progress.player.map = self.map.id
|
||||
progress.player.x = self.player.cellX
|
||||
progress.player.y = self.player.cellY
|
||||
progress.player.facing = self.player.facing
|
||||
end
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
local battle = kind == "trainer"
|
||||
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
or BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase = "menu"
|
||||
battle.queue = {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter" }
|
||||
battle.checkpointOrigin = kind == "trainer"
|
||||
and { kind = "trainer_encounter", map = save.player.map,
|
||||
npcId = "TRAINER_1", trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
event = "EVENT_BEAT_TRAINER_1" }
|
||||
or { kind = "wild_encounter" }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, overworld, battle
|
||||
@@ -45,6 +58,42 @@ T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled standard wild battle is a checkpoint boundary")
|
||||
|
||||
local function settleRealBattle(kind)
|
||||
local liveGame, _, liveBattle = makeGame(kind)
|
||||
liveBattle.phase, liveBattle.queue = nil, {}
|
||||
liveGame.input = {
|
||||
wasPressed = function(_, button) return button == "a" end,
|
||||
isDown = function(_, button) return button == "a" end,
|
||||
}
|
||||
liveBattle:enter()
|
||||
local frames = 0
|
||||
while liveBattle.phase ~= "menu" and frames < 10000 do
|
||||
frames = frames + 1
|
||||
liveBattle:update(1 / 60)
|
||||
end
|
||||
T.eq(liveBattle.phase, "menu", "the real battle intro reaches its command menu")
|
||||
return liveGame
|
||||
end
|
||||
|
||||
local realGame = settleRealBattle("wild")
|
||||
T.same(Checkpoint.inspect(realGame), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "the completed real battle intro is a checkpoint boundary")
|
||||
local oldGetRandomState, oldSetRandomState =
|
||||
love.math.getRandomState, love.math.setRandomState
|
||||
love.math.getRandomState = function() return "real-boundary-rng" end
|
||||
love.math.setRandomState = function() end
|
||||
local realSnapshot, realCaptureCode = Checkpoint.capture(realGame)
|
||||
T.check(type(realSnapshot) == "table" and realSnapshot.kind == "battle",
|
||||
"the first real command decision captures for deferred tools: "
|
||||
.. tostring(realCaptureCode))
|
||||
love.math.getRandomState, love.math.setRandomState =
|
||||
oldGetRandomState, oldSetRandomState
|
||||
local realTrainerGame = settleRealBattle("trainer")
|
||||
T.same(Checkpoint.inspect(realTrainerGame), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "the completed real trainer intro is a checkpoint boundary")
|
||||
|
||||
local function refused(mutator, code, label)
|
||||
local game2, ow2, battle2 = makeGame()
|
||||
mutator(game2, ow2, battle2)
|
||||
@@ -64,7 +113,7 @@ refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
|
||||
refused(function(_, _, b) b.player.mustRecharge = true end,
|
||||
"battle_phase_busy", "automatic locked action is rejected")
|
||||
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
|
||||
"script_busy", "suspended script beneath battle is rejected")
|
||||
"script_busy", "unknown suspended script beneath battle is rejected")
|
||||
refused(function(_, _, b) b.checkpointOrigin = nil end,
|
||||
"battle_origin_unsupported", "unknown completion closure is rejected")
|
||||
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
-- Public battle auxiliary actions are a narrow semantic entry point for tool
|
||||
-- mods. They run only at the same settled ordinary decision boundary as a
|
||||
-- battle checkpoint, consume no FIGHT/PKMN/ITEM/RUN action, and receive no
|
||||
-- live BattleState object.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle menu auxiliary action")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame(kind)
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-menu-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = save.player.map },
|
||||
player = { cellX = save.player.x, cellY = save.player.y, facing = save.player.facing },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
local game = { data = Data, save = save, stack = stack }
|
||||
game.input = { wasPressed = function(_, button) return button == "start" end }
|
||||
game.overworld = overworld
|
||||
stack.states[1] = overworld
|
||||
local battle = kind == "trainer"
|
||||
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
or BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = kind == "trainer"
|
||||
and { kind = "trainer_encounter", map = save.player.map, npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1" }
|
||||
or { kind = "wild_encounter", map = save.player.map }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local oldHooks = Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
|
||||
local game, battle = makeGame("wild")
|
||||
local calls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
|
||||
calls = calls + 1
|
||||
T.check(liveGame == game, "auxiliary action receives the live game")
|
||||
T.same(context, { kind = "wild" }, "auxiliary action receives only data-only battle context")
|
||||
return true
|
||||
end, 0, "tool_fixture")
|
||||
|
||||
local originalIndex = battle.menuIndex
|
||||
battle:update(1 / 60)
|
||||
T.eq(calls, 1, "START reaches the public auxiliary action at a wild decision")
|
||||
T.eq(battle.phase, "menu", "handled auxiliary action does not advance the battle")
|
||||
T.eq(battle.menuIndex, originalIndex, "handled auxiliary action preserves cursor")
|
||||
T.eq(#battle.queue, 0, "handled auxiliary action does not enqueue a turn")
|
||||
|
||||
hooks:removeOwner("tool_fixture")
|
||||
local trainerGame, trainer = makeGame("trainer")
|
||||
local trainerCalls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
|
||||
trainerCalls = trainerCalls + 1
|
||||
T.check(liveGame == trainerGame, "trainer action receives its live game")
|
||||
T.same(context, { kind = "trainer" }, "trainer context remains data-only")
|
||||
return true
|
||||
end, 0, "trainer_fixture")
|
||||
trainer:update(1 / 60)
|
||||
T.eq(trainerCalls, 1, "START reaches the public auxiliary action at a trainer decision")
|
||||
hooks:removeOwner("trainer_fixture")
|
||||
|
||||
local scriptedGame, scripted = makeGame("trainer")
|
||||
scripted.checkpointOrigin = { kind = "script_battle", scriptId = "STORY_TEST", pc = 4 }
|
||||
scripted.checkpointScriptContinuation = { kind = "script_battle" }
|
||||
local scriptedCalls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
|
||||
scriptedCalls = scriptedCalls + 1
|
||||
T.check(liveGame == scriptedGame,
|
||||
"scripted action receives the live game without its runner")
|
||||
T.same(context, { kind = "trainer" },
|
||||
"supported scripted trainer context remains data-only")
|
||||
return true
|
||||
end, 0, "scripted_fixture")
|
||||
scripted:update(1 / 60)
|
||||
T.eq(scriptedCalls, 1,
|
||||
"START reaches the public auxiliary action at a supported scripted decision")
|
||||
hooks:removeOwner("scripted_fixture")
|
||||
|
||||
local unsafeGame, unsafe = makeGame("wild")
|
||||
unsafe.phase = "messages"
|
||||
local unsafeCalls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function() unsafeCalls = unsafeCalls + 1 return true end,
|
||||
0, "unsafe_fixture")
|
||||
unsafe:update(1 / 60)
|
||||
T.eq(unsafeCalls, 0, "messages never expose the auxiliary action")
|
||||
hooks:removeOwner("unsafe_fixture")
|
||||
|
||||
Runtime.hooks = oldHooks
|
||||
T.finish()
|
||||
@@ -0,0 +1,41 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
|
||||
|
||||
local defaults = SaveData.defaultOptions()
|
||||
T.eq(defaults.dateFormat, "device", "date format defaults to device locale")
|
||||
T.eq(defaults.timeFormat, "device", "time format defaults to device locale")
|
||||
|
||||
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
|
||||
T.eq(DateTime.date(game, stamp), os.date("%d-%m-%Y", stamp),
|
||||
"explicit DMY uses day-month-year")
|
||||
T.eq(DateTime.time(game, stamp), os.date("%H:%M", stamp),
|
||||
"explicit 24-hour time omits seconds")
|
||||
T.eq(DateTime.dateTime(game, stamp),
|
||||
os.date("%d-%m-%Y %H:%M", stamp),
|
||||
"combined formatter composes exact date and time preferences")
|
||||
|
||||
game.save.options.dateFormat = "mdy"
|
||||
T.eq(DateTime.date(game, stamp), os.date("%m-%d-%Y", stamp),
|
||||
"explicit MDY is available")
|
||||
game.save.options.dateFormat = "ymd"
|
||||
T.eq(DateTime.date(game, stamp), os.date("%Y-%m-%d", stamp),
|
||||
"explicit YMD is available")
|
||||
game.save.options.timeFormat = "12h"
|
||||
T.eq(DateTime.time(game, stamp), os.date("%I:%M %p", stamp),
|
||||
"explicit 12-hour time is available")
|
||||
|
||||
local fallback = DateTime.formatWithLocale(stamp, "device", "device", "C")
|
||||
T.eq(fallback.date, os.date("%d-%m-%Y", stamp),
|
||||
"missing device locale falls back to requested DMY")
|
||||
T.eq(fallback.time, os.date("%H:%M", stamp),
|
||||
"missing device locale falls back to requested 24-hour time")
|
||||
|
||||
local invalid = DateTime.date({}, -1)
|
||||
T.eq(invalid, "----", "invalid timestamps fail closed")
|
||||
|
||||
T.finish("date_time")
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Commands.pushBattle (src/script/Commands.lua): the shared entry point
|
||||
-- for start_battle, old_man_demo, and the PALLET_TOWN Pikachu catch
|
||||
-- (data/scripts/story2.lua). Every one of those call sites used to carry
|
||||
-- its own copy of "if ctx.overworld.pushBattle then ... else
|
||||
-- ctx.game.stack:push(battle) end"; this locks the dedup in so a future
|
||||
-- edit to one call site can't silently drop the transition wipe for the
|
||||
-- others.
|
||||
-- luajit tests/engine/push_battle_transition.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local Commands = require("src.script.Commands")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local battle = { id = "the battle" }
|
||||
|
||||
-- Runs fn() with Logger.warn spied instead of hitting the real
|
||||
-- print()/Logger.history ring buffer, and returns the last formatted
|
||||
-- warning (or nil if none). pcall-wrapped so an error inside fn() still
|
||||
-- restores Logger.warn before propagating -- a leaked spy would swallow
|
||||
-- every later warning in the same process silently.
|
||||
local function withWarnSpy(fn)
|
||||
local warned
|
||||
local origWarn = Logger.warn
|
||||
Logger.warn = function(fmt, ...) warned = string.format(fmt, ...) end
|
||||
local ok, err = pcall(fn)
|
||||
Logger.warn = origWarn
|
||||
if not ok then error(err, 0) end
|
||||
return warned
|
||||
end
|
||||
|
||||
-- ctx.overworld has a real pushBattle: it must be used, not a bare stack
|
||||
-- push, so the flash/wipe transition and the battle-theme start survive.
|
||||
do
|
||||
local pushed
|
||||
local ow = { pushBattle = function(self, b) pushed = b end }
|
||||
local stackPushed
|
||||
local ctx = { overworld = ow, game = { stack = {
|
||||
push = function(_, b) stackPushed = b end } } }
|
||||
Commands.pushBattle(ctx, battle)
|
||||
eq(pushed, battle, "ctx.overworld:pushBattle is called with the battle")
|
||||
check(stackPushed == nil, "the bare stack push is not also taken")
|
||||
end
|
||||
|
||||
-- ctx.overworld without a pushBattle method (a partial test double, per
|
||||
-- BattleState:finish's "no live children" contract) falls back to a
|
||||
-- bare stack push and logs, rather than silently skipping the transition.
|
||||
do
|
||||
local stackPushed
|
||||
local ctx = { overworld = {}, game = { stack = {
|
||||
push = function(_, b) stackPushed = b end } } }
|
||||
local warned = withWarnSpy(function() Commands.pushBattle(ctx, battle) end)
|
||||
eq(stackPushed, battle, "falls back to ctx.game.stack:push")
|
||||
check(warned ~= nil, "the fallback logs a warning")
|
||||
check(warned and warned:find("pushBattle") ~= nil,
|
||||
"the warning names pushBattle")
|
||||
end
|
||||
|
||||
-- ctx.overworld absent entirely (headless script tests that never set
|
||||
-- one up): same fallback, no crash on the ctx.overworld.pushBattle read
|
||||
-- -- and still spied, since this also takes the warning path.
|
||||
do
|
||||
local stackPushed
|
||||
local ctx = { game = { stack = {
|
||||
push = function(_, b) stackPushed = b end } } }
|
||||
local warned = withWarnSpy(function() Commands.pushBattle(ctx, battle) end)
|
||||
eq(stackPushed, battle, "falls back to ctx.game.stack:push with no overworld")
|
||||
check(warned ~= nil, "this fallback also logs a warning")
|
||||
end
|
||||
|
||||
T.finish("push_battle_transition")
|
||||
@@ -0,0 +1,208 @@
|
||||
-- Regression test for the title-music-bleeds-into-the-map bug: Continue,
|
||||
-- F2 quickload, and checkpoint-resume used to drop the player into the
|
||||
-- overworld while the old song (the title screen's, or F2's previous
|
||||
-- location) was still cross-fading in over Music.MAP_FADE's ~1.2s,
|
||||
-- audibly wrong since the player already had control. See
|
||||
-- OverworldState:setMap (src/world/OverworldController.lua) for the
|
||||
-- opts.freshBoot mechanism this exercises, and Game.lua for where it's
|
||||
-- set (onContinue, New Game, F2, restoreCheckpointSave) and where it's
|
||||
-- deliberately not (dev tooling's reuse of opts.via == "boot").
|
||||
--
|
||||
-- (A)-(A4) and (C) call the real Game:restoreSave, Game:keypressed("f2"),
|
||||
-- Game:restoreCheckpointSave and Console:exec("warp ...") -- SaveData.load
|
||||
-- stubbed to skip the slot/persistence format -- so a dropped freshBoot at
|
||||
-- any real call site fails this test, not just a hand-built opts table.
|
||||
-- (D) simulates HotReload's { via = "boot" } shape instead of calling
|
||||
-- through its local, unexported reloadMap.
|
||||
--
|
||||
-- ROM-free (fixture dataset -- FIX_TOWN/FIX_ROUTE, tests/fixture_data),
|
||||
-- like tests/engine/warp_sprite_hidden_bug916.lua, so the CI headless
|
||||
-- tier (no data/generated/) runs it.
|
||||
-- luajit tests/engine/resume_boot_music_no_fade.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:setLooping() end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:setPitch() end
|
||||
function Source:setFilter() end
|
||||
function Source:getDuration() return 1 end
|
||||
|
||||
local made = {} -- file -> the last source built for it
|
||||
love.audio = {
|
||||
newSource = function(file, mode)
|
||||
made[file] = setmetatable({ file = file, mode = mode }, Source)
|
||||
return made[file]
|
||||
end,
|
||||
}
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
-- fixture patches that let the overworld boot and run headlessly (same
|
||||
-- set tests/engine/warp_sprite_hidden_bug916.lua needs for the same reason)
|
||||
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
Data.field.waterTilesets = {}
|
||||
Data.field.forcedMovement = { tiles = {} }
|
||||
-- no data.audio in the fixture dataset either; synthesize just enough for
|
||||
-- real Music.lua playback to run against the real FIX_TOWN/FIX_ROUTE maps
|
||||
Data.audio = Data.audio or {}
|
||||
Data.audio.songs = Data.audio.songs or {}
|
||||
Data.audio.songs.Music_TitleScreen = { file = "title.wav" }
|
||||
Data.audio.mapSongs = Data.audio.mapSongs or {}
|
||||
Data.audio.mapSongs.FIX_TOWN = "Music_FixTown"
|
||||
Data.audio.songs.Music_FixTown = { file = "town.wav" }
|
||||
Data.audio.mapSongs.FIX_ROUTE = "Music_FixRoute"
|
||||
Data.audio.songs.Music_FixRoute = { file = "route.wav" }
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Game = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local Console = require("src.dev.Console")
|
||||
|
||||
Game.data = Data
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.name = "RED"
|
||||
Game.save.player.map = "FIX_TOWN"
|
||||
StateStack:init()
|
||||
Game.stack = StateStack
|
||||
Game.overworld = OverworldState -- set once at boot in the real game (Game.lua)
|
||||
Game.input = {
|
||||
isDown = function() return false end,
|
||||
wasPressed = function() return false end,
|
||||
step = function() end, state = {}, pressQueue = {},
|
||||
}
|
||||
Game.renderer = {
|
||||
beginWorldPass = function() end, endWorldPass = function() end,
|
||||
beginUIPass = function() end, endUIPass = function() end,
|
||||
worldViewSize = function() return 160, 144 end,
|
||||
setSGBZones = function() end,
|
||||
}
|
||||
|
||||
local function playing()
|
||||
for file, src in pairs(made) do
|
||||
if src.playing then return file end
|
||||
end
|
||||
return "(silence)"
|
||||
end
|
||||
|
||||
local function finishFade()
|
||||
for _ = 1, 7 * Music.MAP_FADE do Music.update(Data) end
|
||||
end
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A) The real Game:restoreSave, called the way onContinue calls it.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
eq(playing(), "title.wav", "title screen music is playing before Continue")
|
||||
|
||||
local loaded = SaveData.newGame()
|
||||
loaded.player.map = "FIX_TOWN"
|
||||
Game:restoreSave(loaded, false, { freshBoot = true })
|
||||
|
||||
eq(playing(), "town.wav",
|
||||
"Continue's real restoreSave(..., {freshBoot=true}) swaps at once")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A2) The same real Game:restoreSave with no opts at all -- its own
|
||||
-- default (e.g. for any future caller that doesn't ask for freshBoot) is
|
||||
-- the safe, ordinary crossfade, not a silent hard-cut.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen") -- stand-in for whatever was playing
|
||||
local loaded2 = SaveData.newGame()
|
||||
loaded2.player.map = "FIX_TOWN"
|
||||
Game:restoreSave(loaded2, false)
|
||||
|
||||
eq(playing(), "title.wav",
|
||||
"restoreSave(...) with no opts still fades, not an instant swap")
|
||||
finishFade()
|
||||
eq(playing(), "town.wav", "...landing on the loaded save's map song")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A3) The real Game:keypressed("f2") handler, both ways it's reachable:
|
||||
-- at the title screen and mid-session. SaveData.load is stubbed rather
|
||||
-- than round-tripped through the in-memory love.filesystem, to isolate
|
||||
-- this test from the slot/persistence format.
|
||||
-- ===========================================================================
|
||||
local realLoad = SaveData.load
|
||||
local loaded3 = SaveData.newGame()
|
||||
loaded3.player.map = "FIX_TOWN"
|
||||
SaveData.load = function() return loaded3, false end
|
||||
|
||||
StateStack:init() -- no overworld on the stack: "at the title screen"
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
Game:keypressed("f2")
|
||||
eq(playing(), "town.wav",
|
||||
"F2 from the title screen (overworld not on the stack) swaps at once")
|
||||
|
||||
StateStack:init()
|
||||
StateStack.states[1] = OverworldState -- overworld already active: mid-session
|
||||
Music.play(Data, "Music_TitleScreen") -- stand-in for the session's own song
|
||||
Game:keypressed("f2")
|
||||
eq(playing(), "town.wav",
|
||||
"F2 mid-session (a live overworld already on the stack) also swaps at once")
|
||||
|
||||
SaveData.load = realLoad
|
||||
StateStack:init()
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A4) The real Game:restoreCheckpointSave, called the way Checkpoint.resume
|
||||
-- (RFC 0006's mod.checkpoint:resume) calls it.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
local checkpointSave = SaveData.newGame()
|
||||
checkpointSave.player.map = "FIX_TOWN"
|
||||
Game:restoreCheckpointSave(checkpointSave)
|
||||
eq(playing(), "town.wav",
|
||||
"a title-session checkpoint resume swaps at once, no lingering title music")
|
||||
|
||||
StateStack:init()
|
||||
|
||||
-- ===========================================================================
|
||||
-- (B) An ordinary warp (e.g. walking into a house) is unaffected: it still
|
||||
-- cross-fades like any other map-to-map transition.
|
||||
-- ===========================================================================
|
||||
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", {})
|
||||
eq(playing(), "town.wav",
|
||||
"an ordinary warp still fades: the old song is still playing right after setMap")
|
||||
finishFade()
|
||||
eq(playing(), "route.wav",
|
||||
"...and lands on the new map's song once the fade completes")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (C) The real dev console `warp` verb (src/dev/Console.lua VERBS.warp).
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen") -- re-arm a "stale" song to prove intent
|
||||
Console.new(Game):exec("warp FIX_TOWN 5 5")
|
||||
|
||||
eq(playing(), "title.wav",
|
||||
"Console's real `warp` verb still fades, like an ordinary warp")
|
||||
finishFade()
|
||||
eq(playing(), "town.wav", "...landing on the target map's song")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (D) src/dev/HotReload.lua's reloadMap opts shape, simulated (see header)
|
||||
-- rather than called through: reloadMap is local/unexported, and
|
||||
-- HotReload.run's full loader teardown is out of scope for this fix.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", { via = "boot" })
|
||||
eq(playing(), "title.wav",
|
||||
"HotReload's { via = \"boot\" } setMap still fades, not an instant swap")
|
||||
finishFade()
|
||||
eq(playing(), "route.wav", "...landing on the reloaded map's song")
|
||||
|
||||
T.finish("resume_boot_music_no_fade")
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Scripted story battles checkpoint a semantic row-list continuation. The
|
||||
-- suspended Lua coroutine is deliberately never serialized.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local oldGetRandomState = love.math.getRandomState
|
||||
local oldSetRandomState = love.math.setRandomState
|
||||
local checkpointRng = "scripted-battle-rng"
|
||||
love.math.getRandomState = function() return checkpointRng end
|
||||
love.math.setRandomState = function(state) checkpointRng = state end
|
||||
|
||||
local T = require("tests.harness").suite("scripted battle checkpoints")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local GameMethods = require("src.core.Game")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "script-battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
SaveData.validate(save, Data)
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local ow = setmetatable({
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}, { __index = OverworldState })
|
||||
function ow:captureSave(target)
|
||||
target.player.map = self.map.id
|
||||
target.player.x, target.player.y = self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing, false
|
||||
end
|
||||
function ow:pushBattle(battle) game.stack:push(battle) end
|
||||
function ow:afterBattle(result, battle)
|
||||
self.after = { result = result, battle = battle }
|
||||
end
|
||||
game = setmetatable({ data = Data, save = save, stack = stack, overworld = ow },
|
||||
{ __index = GameMethods })
|
||||
function game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
ow.map = { id = loaded.player.map }
|
||||
ow.player = {
|
||||
cellX = loaded.player.x, cellY = loaded.player.y,
|
||||
facing = loaded.player.facing, surfing = loaded.player.surfing and true or false,
|
||||
}
|
||||
ow.parallelRunners, ow.pendingScripts, ow.parallelQueue, ow.scriptMoves = {}, {}, {}, {}
|
||||
ow.runner = ScriptRunner.new(self, ow)
|
||||
self.stack.states = { ow }
|
||||
end
|
||||
stack.states[1] = ow
|
||||
ow.runner = ScriptRunner.new(game, ow)
|
||||
return game, ow
|
||||
end
|
||||
|
||||
local rows = {
|
||||
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
|
||||
{ "set_flag", "EVENT_STORY_CONTINUED" },
|
||||
}
|
||||
|
||||
local game, ow = makeGame()
|
||||
ow.runner:run(rows)
|
||||
local battle = game.stack:top()
|
||||
T.check(getmetatable(battle) == BattleState,
|
||||
"script command starts the fixture trainer battle")
|
||||
T.check(type(battle.checkpointOrigin) == "table"
|
||||
and battle.checkpointOrigin.kind == "script_battle",
|
||||
"script battle owns a semantic checkpoint continuation")
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.afterQueue, battle.introSlide = nil, nil
|
||||
battle.player.shownHP, battle.player.shownStatus =
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
local scriptedCapability = Checkpoint.inspect(game)
|
||||
T.same(scriptedCapability, {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled scripted trainer decision is checkpoint-safe: "
|
||||
.. tostring(scriptedCapability.reason))
|
||||
|
||||
local origin = battle.checkpointOrigin
|
||||
T.check(type(origin.script) == "table" and origin.pc == 1,
|
||||
"continuation records detached rows and the command program counter")
|
||||
T.eq(origin.resumeCoroutine, nil,
|
||||
"continuation never exposes a coroutine or Lua execution stack")
|
||||
|
||||
local snapshot, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
T.check(snapshot and snapshot.kind == "battle",
|
||||
"scripted battle captures through the generic checkpoint API: "
|
||||
.. tostring(captureCode or captureMessage))
|
||||
if snapshot then
|
||||
game.save.money = 1
|
||||
local restored, restoreCode, restoreMessage = Checkpoint.restore(game, snapshot)
|
||||
T.check(restored == true,
|
||||
"scripted battle reconstructs through the generic checkpoint API: "
|
||||
.. tostring(restoreCode or restoreMessage))
|
||||
local rebuilt = game.stack:top()
|
||||
T.check(rebuilt ~= battle and getmetatable(rebuilt) == BattleState,
|
||||
"scripted restore creates a fresh battle controller")
|
||||
T.same(Checkpoint.capture(game), snapshot,
|
||||
"scripted battle capture/restore/capture is a differential roundtrip")
|
||||
end
|
||||
|
||||
-- Rebind the continuation on a freshly reconstructed overworld. Completing
|
||||
-- the battle must replay the current command as an already-completed battle,
|
||||
-- then execute the remaining story rows exactly once.
|
||||
local restoredGame, restoredOw = makeGame()
|
||||
local restoredBattle = BattleState.newTrainer(restoredGame,
|
||||
"OPP_FIX_YOUNGSTER", 1)
|
||||
restoredBattle.checkpointOrigin = origin
|
||||
T.check(restoredOw:restoreBattleContinuation(restoredBattle, origin) == true,
|
||||
"script continuation reconstructs without the old runner")
|
||||
restoredBattle.onFinish("win")
|
||||
T.check(restoredGame.save.flags.EVENT_STORY_CONTINUED == true,
|
||||
"restored battle resumes subsequent story progress")
|
||||
T.same(restoredOw.after, { result = "win", battle = restoredBattle },
|
||||
"restored script battle uses the canonical afterBattle path")
|
||||
T.check(not restoredOw.runner:isRunning(),
|
||||
"reconstructed continuation completes without a suspended runner")
|
||||
|
||||
-- Unsafe rows and opaque completion callbacks must stay fail-closed.
|
||||
local unsafeGame, unsafeOw = makeGame()
|
||||
local unsafeRows = {
|
||||
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
|
||||
}
|
||||
unsafeRows.opaque = function() end
|
||||
unsafeOw.runner:run(unsafeRows)
|
||||
local unsafeBattle = unsafeGame.stack:top()
|
||||
unsafeBattle.phase, unsafeBattle.queue = "menu", {}
|
||||
T.eq(unsafeBattle.checkpointOrigin, nil,
|
||||
"non-data-only script arguments do not create a continuation")
|
||||
T.eq(Checkpoint.inspect(unsafeGame).reason, "battle_origin_unsupported",
|
||||
"non-data-only scripted battle remains unavailable")
|
||||
|
||||
local callbackGame, callbackOw = makeGame()
|
||||
callbackOw.runner:run(rows, { onDone = function() end })
|
||||
local callbackBattle = callbackGame.stack:top()
|
||||
callbackBattle.phase, callbackBattle.queue = "menu", {}
|
||||
T.eq(callbackBattle.checkpointOrigin, nil,
|
||||
"opaque script completion callbacks are not guessed")
|
||||
T.eq(Checkpoint.inspect(callbackGame).reason, "battle_origin_unsupported",
|
||||
"opaque scripted completion remains fail-closed")
|
||||
|
||||
local rivalGame, rivalOw = makeGame()
|
||||
local rivalRows = {
|
||||
{ "rival_battle", "OPP_FIX_YOUNGSTER", 1 },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "set_flag", "EVENT_RIVAL_STORY_CONTINUED" },
|
||||
}
|
||||
rivalOw.runner:run(rivalRows)
|
||||
local rivalBattle = rivalGame.stack:top()
|
||||
local rivalOrigin = rivalBattle.checkpointOrigin
|
||||
T.check(rivalOrigin and rivalOrigin.command == "rival_battle" and rivalOrigin.pc == 1,
|
||||
"wrapper battle records the wrapper command rather than skipping its tail")
|
||||
local rivalResumeGame, rivalResumeOw = makeGame()
|
||||
local rivalRestored = BattleState.newTrainer(rivalResumeGame,
|
||||
"OPP_FIX_YOUNGSTER", 1)
|
||||
T.check(rivalResumeOw:restoreBattleContinuation(rivalRestored, rivalOrigin) == true,
|
||||
"rival wrapper continuation reconstructs")
|
||||
rivalRestored.onFinish("win")
|
||||
T.check(rivalResumeGame.save.flags.EVENT_RIVAL_STORY_CONTINUED == true,
|
||||
"rival wrapper and following branch execute exactly once after restore")
|
||||
|
||||
love.math.getRandomState = oldGetRandomState
|
||||
love.math.setRandomState = oldSetRandomState
|
||||
T.finish()
|
||||
@@ -0,0 +1,155 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local phase, root = arg[1], arg[2]
|
||||
assert(phase == "capture" or phase == "resume", "phase must be capture or resume")
|
||||
assert(type(root) == "string" and root ~= "", "test needs a persistence root")
|
||||
|
||||
local function quote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function full(path) return root .. "/" .. path end
|
||||
|
||||
local fs = {}
|
||||
function fs.createDirectory(path)
|
||||
return os.execute("mkdir -p " .. quote(full(path))) == 0
|
||||
end
|
||||
function fs.write(path, body)
|
||||
local parent = path:match("^(.*)/[^/]+$")
|
||||
if parent then assert(fs.createDirectory(parent)) end
|
||||
local handle = assert(io.open(full(path), "wb"))
|
||||
handle:write(body)
|
||||
handle:close()
|
||||
return true
|
||||
end
|
||||
function fs.read(path)
|
||||
local handle = io.open(full(path), "rb")
|
||||
if not handle then return nil end
|
||||
local body = handle:read("*a")
|
||||
handle:close()
|
||||
return body
|
||||
end
|
||||
function fs.remove(path)
|
||||
os.remove(full(path))
|
||||
return true
|
||||
end
|
||||
function fs.getInfo(path)
|
||||
if os.execute("test -d " .. quote(full(path))) == 0 then
|
||||
return { type = "directory" }
|
||||
end
|
||||
local handle = io.open(full(path), "rb")
|
||||
if handle then handle:close(); return { type = "file" } end
|
||||
return nil
|
||||
end
|
||||
function fs.load(path)
|
||||
local body = fs.read(path)
|
||||
if not body then return nil, "no file: " .. path end
|
||||
return load(body, "@" .. path)
|
||||
end
|
||||
function fs.getDirectoryItems(path)
|
||||
local items = {}
|
||||
local pipe = io.popen("find " .. quote(full(path))
|
||||
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
|
||||
if pipe then
|
||||
for item in pipe:lines() do items[#items + 1] = item end
|
||||
pipe:close()
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
function fs.getSaveDirectory() return root end
|
||||
|
||||
love = require("tests.love_stub")
|
||||
love.filesystem = fs
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local function writeProbe()
|
||||
fs.write("mods/cold_start_probe/manifest.json",
|
||||
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}')
|
||||
fs.write("mods/cold_start_probe/main.lua", [[
|
||||
return function(mod)
|
||||
_G.COLD_STORAGE = mod.storage
|
||||
_G.COLD_CHECKPOINTS = mod.checkpoints
|
||||
end
|
||||
]])
|
||||
end
|
||||
|
||||
local function runtime(save, title)
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local overworld = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map, target.player.x, target.player.y = self.map.id,
|
||||
self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing,
|
||||
self.player.surfing and true or false
|
||||
end
|
||||
function overworld:enter(mapId, x, y, facing)
|
||||
self.map = { id = mapId }
|
||||
self.player = { cellX = x, cellY = y, facing = facing,
|
||||
surfing = game.save.player.surfing and true or false }
|
||||
self.scriptMoves, self.pendingScripts = {}, {}
|
||||
self.parallelRunners, self.parallelQueue = {}, {}
|
||||
self.runner = { isRunning = function() return false end }
|
||||
end
|
||||
game = setmetatable({
|
||||
save = save, stack = stack, overworld = overworld,
|
||||
data = {
|
||||
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
|
||||
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = title and { screenId = "TitleState" } or overworld
|
||||
if title then function game:makeTitleState() return { screenId = "TitleState" } end end
|
||||
return game
|
||||
end
|
||||
|
||||
writeProbe()
|
||||
SaveData.resetSlotState()
|
||||
local loader = Loader.new({ fs = fs })
|
||||
|
||||
if phase == "capture" then
|
||||
local game = runtime(SaveData.newGame({ version = "red" }), false)
|
||||
loader.game = game
|
||||
assert(loader:load({}) == true)
|
||||
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
|
||||
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
|
||||
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
|
||||
local id = assert(game.save.meta.playthroughId)
|
||||
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == id)
|
||||
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
|
||||
print("cold-start capture persisted")
|
||||
else
|
||||
local title = runtime(SaveData.newGame({ version = "red" }), true)
|
||||
title.save.options = { volume = 7, bindings = {} }
|
||||
loader.game = title
|
||||
assert(loader:load({}) == true)
|
||||
local selected = assert(_G.COLD_STORAGE:selected(title))
|
||||
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
|
||||
assert(selected:context().playthroughId == witness.playthroughId)
|
||||
assert(selected:read("history/index").newest == "q0001")
|
||||
local checkpoint = assert(selected:read("history/q0001"))
|
||||
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
|
||||
assert(title.save.meta.playthroughId == witness.playthroughId)
|
||||
assert(title.save.options.volume == 7)
|
||||
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
|
||||
== SaveSerializer.encode(checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == witness.playthroughId)
|
||||
print("cold-start resume reconstructed")
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user