Add Pokemon Crystal as a sixth supported version

Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.

Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
  make_gold_manifest as a library, with three additive keyword seams. Gold and
  Silver still regenerate byte-identical, which is the standing requirement for
  touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
  Crystal renames the credits mons, splits the trainer card, Pokegear and
  pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
  badge block sits one higher) and unownCharmap (the main charmap parser stops
  at the first newcharmap so the two cannot contaminate each other).

Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
  0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
  seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
  steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
  Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
  intro and title via src/import/CrystalMovie.lua.

Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
  original bugs where the bug is not hardware dependent; Crystal gets the fixes
  Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
  Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
  of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
  including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
  and mon caught data.

Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
This commit is contained in:
bryanthaboi
2026-08-23 12:10:28 -04:00
parent e88f2ef060
commit ca4d3d283c
141 changed files with 43306 additions and 583 deletions
+16 -14
View File
@@ -140,8 +140,8 @@ ship text.
### 4. `games` (and the legacy `gen2compat`)
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
overworld, script VM and save format. The mod API is shared across both
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
engine, overworld, script VM and save format. The mod API is shared across both
generations (same hook names, same event names, same registry names) but Gen 2
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
@@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
```
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
gen1,gen2` writes the key for you. The mod still installs to one directory,
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
the key for you. The mod still installs to one directory, `mods/<id>/`, shared
by every game -- targeting is declared, never filed.
Absent means Gen 1 only, which is what every mod written before the key existed
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
have actually run your mod on Gold or Silver.
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
run your mod on Gold, Silver or Crystal.
Every token is enforced, per game: the loader gates on the same
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
@@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written
before the key existed changes behavior; list both generations or say `"all"`
when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
writes with a report, and which hooks and events are still to come.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
and drop their writes with a report, and which hooks and events are still to
come.
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
Gen 1 mod, and it is the one to start from.
+12 -8
View File
@@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[![Watch the latest update video](https://img.youtube.com/vi/yi7LkWQPKKM/maxresdefault.jpg)](https://youtu.be/yi7LkWQPKKM)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
Silver ROM is the only game content input.
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
Silver, or Crystal ROM is the only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
construction, and Crystal is the newest of the three, so the launcher lists it
as Crystal (Beta).
## Quick Start
@@ -71,14 +73,16 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
- Crystal: `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -219,7 +223,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| Option | Effect |
| --- | --- |
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) |
| `--slot=2` | load that save slot; takes a slot number or a slot id |
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
+1 -1
View File
@@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC"
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 \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
+3 -1
View File
@@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC"
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 \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
printf '%s\n' "$payload_list" \
@@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing tools/rom_manifest_gold.json"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|| fail "payload is missing tools/rom_manifest_silver.json"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \
|| fail "payload is missing tools/rom_manifest_crystal.json"
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
+4 -4
View File
@@ -55,10 +55,10 @@ The short version, for an author deciding what to write:
```
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
and `GameVersion.generation`, so nothing anywhere restates the game list.
`"gen2"` now expands to both Gold and Silver.
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
and **derives** `manifest.gen2compat` from them, which is the one field the
loader's gate reads.
+1
View File
@@ -19,5 +19,6 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
## Gen 2 Specifics
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
+1 -1
View File
@@ -176,7 +176,7 @@ something the filesystem encodes.
| token | means |
| --- | --- |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
| `"all"` | every game this engine has |
+4 -4
View File
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow|gold|silver>/`, or
`exports/<red|blue|yellow|gold|silver>/`
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
`exports/<red|blue|yellow|gold|silver|crystal>/`
path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
+3 -2
View File
@@ -79,7 +79,7 @@ else
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
fi
# Materialize the listing once and grep the file: piping unzip straight into
@@ -101,7 +101,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
src/ui/kit/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json; do
tools/rom_manifest_silver.json \
tools/rom_manifest_crystal.json; do
grep -qxF "$required" "$LOVE_LISTING" \
|| fail "game.love is missing $required"
done
+54 -1
View File
@@ -34,6 +34,8 @@ 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}"
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json"
CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}"
VERSION=""
PACKAGE_ONLY=false
@@ -246,6 +248,54 @@ ensure_silver_manifest() {
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
}
crystal_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") ==
"f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1)
PY
}
ensure_crystal_manifest() {
local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE"
local staged
staged="$(mktemp)"
if crystal_manifest_is_valid "$manifest"; then
rm -f "$staged"
return
fi
warn "Crystal import manifest is missing or invalid; recovering it before packaging"
if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
&& crystal_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "restored Crystal 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" "$CRYSTAL_MANIFEST_URL" \
&& crystal_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "downloaded Crystal import manifest from the project repository"
return
fi
rm -f "$staged"
fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_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
@@ -312,6 +362,7 @@ pack_game_love() {
ensure_yellow_manifest
ensure_gold_manifest
ensure_silver_manifest
ensure_crystal_manifest
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
@@ -326,7 +377,7 @@ pack_game_love() {
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# List once and match against the captured text: piping unzip straight into
@@ -350,6 +401,8 @@ pack_game_love() {
|| fail "game.love is missing the Gold ROM import manifest"
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|| fail "game.love is missing the Silver ROM import manifest"
grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \
|| fail "game.love is missing the Crystal ROM import manifest"
# This gate exists because the launcher's UI toolkit once lived outside
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
# no other packager, so Android and iOS built an APK/IPA whose launcher
+2
View File
@@ -368,6 +368,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_silver.json"
grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_crystal.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"
@@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_gold.json"
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_silver.json"
grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_crystal.json"
say "Linux arm64 self-test passed"
+2 -1
View File
@@ -50,7 +50,7 @@ rm -f "$OUTPUT"
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
@@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
tools/rom_manifest_silver.json \
tools/rom_manifest_crystal.json \
src/ui/kit/Kit.lua \
src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \
+2 -1
View File
@@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \
&& [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then
fail "generated data missing, run scripts/setup.sh first"
fi
+10 -4
View File
@@ -71,15 +71,15 @@ First install or update (same steps):
your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into:
switch/gen1recomp/pokemon-love2d/imports/
then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow|gold|silver/ raw .sav import (Gold/Silver cart .sav not yet)
exports/red|blue|yellow|gold|silver/ pull after Export save (Gold/Silver not yet)
imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet)
exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet)
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF
@@ -92,7 +92,7 @@ write_readme() {
}
write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher."
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
@@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \
"Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
@@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/crystal/README.txt" \
"Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
rm -f "$OUT_ZIP"
(
@@ -145,11 +149,13 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
)
for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
+3 -1
View File
@@ -272,11 +272,13 @@ for rel in \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done
+59 -5
View File
@@ -5,8 +5,8 @@
# them fails. The tier split is what makes that possible: T1/T2/T4 need
# nothing but the committed fixture dataset, so they run anywhere --
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
# data/generated/, so it is skipped automatically when the ROM has never
# been imported rather than failing the run.
# data/generated/ or an imported Red cache, so it is skipped automatically
# when neither exists rather than failing the run.
#
# scripts/test.sh every tier this checkout can run
# scripts/test.sh --quick skip the slow content tier
@@ -16,6 +16,13 @@
#
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
#
# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache
# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as
# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with:
# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION=<version> \
# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="<rom>" love .
# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins.
set -uo pipefail
@@ -32,7 +39,7 @@ for arg in "$@"; do
--bless) BLESS=1 ;;
--bless-shots) SHOTS=1; BLESS=1 ;;
--quick) QUICK=1 ;;
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
--help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
@@ -49,6 +56,36 @@ fi
# save-editor suite pins that to the default identity.
SANDBOX_IDENTITY="ci-$$"
# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an
# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/).
CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches}
if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then
CACHE_ROOT="$POKEPORT_TEST_CACHES"
elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then
CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY"
else
CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY"
fi
# Only a cache with the importer's completion marker is offered: a half-written
# one would fail suites that are meant to self-skip.
adopt_cache() {
local var="$1" dir="$CACHE_ROOT/$2"
[ -z "${!var:-}" ] || return 0
[ -f "$dir/rom-cache.complete" ] || return 0
export "$var=$dir"
echo " $var=$dir"
}
echo ""
echo "-- per-version caches under $CACHE_ROOT"
adopt_cache RED_CACHE red
adopt_cache BLUE_CACHE blue
adopt_cache YELLOW_CACHE yellow
adopt_cache GOLD_CACHE gold
adopt_cache SILVER_CACHE silver
adopt_cache CRYSTAL_CACHE crystal
FAILED=()
run_tier() {
local label="$1"; shift
@@ -77,6 +114,7 @@ fi
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
@@ -87,6 +125,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
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
# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache),
# so it runs here rather than behind the Red content gate below.
run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.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
@@ -133,7 +174,19 @@ run_content_behavior() {
return 1
}
# The Red dataset is the source tree's when tools/build_data.py wrote one, and
# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook.
HAVE_RED_DATA=0
if [ -f data/generated/maps.lua ]; then
HAVE_RED_DATA=1
elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then
HAVE_RED_DATA=1
export POKEPORT_DATA_DIR="$RED_CACHE/data/generated"
echo ""
echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR"
fi
if [ "$HAVE_RED_DATA" = "1" ]; then
if [ "$QUICK" = "1" ]; then
echo ""
echo "-- T3 content: skipped (--quick)"
@@ -163,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then
fi
else
echo ""
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
echo " import a ROM to run them; the modded-link cases ran in T4)"
echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no"
echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran"
echo " in T4 and the Gen 2 suites in T2)"
fi
# ------- golden screenshots: needs love + a display
+23 -2
View File
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
-- be run from or Roared away.
Battle.BATTLETYPE_FORCESHINY = 7
Battle.BATTLETYPE_TRAP = 9
-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends
Battle.BATTLETYPE_CELEBI = 11
Battle.BATTLETYPE_SUICUNE = 12
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
-- type whose loss still prints the trainer's own line instead of a whiteout.
Battle.BATTLETYPE_CANLOSE = 1
@@ -247,6 +250,10 @@ function Battle.new(opts)
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
self.battleType = opts.battleType
-- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set
-- around the Tower's own StartBattle (engine/events/battle_tower/
-- battle_tower.asm:220-223) and cleared again at :253-254.
self.inBattleTowerBattle = opts.battleTower and true or false
self.events = {}
self.turn = 0
self.over = false
@@ -765,6 +772,9 @@ end
function Battle:battleStat(mon, key)
local value = (mon.stats or {})[key] or 1
if mon ~= self.player then return value end
-- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788):
-- adventure badges do not follow the player into the standardised Tower.
if self.inBattleTowerBattle then return value end
local badge = Battle.BADGE_STAT_BOOSTS[key]
if badge and self:hasBadge("badges", badge) then
return Battle.boostStat(value)
@@ -783,6 +793,8 @@ end
-- boosts the damage. Each type appears once, so this is a plain scan.
function Battle:badgeTypeBoost(attacker, moveType)
if attacker ~= self.player or not moveType then return false end
-- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154).
if self.inBattleTowerBattle then return false end
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
if row.type == moveType then
return self:hasBadge(row.store, row.badge)
@@ -3965,6 +3977,16 @@ function Battle:usableMoves(mon)
return out
end
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third.
function Battle:noEscapeBattleType()
local t = self.battleType
return t == Battle.BATTLETYPE_FORCESHINY
or t == Battle.BATTLETYPE_TRAP
or t == Battle.BATTLETYPE_CELEBI
or t == Battle.BATTLETYPE_SUICUNE
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.
@@ -3978,8 +4000,7 @@ function Battle:tryRun(pSpd)
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
-- trainer check and any speed math. Without this, running from the Red
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
if self.battleType == Battle.BATTLETYPE_FORCESHINY
or self.battleType == Battle.BATTLETYPE_TRAP then
if self:noEscapeBattleType() then
self:emit({ kind = "message", text = "Can't escape!" })
self.runRefused = true
return false
+42
View File
@@ -25,6 +25,7 @@
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
local Runtime = require("src.mods.Runtime")
local Mon = require("src.battle.gen2.Mon")
local Catching = {}
@@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts)
return bonuses[status] or 0
end
-- constants/landmark_constants.asm:24, the fallback when no cache is passed.
Catching.LANDMARK_NATIONAL_PARK = 19
local function landmarkIndex(opts, id, fallback)
local data = opts and (opts.data or (opts.battle and opts.battle.data))
local rows = (opts and opts.landmarks)
or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks)
local row = rows and rows[id]
return (row and row.index) or fallback
end
-- GetWorldMapLocation with the POKECENTER_2F backup-map swap
-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81).
function Catching.caughtLandmark(opts)
opts = opts or {}
if opts.bugContest then
return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK",
Catching.LANDMARK_NATIONAL_PARK)
end
if opts.landmark then return opts.landmark end
local map = opts.map
if map and map.id == "POKECENTER_2F" and opts.backupMap then
map = opts.backupMap
end
return (map and map.landmark) or 0
end
-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal.
function Catching.stampCaughtData(mon, opts)
opts = opts or {}
if not Mon.hasCaughtData(opts.version) then return mon end
local save = opts.save or (opts.battle and opts.battle.save)
return Mon.setCaughtData(mon, {
level = opts.level or (type(mon) == "table" and mon.level) or 0,
timeOfDay = opts.timeOfDay,
landmark = Catching.caughtLandmark(opts),
playerGender = opts.playerGender
or (save and save.player and save.player.gender),
})
end
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
-- A rate of 255 or a Master Ball is certain.
--
+30 -1
View File
@@ -21,8 +21,13 @@
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
-- records carry that as `category`.
local GameVersion = require("src.core.GameVersion")
local Damage = {}
-- ../pokecrystal/constants/battle_constants.asm:78
Damage.MAX_STAT_VALUE = 999
-- data/battle/critical_hit_chances.asm, as "1 in N".
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
@@ -60,7 +65,23 @@ end
function Damage.applyStage(value, stage)
local numerator, denominator = Damage.stageMultiplier(stage)
local out = math.floor(value * numerator / denominator)
return math.max(1, out)
-- ../pokecrystal/engine/battle/core.asm:6739
return math.max(1, math.min(Damage.MAX_STAT_VALUE, out))
end
-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one
-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it.
function Damage.truncateStats(attack, defense, fixed)
local a = math.max(0, math.floor(attack or 0))
local d = math.max(0, math.floor(defense or 0))
while a > 255 or d > 255 do
d = math.floor(d / 4)
if d == 0 then d = 1 end
a = math.floor(a / 4)
if a == 0 then a = 1 end
if not fixed then break end
end
return a % 256, d % 256
end
-- Is this move physical? `types` is type_chart.lua's `types` table.
@@ -169,6 +190,8 @@ end
-- random(n) -- 0..n-1, for the variation roll
-- screen -- Reflect/Light Screen active on the defender
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC
-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644
--
-- Returns damage, info where info carries the pieces a battle message needs:
-- effectiveness (x10), critical, physical, variation.
@@ -204,6 +227,12 @@ function Damage.calc(opts)
defense = defense * 2
end
-- PlayerAttackDamage hands DamageCalc one-byte stats
-- (../pokecrystal/engine/battle/effect_commands.asm:2604).
local fixed = opts.reflectOverflowFixed
if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end
attack, defense = Damage.truncateStats(attack, defense, fixed)
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
-- Explosion halve the defence, never below 1.
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
+102 -1
View File
@@ -17,6 +17,7 @@
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
-- GROWTH_* so this needs no hardcoded table.
local GameVersion = require("src.core.GameVersion")
local Unown = require("src.core.gen2.Unown")
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
@@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
Mon.MAX_DV = 15
-- MON_CAUGHTDATA's two packed bytes and their masks --
-- constants/pokemon_data_constants.asm:93-99, :120-130.
Mon.CAUGHT_TIME_MASK = 0xc0
Mon.CAUGHT_LEVEL_MASK = 0x3f
Mon.CAUGHT_GENDER_MASK = 0x80
Mon.CAUGHT_LOCATION_MASK = 0x7f
Mon.CAUGHT_EGG_LEVEL = 1
-- constants/landmark_constants.asm:111-113
Mon.LANDMARK_EVENT = 0x7f
Mon.LANDMARK_GIFT = 0x7e
-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData --
-- pokegold constants/pokemon_data_constants.asm:93.
function Mon.hasCaughtData(version)
return GameVersion.engine(version) == "crystal"
end
-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored
-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172.
local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 }
function Mon.caughtTimeOf(timeOfDay)
if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end
if type(timeOfDay) ~= "number" then return 0 end
local id = math.floor(timeOfDay)
if id < 0 or id > 3 then return 0 end
if id > 2 then id = 2 end
return id + 1
end
-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177).
local CAUGHT_GENDER = {
girl = "girl", female = "girl", boy = "boy", male = "boy",
}
function Mon.caughtGenderOf(gender)
if gender == true then return "girl" end
if gender == false then return "boy" end
if type(gender) ~= "string" then return nil end
return CAUGHT_GENDER[gender:lower()]
end
local function landmarkByte(landmark)
if type(landmark) ~= "number" then return 0 end
return math.floor(landmark) % 0x80
end
-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the
-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242).
function Mon.setCaughtData(mon, opts)
if type(mon) ~= "table" then return mon end
opts = opts or {}
mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay)
mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0))
mon.caughtLocation = landmarkByte(opts.landmark)
mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy"
return mon
end
-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b`
-- (constants/pokemon_data_constants.asm:126-128).
Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 }
-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or
-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit.
function Mon.setGiftCaughtData(mon, caughtBy)
if type(mon) ~= "table" then return mon end
local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0
local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK
local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated)
mon.caughtTime, mon.caughtLevel = 0, 0
mon.caughtLocation = unpacked.caughtLocation
mon.caughtByGender = unpacked.caughtByGender
return mon
end
-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it.
function Mon.packCaughtData(mon)
mon = type(mon) == "table" and mon or {}
local time = math.floor(tonumber(mon.caughtTime) or 0) % 4
local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40
local location = landmarkByte(tonumber(mon.caughtLocation) or 0)
local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl"
return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location
end
function Mon.unpackCaughtData(byte0, byte1)
byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256
return {
caughtTime = math.floor(byte0 / 0x40),
caughtLevel = byte0 % 0x40,
caughtLocation = byte1 % 0x80,
caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy",
}
end
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
@@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts)
status = nil,
-- 70 for a caught mon, 120 for a gift/hatched one.
happiness = opts.happiness or 70,
caughtLevel = level,
caughtLevel = opts.caughtLevel or level,
-- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99.
caughtTime = opts.caughtTime,
caughtLocation = opts.caughtLocation,
caughtByGender = opts.caughtByGender,
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
-- overriding the roll rather than a roll to be hooked.
+6 -3
View File
@@ -19,6 +19,7 @@ local Clock = require("src.core.gen2.Clock")
local FixedStep = require("src.core.FixedStep")
local Font = require("src.render.Font")
local GamepadMap = require("src.core.GamepadMap")
local GameVersion = require("src.core.GameVersion")
local Input = require("src.core.Input")
local Music = require("src.core.Music")
local Save = require("src.core.gen2.Save")
@@ -288,8 +289,6 @@ function Game2:continueGame(save)
self:startWorld()
-- After the adopt and after the world is standing, which is where Gen 1
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
-- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff
-- is derived from it and so comes back empty rather than missing.
if modsDiff then
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
if notice then require("src.core.Logger").warn("%s", notice) end
@@ -354,10 +353,14 @@ function Game2:showTitle()
})
end
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
function Game2:showIntro()
self.stack:clear()
self.phase = "boot"
Screens.push(self, "Gen2GoldSilverIntro", {
local id = (GameVersion.engine() == "crystal")
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
Screens.push(self, id, {
onDone = function()
self:showTitle()
end,
+46 -6
View File
@@ -1,6 +1,6 @@
-- Which game this process is running: Red (the historical default), Blue,
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that
-- differs by version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
--
@@ -8,8 +8,9 @@
-- saves are untouched, but its extracted cache lives under red/ like Blue,
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
-- and selected side by side. Gold and Silver are Gen 2 (see
-- docs/gold-phase1.md).
-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see
-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine`
-- splits Gold/Silver from Crystal within Gen 2.
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
@@ -27,6 +28,8 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest.json",
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
-- Absent reads as "gen1" (GameVersion.engine)
engine = "gen1",
},
blue = {
id = "blue",
@@ -37,6 +40,7 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
engine = "gen1",
},
yellow = {
id = "yellow",
@@ -47,6 +51,7 @@ GameVersion.VERSIONS = {
manifest = "tools/rom_manifest_yellow.json",
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
engine = "gen1",
},
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
@@ -64,6 +69,7 @@ GameVersion.VERSIONS = {
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
-- Absent reads as 1 (GameVersion.generation)
generation = 2,
engine = "gs",
},
-- Gold's engine with edition-selected data; the manifest is derived from
-- Gold's by tools/make_silver_manifest.py.
@@ -77,11 +83,34 @@ GameVersion.VERSIONS = {
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
generation = 2,
engine = "gs",
},
crystal = {
id = "crystal",
label = "Crystal",
displayName = "Pokemon Crystal",
launcherName = "Crystal (Beta)",
sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
manifest = "tools/rom_manifest_crystal.json",
cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
generation = 2,
engine = "crystal",
fixes = {
-- pokegold/docs/bugs_and_glitches.md:61
luckyNumberBoxes = true,
-- pokegold/docs/bugs_and_glitches.md:88
surfOntoNpc = true,
-- pokecrystal/engine/battle/effect_commands.asm:2614
reflectOverflow = true,
},
},
}
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
local NO_FIXES = {}
-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index).
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" }
GameVersion.current = "red"
@@ -115,6 +144,17 @@ function GameVersion.generation(id)
return GameVersion.info(id).generation or 1
end
-- "gen1" | "gs" | "crystal": lineage within a generation.
function GameVersion.engine(id)
return GameVersion.info(id).engine or "gen1"
end
-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged.
function GameVersion.fixes(id)
local info = GameVersion.info(id)
return (info and info.fixes) or NO_FIXES
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
+1
View File
@@ -40,6 +40,7 @@ local function normalizeVersion(v)
y = "yellow", yellow = "yellow",
g = "gold", gold = "gold",
s = "silver", silver = "silver",
c = "crystal", crystal = "crystal",
}
v = alias[v] or v
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
+4 -2
View File
@@ -1486,8 +1486,10 @@ function SaveData.runMigrations(save, modChains, activeMods)
-- every step whose from-format the save has not passed yet runs, in
-- (from, registration) order; a save at the current format runs none
local fmt = (save.meta and save.meta.format) or 1
for _, m in ipairs(coreMigrations) do
if m.from >= fmt then m.fn(save) end
if GameVersion.generation() == 1 then
for _, m in ipairs(coreMigrations) do
if m.from >= fmt then m.fn(save) end
end
end
-- a save that predates meta records an empty mod set: an old vanilla
-- save becomes a v2 vanilla save
+503
View File
@@ -0,0 +1,503 @@
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27.
local Breeding = require("src.core.gen2.Breeding")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local BattleTower = {}
-- ../pokecrystal/constants/battle_tower_constants.asm:1-7
BattleTower.PARTY_LENGTH = 3
BattleTower.STREAK_LENGTH = 7
BattleTower.NUM_UNIQUE_MON = 21
BattleTower.NUM_UNIQUE_TRAINERS = 70
BattleTower.TRAINERDATALENGTH = 36
-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE
BattleTower.GS_BALL_AVAILABLE = 0x0b
-- ../pokecrystal/constants/battle_tower_constants.asm:55-61
BattleTower.NO_CHALLENGE = 0
BattleTower.SAVED_AND_LEFT = 1
BattleTower.CHALLENGE_IN_PROGRESS = 2
BattleTower.WON_CHALLENGE = 3
BattleTower.RECEIVED_REWARD = 4
-- ../pokecrystal/constants/battle_tower_constants.asm:63-67
BattleTower.REWARD_QUANTITY = 5
BattleTower.MIN_REWARD = "HP_UP"
BattleTower.MAX_REWARD = "CALCIUM"
BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH"
BattleTower.FALLBACK_REWARD = "POTION"
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0,
-- :978-1008 bit 1 of sBattleTowerSaveFileFlags.
BattleTower.SAVEFILE_REGISTERED = 1
BattleTower.SAVEFILE_EXPLANATION = 2
-- ../pokecrystal/constants/battle_tower_constants.asm:11-43
BattleTower.ACTIONS = {
CHECK_EXPLANATION_READ = 0,
SET_EXPLANATION_READ = 1,
GET_CHALLENGE_STATE = 2,
SAVE_AND_QUIT = 3,
CHALLENGECANCELED = 4,
ACTION_05 = 5,
ACTION_06 = 6,
SAVELEVELGROUP = 7,
LOADLEVELGROUP = 8,
CHECKSAVEFILEISYOURS = 9,
ACTION_0A = 10,
GSBALL = 11,
ACTION_0C = 12,
ACTION_0D = 13,
EGGTICKET = 14,
ACTION_0F = 15,
ACTION_10 = 16,
ACTION_11 = 17,
ACTION_12 = 18,
ACTION_13 = 19,
ACTION_14 = 20,
ACTION_15 = 21,
ACTION_16 = 22,
ACTION_17 = 23,
LEVEL_CHECK = 24,
UBERS_CHECK = 25,
RESETDATA = 26,
GIVEREWARD = 27,
ACTION_1C = 28,
ACTION_1D = 29,
CHOOSEREWARD = 30,
SAVEOPTIONS = 31,
}
BattleTower.NUM_ACTIONS = 32
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which
-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`.
BattleTower.WRAM_NR_BEATEN = 0xcf64
-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`.
BattleTower.TEXT_INTRO = 1
BattleTower.TEXT_WIN = 2
BattleTower.TEXT_LOSS = 3
-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order
-- BattleTower_ExecuteJumptable prints them.
BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText"
BattleTower.RULE_FAIL_TEXTS = {
"_OnlyThreeMonMayBeEnteredText",
"_TheMonMustAllBeDifferentKindsText",
"_TheMonMustNotHoldTheSameItemsText",
"_YouCantTakeAnEggText",
}
-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68
BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText"
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2.
BattleTower.RULE_PARTY_COUNT_TEXT = "3"
-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck.
BattleTower.UBERS = {
MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true,
}
BattleTower.UBER_MIN_LEVEL = 70
-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and
-- Strings_Ll0ToL40.
BattleTower.MAX_LEVEL_GROUP = 10
BattleTower.PRE_HOF_LEVEL_GROUPS = 4
local function counter(value)
return math.max(0, math.floor(tonumber(value) or 0))
end
-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState.
function BattleTower.state(save)
local tower = Save.battleTowerState(save)
-- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup
tower.levelGroup = counter(tower.levelGroup)
-- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags
tower.saveFileFlags = counter(tower.saveFileFlags) % 256
-- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot
if type(tower.trainers) ~= "table" then tower.trainers = {} end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d
if tower.reentry == nil then tower.reentry = false end
return tower
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and
-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean.
function BattleTower.saveFileFlag(save, mask)
local tower = BattleTower.state(save)
return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0
end
function BattleTower.setSaveFileFlag(save, mask)
local tower = BattleTower.state(save)
if math.floor(tower.saveFileFlags / mask) % 2 == 0 then
tower.saveFileFlags = tower.saveFileFlags + mask
end
return tower.saveFileFlags
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
function BattleTower.resetTrainers(save)
local tower = BattleTower.state(save)
tower.trainers = {}
tower.streak = 0
return tower
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022
function BattleTower.setChallengeState(save, state)
local tower = BattleTower.state(save)
tower.challenge = counter(state)
return tower.challenge
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211
function BattleTower.partyCountOk(party)
return #party == BattleTower.PARTY_LENGTH
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277
-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value
-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first.
local function valuesUnique(party, get)
local count = #party
for i = 1, count - 1 do
local mon = party[i]
if not Breeding.isEgg(mon) then
local value = get(mon)
if value ~= nil and value ~= 0 then
for j = i + 1, count do
local other = party[j]
if not Breeding.isEgg(other) and get(other) == value then
return false
end
end
end
end
end
return true
end
local function speciesOf(mon) return mon and mon.species or nil end
local function itemOf(mon) return mon and mon.item or nil end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216
function BattleTower.speciesUnique(party)
return valuesUnique(party, speciesOf)
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282
function BattleTower.itemsUnique(party)
return valuesUnique(party, itemOf)
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299
function BattleTower.partyHasEgg(party)
for _, mon in ipairs(party) do
if Breeding.isEgg(mon) then return true end
end
return false
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every
-- check runs, the first failure prints the header first, and a tail line
-- follows any failure at all. Returns the text labels in printing order.
function BattleTower.checkRules(party)
party = party or {}
local failed = {
not BattleTower.partyCountOk(party),
not BattleTower.speciesUnique(party),
not BattleTower.itemsUnique(party),
BattleTower.partyHasEgg(party),
}
local lines, any = {}, false
for index = 1, #BattleTower.RULE_FAIL_TEXTS do
if failed[index] then
if not any then
any = true
lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT
end
lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index]
end
end
if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end
return lines, any
end
-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what
-- opens rooms above L40, and the last row is always CANCEL.
function BattleTower.levelGroupCount(save)
if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end
return BattleTower.PRE_HOF_LEVEL_GROUPS
end
function BattleTower.levelGroupRows(save)
local rows = {}
for group = 1, BattleTower.levelGroupCount(save) do
rows[group] = { group = group, level = group * 10 }
end
return rows
end
-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the
-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47).
function BattleTower.roomOf(group)
return math.floor((math.max(1, counter(group)) - 1) / 2)
end
-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck
function BattleTower.levelCheck(party, group)
local cap = counter(group) * 10
for _, mon in ipairs(party or {}) do
if (tonumber(mon.level) or 0) > cap then return true end
end
return false
end
-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below
-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49.
function BattleTower.ubersCheck(party, group)
if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end
for _, mon in ipairs(party or {}) do
if BattleTower.UBERS[mon.species]
and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then
return mon.species
end
end
return nil
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a
-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the
-- bottom of the range and rerolls LUCKY_PUNCH.
function BattleTower.rollReward(order, random)
if type(order) ~= "table" then return nil end
local low, high
for index, name in pairs(order) do
if name == BattleTower.MIN_REWARD then low = index end
if name == BattleTower.MAX_REWARD then high = index end
end
if not (low and high) then return nil end
local span = high - low + 1
local mask = 1
while mask < span do mask = mask * 2 end
for _ = 1, 64 do
local roll = random(mask) % mask
if roll >= span then roll = roll - span end
local item = order[low + roll]
if item ~= BattleTower.SKIPPED_REWARD then return item end
end
return order[low]
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of
-- the reward only fit when the ITEM pocket has a free slot, or already holds
-- the reward with room for five more. Otherwise the desk hands over a POTION,
-- which the script reads as "your PACK is stuffed full".
function BattleTower.rewardFits(slots, capacity, held)
if counter(slots) < counter(capacity) then return true end
if held == nil then return false end
return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1
end
-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/
-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block.
-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver.
function BattleTower.roster(data)
local trainers = data and (data.gen2Trainers or data.trainers)
local roster = trainers and trainers.battleTower
if type(roster) ~= "table" then return nil end
if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then
return nil
end
return roster
end
-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three
-- species and the one before that's, which the draw refuses to repeat.
function BattleTower.prevTeams(save)
local tower = BattleTower.state(save)
local teams = tower.prevTeams
if type(teams) ~= "table" then
teams = {}
tower.prevTeams = teams
end
if type(teams.prev) ~= "table" then teams.prev = {} end
if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end
return teams
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the
-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes
-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10.
function BattleTower.opponentGroup(save, roster)
local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP
local group = counter(BattleTower.state(save).levelGroup)
if group < 1 then return 1 end
if group > groups then return groups end
return group
end
-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw
-- from the survivors is the same distribution and cannot spin. An empty
-- survivor set is where the cart's own loop would hang, so it draws unfiltered.
local function drawFiltered(count, random, accept)
local pool = {}
for index = 1, count do
if accept(index) then pool[#pool + 1] = index end
end
if #pool == 0 then return random(count) end
return pool[random(#pool)]
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll
-- is refused while it names anybody already in sBTTrainers, and the winner is
-- written into the slot sNrOfBeatenBattleTowerTrainers points at.
function BattleTower.chooseTrainer(save, roster, random)
local tower = BattleTower.state(save)
-- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with
-- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows.
local ceiling = counter(roster.sampleTrainers)
if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end
local seen = {}
for slot = 1, BattleTower.STREAK_LENGTH do
local held = tonumber(tower.trainers[slot])
if held then seen[held] = true end
end
local index = drawFiltered(ceiling, random, function(row)
return not seen[row - 1]
end)
tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1)
+ 1] = index - 1
return roster.trainers[index]
end
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three
-- draws out of the chosen level group, each refusing a species this team
-- already holds, an ITEM this team already holds, and any species from the
-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set
-- to $ff first (:7-17), which is why an unfilled slot collides with nothing.
function BattleTower.chooseTeam(save, roster, group, random)
local rows = roster.groups[group] or {}
local teams = BattleTower.prevTeams(save)
local picked, species, items = {}, {}, {}
for _ = 1, BattleTower.PARTY_LENGTH do
local index = drawFiltered(#rows, random, function(row)
local mon = rows[row]
if not mon then return false end
if species[mon.species] then return false end
if mon.item ~= nil and items[mon.item] then return false end
for _, seen in ipairs(teams.prev) do
if seen == mon.species then return false end
end
for _, seen in ipairs(teams.prevPrev) do
if seen == mon.species then return false end
end
return true
end)
local mon = rows[index]
if not mon then break end
picked[#picked + 1] = mon
species[mon.species] = true
if mon.item ~= nil then items[mon.item] = true end
end
-- :195-206, after all three: this team becomes sBTMonPrevTrainer and the
-- one it displaces becomes sBTMonPrevPrevTrainer.
local prev = {}
for slot, mon in ipairs(picked) do prev[slot] = mon.species end
teams.prevPrev = teams.prev
teams.prev = prev
return picked
end
-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The
-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved --
-- only the sBTTrainers slot and the two previous teams this walk writes are.
function BattleTower.drawOpponent(data, save, random)
local roster = BattleTower.roster(data)
if not roster then return nil end
local group = BattleTower.opponentGroup(save, roster)
local trainer = BattleTower.chooseTrainer(save, roster, random)
if not trainer then return nil end
local rows = BattleTower.chooseTeam(save, roster, group, random)
return {
index = trainer.index,
name = trainer.name,
class = trainer.class,
classId = trainer.classId,
sprite = roster.classSprites and roster.classSprites[trainer.classId],
group = group,
rows = rows,
}
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570
-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing
-- ReadBTTrainerParty does: the challenge is now in progress and the streak
-- counter steps BEFORE the battle, which is why beating the seventh opponent
-- leaves the counter on BATTLETOWER_STREAK_LENGTH.
function BattleTower.beginBattle(save)
local tower = BattleTower.state(save)
tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS
tower.streak = counter(tower.streak) + 1
return tower.streak
end
-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the
-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored
-- stats go on top of Mon.new's, which agree with them anyway.
-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one
-- with GetPokemonName, so the rows carry none.
function BattleTower.battleParty(data, rows)
local party = {}
for _, row in ipairs(rows or {}) do
local moves = nil
if row.moves and #row.moves > 0 then
moves = {}
for slot, id in ipairs(row.moves) do
local def = data and data.moves and data.moves[id]
local max = (def and def.pp) or 0
moves[slot] = {
id = id,
pp = (row.pp and row.pp[slot]) or max,
maxPp = max,
}
end
end
local dvs = row.dvs or {}
local statExp = row.statExp or {}
local mon = Mon.new(data, row.species, row.level, {
moves = moves,
item = row.item,
dvs = { attack = dvs.attack, defense = dvs.defense,
speed = dvs.speed, special = dvs.special },
statExp = { hp = statExp.hp, attack = statExp.attack,
defense = statExp.defense, speed = statExp.speed,
special = statExp.special },
happiness = row.happiness,
})
if mon then
if row.stats then
mon.stats = {
hp = row.stats.hp, attack = row.stats.attack,
defense = row.stats.defense, speed = row.stats.speed,
specialAttack = row.stats.specialAttack,
specialDefense = row.stats.specialDefense,
}
end
mon.maxHp = row.maxHp or mon.maxHp
mon.hp = row.hp or mon.maxHp
mon.experience = row.experience or mon.experience
party[#party + 1] = mon
end
end
return party
end
return BattleTower
+20 -1
View File
@@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which)
rebuilt.status = nil
for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end
rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel
-- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA
-- included -- engine/pokemon/move_mon.asm:805.
rebuilt.caughtTime = mon.caughtTime
rebuilt.caughtLocation = mon.caughtLocation
rebuilt.caughtByGender = mon.caughtByGender
rebuilt.ot, rebuilt.otId = mon.ot, mon.otId
-- CalcExpAtLevel, which is the experience loss.
rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel)
@@ -1077,7 +1082,9 @@ end
-- Returns the new record plus a table of side effects the caller owes:
-- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the
-- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species.
function Breeding.hatch(data, save, index, nickname)
-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and
-- playerGender -- engine/pokemon/breeding.asm:228.
function Breeding.hatch(data, save, index, nickname, where)
local party = (save and save.party) or {}
local egg = party[index]
if not Breeding.isEgg(egg) then return nil end
@@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname)
hatched.ot = egg.ot or (save.player and save.player.name)
hatched.otId = egg.otId or (save.player and save.player.id)
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
if Mon.hasCaughtData(save and save.version) then
where = where or {}
Mon.setCaughtData(hatched, {
level = Mon.CAUGHT_EGG_LEVEL,
timeOfDay = where.timeOfDay,
landmark = where.landmark,
playerGender = where.playerGender
or (save.player and save.player.gender),
})
end
party[index] = hatched
Breeding.markPokedex(save, egg.species)
+6
View File
@@ -357,6 +357,7 @@ Evolution.MON_FIELDS = {
experience = true, dvs = true, stats = true, hp = true, maxHp = true,
types = true, moves = true, item = true, status = true, happiness = true,
caughtLevel = true, shiny = true, gender = true,
caughtTime = true, caughtLocation = true, caughtByGender = true,
}
-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it
@@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry)
evolved.experience = mon.experience
evolved.status = mon.status
evolved.caughtLevel = mon.caughtLevel
-- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back
-- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293.
evolved.caughtTime = mon.caughtTime
evolved.caughtLocation = mon.caughtLocation
evolved.caughtByGender = mon.caughtByGender
-- Anything a future field adds to a party record (mail, pokerus) rides along
-- rather than being silently dropped. Only fields Mon.new does NOT own may
-- be carried: copying `nickname` back would undo
+22 -1
View File
@@ -86,8 +86,11 @@ Happiness.EVENT = {
ENERGYROOT = 16, -- 10
REVIVALHERB = 17, -- 11
GROOMING = 18, -- 12
-- Crystal only; Gold's enum stops at GROOMING
-- (pokegold constants/pokemon_data_constants.asm:205).
GAINLEVELATHOME = 19, -- 13
}
Happiness.NUM_EVENTS = 18
Happiness.NUM_EVENTS = 19
-- data/events/happiness_changes.asm, transcribed row for row. The three
-- columns are the three tiers below, in order.
@@ -110,6 +113,7 @@ Happiness.CHANGES = {
{ -10, -10, -15 }, -- 10 Used Energy Root (bitter)
{ -15, -15, -20 }, -- 11 Used Revival Herb (bitter)
{ 3, 3, 1 }, -- 12 Grooming
{ 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal)
}
-- Which of HappinessChanges' three columns a CURRENT value reads. The cart
@@ -174,6 +178,23 @@ function Happiness.change(mon, event)
return value
end
-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK
-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20.
function Happiness.levelUpEvent(mon, landmark)
local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil
landmark = tonumber(landmark)
if not (caught and landmark) then return "GAINLEVEL" end
if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then
return "GAINLEVEL"
end
return "GAINLEVELATHOME"
end
-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19.
function Happiness.levelUp(mon, landmark)
return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark))
end
-- The same event across a party, which is how the Gym Leader award is written
-- out longhand in engine/battle/core.asm InitEnemyTrainer:
--
+128 -18
View File
@@ -1,5 +1,5 @@
-- The two pieces of Gen 2 world state that override where a wild mon comes
-- from: the three roaming legendaries, and swarms.
-- from: the roaming legendaries, and swarms.
--
-- Both live in one module because they are the same KIND of thing -- a
-- persistent record that sits in front of a map's own encounter table -- and
@@ -26,6 +26,7 @@
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
-- beast is caught or beaten -- is nil.
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local Runtime = require("src.mods.Runtime")
@@ -37,7 +38,7 @@ local Roamers = {}
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
-- teleport triggers) -- so a tracker mod sees every hop.
--
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only)
-- slot that roamer record, already carrying the new map
-- species the beast's species id
-- from the map id it left
@@ -68,9 +69,31 @@ Roamers.SPECIES = {
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
}
Roamers.COUNT = 3
Roamers.LEVEL = 40
-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only,
-- so the roster is read out of the cache when the cache carries one.
local function startMapFor(species)
for _, row in ipairs(Roamers.SPECIES) do
if row.species == species then return row.map end
end
return nil
end
function Roamers.roster(encounters)
local extracted = encounters and encounters.roamMons
if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end
local rows = {}
for _, row in ipairs(extracted) do
rows[#rows + 1] = {
species = row.species,
level = row.level or Roamers.LEVEL,
map = row.map or startMapFor(row.species),
}
end
return rows
end
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
@@ -185,7 +208,7 @@ end
-- The save record
--------------------------------------------------------------------------
--
-- save.roamers is a three-slot array in the order above, written first by the
-- save.roamers is a slot array in the roster order above, written first by the
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
-- basement script fires. Each slot:
--
@@ -215,13 +238,15 @@ function Roamers.slot(save, index)
end
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
-- flag, but a re-init would hand the player three fresh beasts, so this only
-- writes when there is nothing there.
-- flag, but a re-init would hand the player fresh beasts, so this only writes
-- when there is nothing there.
function Roamers.init(save, opts)
if type(save) ~= "table" then return nil end
if save.roamers and not (opts and opts.force) then return save.roamers end
local encounters = opts and (opts.encounters
or (opts.data and opts.data.gen2Encounters))
local list = {}
for _, row in ipairs(Roamers.SPECIES) do
for _, row in ipairs(Roamers.roster(encounters)) do
list[#list + 1] = {
species = row.species,
level = row.level,
@@ -413,7 +438,23 @@ end
-- -- the roaming battle is one attack long unless it is trapped. The other
-- two lists are the same routine's 50% and 10% gates and live here so the
-- battle engine has one place to read them from.
Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true }
-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the
-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable.
Roamers.ALWAYS_FLEE_BY_ENGINE = {
gs = { RAIKOU = true, ENTEI = true, SUICUNE = true },
crystal = { RAIKOU = true, ENTEI = true },
}
function Roamers.alwaysFleeMons(versionId)
return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)]
or Roamers.ALWAYS_FLEE_BY_ENGINE.gs
end
-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name.
Roamers.ALWAYS_FLEE = setmetatable({}, {
__index = function(_, species) return Roamers.alwaysFleeMons()[species] end,
})
Roamers.OFTEN_FLEE = {
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
@@ -433,29 +474,79 @@ local Swarm = {}
Roamers.Swarm = Swarm
-- The state, on the save:
-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id
-- save.swarmMaps the stored map pairs, keyed by swarm kind
-- save.swarmMap the Gold single pair, kept as the legacy alias
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
-- save.dailyResetDay the day wDailyResetTimer was last restarted
--
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
-- already write the first three; this module is where they are READ and where
-- they expire.
-- already write the flags and the alias; this module is where they are READ
-- and where they expire.
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
Swarm.FISH_NONE = 0
Swarm.FISH_QWILFISH = 1
Swarm.FISH_REMORAID = 2
-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal
-- puts in front of a `swarm` map, choosing between its two stored pairs.
Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" }
Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" }
Swarm.DEFAULT_KIND = "DUNSPARCE"
local KIND_NAMES = { DUNSPARCE = true, YANMA = true }
local function kindName(kind)
if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end
if KIND_NAMES[kind] then return kind end
return Swarm.DEFAULT_KIND
end
-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save
-- (and every save written before this record was keyed) folds onto that key.
local function goldShape(maps)
return maps.YANMA == nil
end
function Swarm.maps(save)
if type(save) ~= "table" then return nil end
local maps = save.swarmMaps
if type(maps) ~= "table" then
maps = {}
save.swarmMaps = maps
end
if save.swarmMap ~= nil and goldShape(maps) then
maps[Swarm.DEFAULT_KIND] = save.swarmMap
end
return maps
end
local function anyMap(save)
if type(save) ~= "table" then return false end
if save.swarmMap ~= nil then return true end
local maps = save.swarmMaps
if type(maps) ~= "table" then return false end
for _, name in ipairs(Swarm.KIND_ORDER) do
if maps[name] ~= nil then return true end
end
return false
end
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
-- writes the map pair AND the daily flag. A port that stored only the map
-- would leave the Dunsparce call live for the rest of the game, because
-- CheckSwarmFlag answers off the flag and clears the pair itself.
function Swarm.set(save, mapId)
--
-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead.
function Swarm.set(save, mapId, kind)
if type(save) ~= "table" then return false end
save.dailyFlags = save.dailyFlags or {}
save.dailyFlags.swarm = true
save.swarmMap = mapId
local name = kindName(kind)
local maps = Swarm.maps(save)
maps[name] = mapId
if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end
return true
end
@@ -475,9 +566,22 @@ function Swarm.active(save)
and save.dailyFlags.swarm == true
end
function Swarm.mapId(save)
function Swarm.mapId(save, kind)
if not Swarm.active(save) then return nil end
return save.swarmMap
local maps = Swarm.maps(save)
return maps and maps[kindName(kind)] or nil
end
-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and
-- falls through to Yanma, so a map both are on answers Dunsparce.
function Swarm.onMap(save, mapId)
if mapId == nil or not Swarm.active(save) then return nil end
local maps = Swarm.maps(save)
if not maps then return nil end
for _, name in ipairs(Swarm.KIND_ORDER) do
if maps[name] == mapId then return name end
end
return nil
end
function Swarm.fishing(save)
@@ -493,6 +597,12 @@ function Swarm.check(save)
if type(save) ~= "table" then return 1 end
if Swarm.active(save) then return 0 end
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
-- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole,
-- which strands both of Crystal's pairs on the one daily tick.
local maps = save.swarmMaps
if type(maps) == "table" then
for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end
end
save.swarmMap = nil
return 1
end
@@ -518,10 +628,10 @@ end
-- rather than needing its own timer. Returns true when the swarm ended on
-- this call.
function Swarm.timeEvents(save, day)
local hadMap = anyMap(save)
local reset = Swarm.checkDailyReset(save, day)
local hadMap = save and save.swarmMap ~= nil
Swarm.check(save)
return reset and hadMap and (save.swarmMap == nil)
return reset and hadMap and not anyMap(save)
end
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
@@ -538,7 +648,7 @@ end
-- lookup here falls through to the map's own list.
function Swarm.entry(save, encounters, mapId, kind)
if not encounters then return nil end
if Swarm.mapId(save) ~= mapId then return nil end
if not Swarm.onMap(save, mapId) then return nil end
local table_ = (kind == "water") and encounters.swarmWater
or encounters.swarmGrass
return table_ and table_[mapId] or nil
+85 -6
View File
@@ -133,10 +133,33 @@ local function fs()
return love.filesystem
end
-- The blank-name fallback is the first PlayerNameArray row, which differs
-- per edition -- data/player_names.asm:12-23.
function Save.defaultPlayerName(version)
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and
-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35.
Save.DEFAULT_PLAYER_NAMES = {
gold = "GOLD",
silver = "SILVER",
crystal = "CHRIS",
}
-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to
-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781).
Save.DEFAULT_PLAYER_NAMES_FEMALE = {
crystal = "KRIS",
}
-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177).
function Save.isFemale(save)
local player = type(save) == "table" and save.player
return (player and player.gender) == "female"
end
function Save.defaultPlayerName(version, gender)
version = version or GameVersion.get()
if gender == "female" then
local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version]
if female then return female end
end
return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD"
end
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
@@ -148,10 +171,13 @@ function Save.newGame(opts)
version = GameVersion.get(),
generation = 2,
player = {
name = opts.playerName or Save.defaultPlayerName(),
name = opts.playerName
or Save.defaultPlayerName(nil, opts.gender or "male"),
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
-- (engine/menus/intro_menu.asm:41-49).
id = opts.trainerId or rand(0, 65535),
-- InitCrystalData zeroes wPlayerGender before InitGender is even offered
-- (engine/menus/init_gender.asm:1-6).
gender = opts.gender or "male",
money = 3000,
coins = 0,
@@ -379,6 +405,49 @@ local function normalizePokerus(mons)
end
end
-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte.
Save.GS_BALL_STATES = { have = true, given = true, used = true }
local function counter(value)
return math.max(0, math.floor(tonumber(value) or 0))
end
-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the
-- way Mail.state creates sPartyMail, so a Crystal handler can index freely.
function Save.crystalState(save)
local crystal = save.crystal or {}
save.crystal = crystal
if crystal.celebiCaught == nil then crystal.celebiCaught = false end
crystal.beasts = crystal.beasts or {}
-- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance.
local buena = crystal.buenaPassword or {}
crystal.buenaPassword = buena
buena.prizesToday = counter(buena.prizesToday)
buena.streak = counter(buena.streak)
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor.
crystal.moveTutor = crystal.moveTutor or {}
if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end
-- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns.
crystal.unownWords = crystal.unownWords or {}
return crystal
end
-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil
-- until one is won, which is sBattleTowerReward's zero byte (:160).
function Save.battleTowerState(save)
local tower = save.battleTower or {}
save.battleTower = tower
-- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers.
tower.streak = counter(tower.streak)
tower.best = counter(tower.best)
-- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower.
tower.challenge = counter(tower.challenge)
-- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers.
tower.prevTeams = tower.prevTeams or {}
if tower.inChallenge == nil then tower.inChallenge = false end
return tower
end
-- Fill in anything a save (or an older save) is missing, so callers can index
-- freely. Runs on both newGame and load.
function Save.normalize(save)
@@ -390,7 +459,11 @@ function Save.normalize(save)
end
save.generation = 2
save.player = save.player or {}
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
-- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never
-- writes it -- constants/ram_constants.asm:177.
save.player.gender = save.player.gender or "male"
save.player.name = save.player.name
or Save.defaultPlayerName(save.version, save.player.gender)
save.player.id = save.player.id or rand(0, 65535)
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
@@ -462,6 +535,12 @@ function Save.normalize(save)
save.playTime = save.playTime
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
save.rtc = save.rtc or {}
-- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a
-- Gold or Silver file never grows either key.
if GameVersion.engine(save.version) == "crystal" then
Save.crystalState(save)
Save.battleTowerState(save)
end
-- HallOfFame.record fills in the count and the roster list, and trims a
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
-- party gets below, for the same reason.
+3 -2
View File
@@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version)
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "gold", "silver", "assets",
local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets",
"yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated", "silver/assets/generated",
"silver/data/generated" }
"silver/data/generated", "crystal/assets/generated",
"crystal/data/generated" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
+396
View File
@@ -0,0 +1,396 @@
-- Crystal's intro movie and title screen extraction (pokecrystal
-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374).
-- Called from RomExtractorGen2's extractIntro/extractTitle stages when
-- edition == "crystal"; reads through the passed-in extractor and never
-- mutates it.
local ImageWriter = require("src.import.ImageWriter")
local CrystalMovie = {}
local TILE_BYTES = 16
local SHEET_TILES = 16
-- vBGMap tilemaps and attrmaps are one full 32x32 map
-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes).
local MAP_BYTES = 1024
local function padTiles(raw, count)
local length = count * TILE_BYTES
while #raw > length do table.remove(raw) end
while #raw < length do raw[#raw + 1] = 0 end
return raw
end
-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target`
-- starting at tile `at` (0-based).
local function overlayTiles(target, source, at, from, count)
for offset = 1, count * TILE_BYTES do
target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0
end
return target
end
local function blankTiles(count)
local out = {}
for index = 1, count * TILE_BYTES do out[index] = 0 end
return out
end
-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16),
-- the same layout data/sprite_anims/oam.asm:120-136 assumes.
local function writeSheet(self, raw, count, relative)
padTiles(raw, count)
local rows = math.ceil(count / SHEET_TILES)
padTiles(raw, rows * SHEET_TILES)
self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true)
return "assets/generated/" .. relative
end
local function readMap(self, label)
local bytes = self:decompressLz3Symbol(label)
while #bytes > MAP_BYTES do table.remove(bytes) end
while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end
return bytes
end
-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies
-- `16 palettes` over wBGPals1, which wOBPals1 follows).
local function readPalettes(self, label)
local symbol = self:symbol(label)
local flat = self:colors(symbol.bank, symbol.address, 64)
local bg, obj = {}, {}
for pal = 0, 7 do
local a, b = {}, {}
for slot = 1, 4 do
a[slot] = flat[pal * 4 + slot]
b[slot] = flat[(pal + 8) * 4 + slot]
end
bg[pal + 1], obj[pal + 1] = a, b
end
return { bg = bg, obj = obj }
end
local function readColors(self, label, count)
local symbol = self:symbol(label)
return self:colors(symbol.bank, symbol.address, count)
end
function CrystalMovie.extractIntro(self)
self:beginStage("Intro movie")
local out = {
generation = 2,
layout = "crystal",
source = "ROM:CrystalIntro (engine/movie/intro.asm)",
}
local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128)
local grassSym = self:symbol("IntroGrass4GFX")
local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES)
local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png")
local pulsePath = writeSheet(self,
self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png")
local backgroundPath = writeSheet(self,
self:decompressLz3Symbol("IntroBackgroundGFX"), 128,
"intro/background_tiles.png")
local runPath = writeSheet(self,
self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192,
"intro/suicune_run_sprites.png")
local pichuPath = writeSheet(self,
self:decompressLz3Symbol("IntroPichuWooperGFX"), 128,
"intro/pichu_wooper_sprites.png")
self:tick("Intro movie", 1, 4)
-- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0,
-- i.e. BG id $80 (engine/movie/intro.asm:744-753).
local jump = blankTiles(256)
overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128)
overlayTiles(jump, grass4, 0x80, 0, 1)
local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png")
-- The same grass tile is the whole of the SUICUNE_AWAY object's sheet
-- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753).
local unownBack = blankTiles(144)
overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48)
overlayTiles(unownBack, grass4, 0x80, 0, 1)
local unownBackPath = writeSheet(self, unownBack, 144,
"intro/unown_back_sprites.png")
-- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0
-- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828).
local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255)
local close = blankTiles(256)
for id = 0, 255 do
local from = (id + 128) % 256
if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end
end
local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png")
-- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and
-- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901).
local back = blankTiles(256)
overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128)
overlayTiles(back, unowns, 128, 0, 128)
overlayTiles(back, grass4, 0xff, 0, 1)
local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png")
local crystalUnownsPath = writeSheet(self,
self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32,
"intro/crystal_unowns_tiles.png")
-- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3
-- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4.
local grassStrip = blankTiles(16)
for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX",
"IntroGrass3GFX" }) do
local sym = self:symbol(label)
overlayTiles(grassStrip,
self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4,
0, 4)
end
out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png")
self:tick("Intro movie", 2, 4)
local unownPals = readPalettes(self, "IntroUnownsPalette")
local backgroundPals = readPalettes(self, "IntroBackgroundPalette")
local suicunePals = readPalettes(self, "IntroSuicunePalette")
local closePals = readPalettes(self, "IntroSuicuneClosePalette")
local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette")
local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes)
return {
tiles = tiles,
sprites = sprites,
tilemap = readMap(self, tilemapLabel),
attrmap = readMap(self, attrmapLabel),
palettes = palettes,
}
end
out.acts = {
unownA = act(unownsPath, pulsePath,
"IntroUnownATilemap", "IntroUnownAAttrmap", unownPals),
unownHI = act(unownsPath, pulsePath,
"IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals),
unowns = act(unownsPath, pulsePath,
"IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals),
background = act(backgroundPath, runPath,
"IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals),
suicuneJump = act(jumpPath, unownBackPath,
"IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals),
suicuneClose = act(closePath, nil,
"IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals),
suicuneBack = act(backPath, unownBackPath,
"IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals),
crystalUnowns = act(crystalUnownsPath, nil,
"IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap",
crystalUnownsPals),
}
-- IntroScene7/10 load pichu_wooper into VRAM bank 1
-- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet.
out.acts.background.sprites1 = pichuPath
self:tick("Intro movie", 3, 4)
out.fades = {
toWhite = {},
unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4),
unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4),
wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16),
wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16),
}
-- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each
-- (engine/movie/intro.asm:1155-1189).
local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32)
for row = 0, 7 do
local pal = {}
for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end
out.fades.toWhite[row + 1] = pal
end
self:write("intro", out)
self:tick("Intro movie", 4, 4)
return out
end
--------------------------------------------------------------------------
local function shadeOf(r)
if r > 0.9 then return 0 end
if r > 0.5 then return 1 end
if r > 0.2 then return 2 end
return 3
end
local function tilesFrom2bpp(raw)
local tiles = {}
for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do
local one = {}
for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true)
end
return tiles
end
local function blitTile(target, tile, tx, ty)
if not tile then return end
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = tile:getPixel(x, y)
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
end
end
end
local function colorize(image, palFor)
local w, h = image:getDimensions()
local out = ImageWriter.blank(w, h, 0, 0, 0, 0)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, _, _, a = image:getPixel(x, y)
if a ~= 0 then
local pal = palFor(x, y)
local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 }
out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1)
end
end
end
return out
end
function CrystalMovie.extractTitle(self)
self:beginStage("Title screen")
-- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and
-- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94).
local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX"))
-- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f
-- (engine/movie/title.asm:24-27).
local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX"))
local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX"))
local pals = readPalettes(self, "TitleScreenPalettes")
self:tick("Title screen", 1, 5)
-- Attribute regions from _TitleScreen's ByteFills
-- (engine/movie/title.asm:39-85): logo gradient rows, the version strip,
-- pal 7 on the window copyright line, pal 0 everywhere else.
local function bgPalAt(col, row)
if row == 9 and col >= 5 and col <= 15 then return 1 end
if row >= 3 and row <= 4 then return 2 end
if row == 5 then return 3 end
if row == 6 then return 4 end
if row == 7 then return 5 end
if row >= 8 and row <= 9 then return 6 end
if row == 17 then return 7 end
return 0
end
local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0)
-- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80
-- (engine/movie/title.asm:107-112,275-302).
for row = 0, 6 do
for col = 0, 19 do
blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8)
end
end
-- Copyright line: 13 tiles from d=$c on the window's row 0, shown at
-- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122).
for index = 0, 12 do
blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8)
end
local colored = colorize(shade, function(x, y)
return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1]
end)
self:save(colored, "title/crystal_screen.png")
self:save(shade, "title/crystal_screen_gray.png")
local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0)
ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56)
self:save(logo, "title/crystal_logo.png")
local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0)
ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8)
self:save(wordmark, "title/crystal_wordmark.png")
self:tick("Title screen", 2, 5)
-- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16;
-- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273).
local suicunePaths, suicuneGrayPaths = {}, {}
for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do
local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0)
for row = 0, 5 do
for col = 0, 7 do
blitTile(frame, suicuneTiles[base + row * 16 + col + 1],
col * 8, row * 8)
end
end
local tinted = colorize(frame, function() return pals.bg[1] end)
local rel = ("title/crystal_suicune_%d.png"):format(index)
self:save(tinted, rel)
suicunePaths[index] = "assets/generated/" .. rel
local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index)
self:save(frame, grayRel)
suicuneGrayPaths[index] = "assets/generated/" .. grayRel
if index == 1 then self:save(tinted, "title/crystal_suicune.png") end
end
self:tick("Title screen", 3, 5)
-- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive
-- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338).
local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0)
for strip = 0, 4 do
for slot = 0, 5 do
local tile = strip * 12 + slot * 2
blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16)
blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8)
end
end
local gemTinted = colorize(gem, function() return pals.obj[1] end)
self:save(gemTinted, "title/crystal_gem.png")
self:save(gem, "title/crystal_gem_gray.png")
self:tick("Title screen", 4, 5)
local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end
local data = {
generation = 2,
layout = "crystal_title",
source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX"
.. " + TitleScreenPalettes",
screen = "assets/generated/title/crystal_screen.png",
screenGray = "assets/generated/title/crystal_screen_gray.png",
image = "assets/generated/title/crystal_logo.png",
wordmark = "assets/generated/title/crystal_wordmark.png",
suicune = "assets/generated/title/crystal_suicune.png",
suicuneFrames = suicunePaths,
suicuneFramesGray = suicuneGrayPaths,
-- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels.
suicuneX = 48,
suicuneY = 96,
-- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243).
suicuneEvery = 8,
gem = "assets/generated/title/crystal_gem.png",
gemGray = "assets/generated/title/crystal_gem_gray.png",
-- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen
-- (56, 6) (engine/movie/title.asm:306-311,340-362).
gemX = 56,
gemY = 6,
gemFromY = -50,
gemStep = 2,
-- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line
-- signage over the logo's 80 lines; the copyright window sits at
-- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111).
entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 },
entranceSfx = "Sfx_TitleScreenEntrance",
-- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136).
timeoutFrames = 73 * 60 + 36,
sky = unit(pals.bg[1][1]),
below = unit(pals.bg[1][1]),
palettes = { bg = pals.bg, obj = pals.obj },
}
self:write("title", data)
self:tick("Title screen", 5, 5)
return data
end
return CrystalMovie
+26 -10
View File
@@ -405,6 +405,7 @@ end
local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber, silver = PAL.railSilver,
crystal = PAL.railCrystal,
}
local function cartColor(version)
return CART_COLOR[version] or PAL.green
@@ -1062,6 +1063,8 @@ local GAME_TABS = {
label = "Gold" },
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
label = "Silver" },
{ id = "crystal", key = "tab-crystal", letter = "C",
color = PAL.railCrystal, label = "Crystal" },
}
local HEADER_TABS = {
@@ -3555,24 +3558,37 @@ end
-- also what a controller reaches after the tab row.
local function buildGameModal(imp, m)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(360 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local headH = Kit.textHeight("button") + math.floor(12 * m.s)
local avail = m.H - 2 * m.pad
local cols, gap, btnH = 1, math.floor(8 * m.s), m.btnH
local function rows() return math.ceil(#GAME_TABS / cols) + 1 end
local function total() return 2 * pad + headH + rows() * btnH
+ (rows() - 1) * gap end
if total() > avail then cols = 2 end
if total() > avail then gap = math.max(2, math.floor(3 * m.s)) end
if total() > avail then
btnH = math.max(Kit.tapMin(),
btnH - math.ceil((total() - avail) / rows()))
end
local nrows = rows() - 1
local w = math.floor((cols > 1 and 440 or 360) * m.s)
local px, py, pw = modalPanel(m, w, total())
local cy = py + pad
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
cy = cy + headH
local chrome = headerChrome(imp)
for _, g in ipairs(GAME_TABS) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
local colW = math.floor((pw - 2 * pad - (cols - 1) * gap) / cols)
for i, g in ipairs(GAME_TABS) do
local bx = px + pad + ((i - 1) % cols) * (colW + gap)
local by = cy + math.floor((i - 1) / cols) * (btnH + gap)
btn(imp, bx, by, colW, btnH, "gamepop-" .. g.id,
Strings(g.label), {
face = "tab", font = "small", letter = g.letter, color = g.color,
active = imp.tab == g.id,
action = chrome.tab[g.id] })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
cy = cy + nrows * (btnH + gap)
btn(imp, px + pad, cy, pw - 2 * pad, btnH, "gamepop-close",
Strings("Close"), { font = "small",
action = function() imp._gamePopup = nil end })
end
File diff suppressed because it is too large Load Diff
+77 -7
View File
@@ -155,6 +155,53 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
"assets/generated/pc/mail_item.png",
},
crystal = {
"data/generated/constants.lua",
"data/generated/maps.lua",
"data/generated/roofs.lua",
"data/generated/sprites.lua",
"data/generated/scripts.lua",
"data/generated/text.lua",
"data/generated/rom_text.lua",
"data/generated/pokemon.lua",
"data/generated/encounters.lua",
"data/generated/tilesets.lua",
"data/generated/landmarks.lua",
"data/generated/audio.lua",
"data/generated/marts.lua",
"data/generated/oak_speech.lua",
"data/generated/title.lua",
"data/generated/intro.lua",
"assets/generated/fonts/font.png",
"assets/generated/fonts/frames.png",
"assets/generated/title/crystal_logo.png",
"assets/generated/title/crystal_wordmark.png",
"assets/generated/title/crystal_suicune.png",
"assets/generated/splash/ditto.png",
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
"assets/generated/intro/suicune_run_sprites.png",
"assets/generated/intro/unowns_tiles.png",
"assets/generated/intro/oak.png",
"assets/generated/tilesets/johto.png",
"assets/generated/tilesets/roofs/new_bark.png",
"assets/generated/sprites/chris.png",
"assets/generated/sprites/kris.png",
"assets/generated/battle/front/chikorita.png",
"assets/generated/battle/front/wooper.png",
"assets/generated/battle/front/pikachu.png",
"assets/generated/battle/trainers/falkner.png",
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
"assets/generated/pc/mail_item.png",
"assets/generated/trainer_card/card_f.png",
"data/generated/mobile_gfx.lua",
"assets/generated/battle/player_back_female.png",
"assets/generated/battle/trainers/kris.png",
"assets/generated/battle/trainers/chris.png",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
@@ -605,6 +652,27 @@ local function isAcceptedRomSize(n)
return n == ROM_BYTES_GEN1 or n == ROM_BYTES_GEN2
end
local function cartLabels(generation)
local out = {}
for _, id in ipairs(GameVersion.ORDER) do
if generation == nil or GameVersion.generation(id) == generation then
out[#out + 1] = GameVersion.info(id).label
end
end
return out
end
local function cartsSlashed(generation)
return table.concat(cartLabels(generation), "/")
end
local function cartsProse()
local names = cartLabels(nil)
local last = table.remove(names)
if #names == 0 then return last end
return table.concat(names, ", ") .. ", or " .. last
end
local function savesInboxDir(version)
return SAVES_INBOX_DIR .. "/" .. tostring(version)
end
@@ -1849,18 +1917,18 @@ function RomImporter:startData(data, displayName)
return
end
if not isAcceptedRomSize(#data) then
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
:format(#data / 1024 / 1024))
self:setError(("Expected a 1 MiB Game Boy ROM (%s) or a "
.. "2 MiB Game Boy Color ROM (%s); this file is %.2f MiB.")
:format(cartsSlashed(1), cartsSlashed(2), #data / 1024 / 1024))
return
end
local actualHash = sha1(data)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
.. "%s dump; patched, trimmed or "
.. "\"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
.. "(tagged [b] or [BF]) never verify."):format(actualHash, cartsProse()))
return
end
local info = GameVersion.info(version)
@@ -2965,8 +3033,10 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "silver",
"mods", "find", "skins", "bug" }
local order = { "mods", "find", "skins", "bug" }
for i = #GameVersion.ORDER, 1, -1 do
table.insert(order, 1, GameVersion.ORDER[i])
end
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
+164
View File
@@ -0,0 +1,164 @@
-- ../pokecrystal/engine/gfx/pic_animation.asm:544 ConvertAndApplyBitmask
-- ../pokecrystal/engine/gfx/pic_animation.asm:356 PokeAnim_DoAnimScript
local MonAnim = {}
MonAnim.__index = MonAnim
-- .Sizes db 4, 5, 7: bitmask bytes for a 5x5, 6x6 and 7x7 pic.
-- ../pokecrystal/engine/gfx/pic_animation.asm:534
MonAnim.BITMASK_BYTES = { [5] = 4, [6] = 5, [7] = 7 }
-- ../pokecrystal/macros/scripts/pic_anims.asm:13-27
MonAnim.END = 0xff
MonAnim.SETREPEAT = 0xfe
MonAnim.DOREPEAT = 0xfd
-- .NextBit walks the row first, so bit i is row i % height, column i / height.
-- ../pokecrystal/engine/gfx/pic_animation.asm:746
function MonAnim.tileMap(data, frame)
local tiles = data and data.tiles
if not tiles then return nil end
local count = tiles * tiles
local out = {}
for i = 1, count do out[i] = i - 1 end
if not frame or frame <= 0 then return out end
local row = data.frames and data.frames[frame]
local mask = row and data.bitmasks and data.bitmasks[row.bitmask]
if not (row and mask) then return nil end
local cursor = 1
for i = 0, count - 1 do
local byte = mask[math.floor(i / 8) + 1] or 0
if math.floor(byte / 2 ^ (i % 8)) % 2 == 1 then
local id = row.tiles[cursor]
if id == nil then return nil end
out[i + 1] = id
cursor = cursor + 1
end
end
return out
end
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16), truncated to 8 bits.
-- ../pokecrystal/engine/gfx/pic_animation.asm:413
function MonAnim.duration(param, speed)
param = param % 256
local scaled = math.floor(param * (speed or 0) / 16) % 256
return (scaled + param) % 256
end
-- The PokeAnims programs less their cry commands; "wait" is SetWait's 18.
-- ../pokecrystal/engine/gfx/pic_animation.asm:69-77, :150-164
local SCENES = {
battle = { "setup", "play" },
battleSlow = { "setup2", "play" },
menu = { "setup", "play", "wait", "idle", "play" },
}
MonAnim.SCENE_WAIT = 18
function MonAnim.scenes() return SCENES end
function MonAnim.new(data, scene)
local steps = SCENES[scene or "battle"]
if not (data and steps and data.play and #data.play > 0) then return nil end
return setmetatable({
data = data,
steps = steps,
step = 1,
frame = 0,
speed = 0,
script = nil,
pc = 1,
repeatTimer = 0,
waiting = false,
waitCounter = 0,
sceneWait = nil,
done = false,
}, MonAnim)
end
function MonAnim:finished() return self.done end
-- PokeAnim_GetFrame's `and a / ret z`: command 0 is the base picture.
-- ../pokecrystal/engine/gfx/pic_animation.asm:431-435
function MonAnim:currentFrame() return self.frame end
function MonAnim:beginScript(rows, speed)
self.script = rows or {}
self.speed = speed
self.pc = 1
self.repeatTimer = 0
self.waiting = false
self.waitCounter = 0
end
-- One tick of PokeAnim_DoAnimScript; true once the script has hit endanim.
-- ../pokecrystal/engine/gfx/pic_animation.asm:370-411
function MonAnim:runScript()
if self.waiting then
self.waitCounter = (self.waitCounter - 1) % 256
if self.waitCounter == 0 then self.waiting = false end
return false
end
for _ = 1, 256 do
local row = self.script[self.pc]
self.pc = self.pc + 1
if row == nil then return true end
local command = row[1]
if command == MonAnim.END then
return true
elseif command == MonAnim.SETREPEAT then
self.repeatTimer = row[2]
elseif command == MonAnim.DOREPEAT then
-- .DoRepeat returns on both `ret z` (:397-406).
if self.repeatTimer == 0 then return false end
self.repeatTimer = self.repeatTimer - 1
if self.repeatTimer == 0 then return false end
self.pc = row[2] + 1
else
self.frame = command
self.waiting = true
self.waitCounter = MonAnim.duration(row[2], self.speed)
-- StartWaitAnim falls through into .WaitAnim (:383-388), so the frame
-- it places is the frame the counter first ticks on.
self.waitCounter = (self.waitCounter - 1) % 256
if self.waitCounter == 0 then self.waiting = false end
return false
end
end
return true
end
-- One iteration of AnimateFrontpic's .loop: one scene command per frame.
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
function MonAnim:update()
if self.done then return end
local step = self.steps[self.step]
if step == nil then
-- PokeAnim_Finish's DeinitFrames puts the base picture back (:224-228).
self.frame = 0
self.done = true
return
end
if step == "setup" or step == "setup2" or step == "idle" then
local rows = (step == "idle") and self.data.idle or self.data.play
self:beginScript(rows, step == "setup2" and 4 or 0)
self.step = self.step + 1
return
end
if step == "wait" then
self.sceneWait = (self.sceneWait or MonAnim.SCENE_WAIT) - 1
if self.sceneWait <= 0 then
self.sceneWait = nil
self.step = self.step + 1
end
return
end
if self:runScript() then
-- PokeAnim_Play redraws the base picture as the script ends (:196-205).
self.frame = 0
self.step = self.step + 1
end
end
return MonAnim
+8 -6
View File
@@ -21,6 +21,7 @@
-- require alone cannot see them there (#420).
local GenSave = require("src.save_convert.GenSave")
local GameVersion = require("src.core.GameVersion")
local SaveConvert = {}
@@ -228,10 +229,11 @@ SaveConvert.mergeDefaults = mergeDefaults
-- codec exists yet. Both directions answer with a plain message the
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
-- table through Gen 1 offsets and surfacing a codec traceback.
local GEN2_SAV_UNSUPPORTED = {
gold = "Pokemon Gold",
silver = "Pokemon Silver",
}
local function gen2CartName(gameVersion)
if not GameVersion.VERSIONS[gameVersion] then return nil end
if GameVersion.generation(gameVersion) ~= 2 then return nil end
return GameVersion.info(gameVersion).displayName
end
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
@@ -245,7 +247,7 @@ function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion]
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
end
@@ -281,7 +283,7 @@ function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion]
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
end
+129 -5
View File
@@ -173,6 +173,113 @@ local Opcodes = {
[0xa1] = { name = "warpfacing", size = 5 },
}
-- pokegold/macros/scripts/events.asm:1015 DEF NUM_EVENT_COMMANDS EQU $a2
Opcodes.NUM_EVENT_COMMANDS = 0xa2
-- pokecrystal/macros/scripts/events.asm:1-539 is identical to pokegold's, so
-- $00..$51 is copied; :541 inserts farjumptext and shifts everything after it.
local CRYSTAL = {}
for byte = 0x00, 0x51 do
local row = Opcodes[byte]
CRYSTAL[byte] = { name = row.name, size = row.size }
end
-- pokecrystal/macros/scripts/events.asm:541-1066, cross-checked against
-- ScriptCommandTable at pokecrystal/engine/overworld/scripting.asm:64-237.
CRYSTAL[0x52] = { name = "farjumptext", size = 3 }
CRYSTAL[0x53] = { name = "jumptext", size = 2 }
CRYSTAL[0x54] = { name = "waitbutton", size = 0 }
CRYSTAL[0x55] = { name = "promptbutton", size = 0 }
CRYSTAL[0x56] = { name = "pokepic", size = 1 }
CRYSTAL[0x57] = { name = "closepokepic", size = 0 }
CRYSTAL[0x58] = { name = "_2dmenu", size = 0 }
CRYSTAL[0x59] = { name = "verticalmenu", size = 0 }
CRYSTAL[0x5a] = { name = "loadpikachudata", size = 0 }
CRYSTAL[0x5b] = { name = "randomwildmon", size = 0 }
CRYSTAL[0x5c] = { name = "loadtemptrainer", size = 0 }
CRYSTAL[0x5d] = { name = "loadwildmon", size = 2 }
CRYSTAL[0x5e] = { name = "loadtrainer", size = 2 }
CRYSTAL[0x5f] = { name = "startbattle", size = 0 }
CRYSTAL[0x60] = { name = "reloadmapafterbattle", size = 0 }
CRYSTAL[0x61] = { name = "catchtutorial", size = 1 }
CRYSTAL[0x62] = { name = "trainertext", size = 1 }
CRYSTAL[0x63] = { name = "trainerflagaction", size = 1 }
CRYSTAL[0x64] = { name = "winlosstext", size = 4 }
CRYSTAL[0x65] = { name = "scripttalkafter", size = 0 }
CRYSTAL[0x66] = { name = "endifjustbattled", size = 0 }
CRYSTAL[0x67] = { name = "checkjustbattled", size = 0 }
CRYSTAL[0x68] = { name = "setlasttalked", size = 1 }
CRYSTAL[0x69] = { name = "applymovement", size = 3 }
CRYSTAL[0x6a] = { name = "applymovementlasttalked", size = 2 }
CRYSTAL[0x6b] = { name = "faceplayer", size = 0 }
CRYSTAL[0x6c] = { name = "faceobject", size = 2 }
CRYSTAL[0x6d] = { name = "variablesprite", size = 2 }
CRYSTAL[0x6e] = { name = "disappear", size = 1 }
CRYSTAL[0x6f] = { name = "appear", size = 1 }
CRYSTAL[0x70] = { name = "follow", size = 2 }
CRYSTAL[0x71] = { name = "stopfollow", size = 0 }
CRYSTAL[0x72] = { name = "moveobject", size = 3 }
CRYSTAL[0x73] = { name = "writeobjectxy", size = 1 }
CRYSTAL[0x74] = { name = "loademote", size = 1 }
CRYSTAL[0x75] = { name = "showemote", size = 3 }
CRYSTAL[0x76] = { name = "turnobject", size = 2 }
CRYSTAL[0x77] = { name = "follownotexact", size = 2 }
CRYSTAL[0x78] = { name = "earthquake", size = 1 }
CRYSTAL[0x79] = { name = "changemapblocks", size = 3 }
CRYSTAL[0x7a] = { name = "changeblock", size = 3 }
CRYSTAL[0x7b] = { name = "reloadmap", size = 0 }
CRYSTAL[0x7c] = { name = "refreshmap", size = 0 }
CRYSTAL[0x7d] = { name = "writecmdqueue", size = 2 }
CRYSTAL[0x7e] = { name = "delcmdqueue", size = 1 }
CRYSTAL[0x7f] = { name = "playmusic", size = 2 }
CRYSTAL[0x80] = { name = "encountermusic", size = 0 }
CRYSTAL[0x81] = { name = "musicfadeout", size = 3 }
CRYSTAL[0x82] = { name = "playmapmusic", size = 0 }
CRYSTAL[0x83] = { name = "dontrestartmapmusic", size = 0 }
CRYSTAL[0x84] = { name = "cry", size = 2 }
CRYSTAL[0x85] = { name = "playsound", size = 2 }
CRYSTAL[0x86] = { name = "waitsfx", size = 0 }
CRYSTAL[0x87] = { name = "warpsound", size = 0 }
CRYSTAL[0x88] = { name = "specialsound", size = 0 }
CRYSTAL[0x89] = { name = "autoinput", size = 3 }
CRYSTAL[0x8a] = { name = "newloadmap", size = 1 }
CRYSTAL[0x8b] = { name = "pause", size = 1 }
CRYSTAL[0x8c] = { name = "deactivatefacing", size = 1 }
CRYSTAL[0x8d] = { name = "sdefer", size = 2 }
CRYSTAL[0x8e] = { name = "warpcheck", size = 0 }
CRYSTAL[0x8f] = { name = "stopandsjump", size = 2 }
CRYSTAL[0x90] = { name = "endcallback", size = 0 }
CRYSTAL[0x91] = { name = "end", size = 0 }
CRYSTAL[0x92] = { name = "reloadend", size = 1 }
CRYSTAL[0x93] = { name = "endall", size = 0 }
CRYSTAL[0x94] = { name = "pokemart", size = 3 }
CRYSTAL[0x95] = { name = "elevator", size = 2 }
CRYSTAL[0x96] = { name = "trade", size = 1 }
CRYSTAL[0x97] = { name = "askforphonenumber", size = 1 }
CRYSTAL[0x98] = { name = "phonecall", size = 2 }
CRYSTAL[0x99] = { name = "hangup", size = 0 }
CRYSTAL[0x9a] = { name = "describedecoration", size = 1 }
CRYSTAL[0x9b] = { name = "fruittree", size = 1 }
CRYSTAL[0x9c] = { name = "specialphonecall", size = 2 }
CRYSTAL[0x9d] = { name = "checkphonecall", size = 0 }
CRYSTAL[0x9e] = { name = "verbosegiveitem", size = 2 }
CRYSTAL[0x9f] = { name = "verbosegiveitemvar", size = 2 }
-- events.asm:1003-1008: Crystal's swarm is `db flag` then `map_id`, not a bare
-- map_id; Script_swarm makes three GetScriptByte calls (scripting.asm:654-662).
CRYSTAL[0xa0] = { name = "swarm", size = 3 }
CRYSTAL[0xa1] = { name = "halloffame", size = 0 }
CRYSTAL[0xa2] = { name = "credits", size = 0 }
CRYSTAL[0xa3] = { name = "warpfacing", size = 5 }
CRYSTAL[0xa4] = { name = "battletowertext", size = 1 }
CRYSTAL[0xa5] = { name = "getlandmarkname", size = 2 }
CRYSTAL[0xa6] = { name = "gettrainerclassname", size = 2 }
CRYSTAL[0xa7] = { name = "getname", size = 3 }
CRYSTAL[0xa8] = { name = "wait", size = 1 }
CRYSTAL[0xa9] = { name = "checksave", size = 0 }
-- pokecrystal/macros/scripts/events.asm:1068 DEF NUM_EVENT_COMMANDS EQU $aa
CRYSTAL.NUM_EVENT_COMMANDS = 0xaa
-- Commands that end the current linear path (jumps transfer control).
--
-- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does
@@ -185,9 +292,13 @@ local Opcodes = {
-- these four regardless. `catchtutorial` is deliberately NOT here: it ends on
-- `jp Script_reloadmap` and the script really does continue afterwards, the
-- same way `reloadmap` itself does.
--
-- `farjumptext` is Crystal-only and joins them for the same reason `jumptext`
-- is here: pokecrystal/engine/overworld/scripting.asm:318-327 ends on
-- `jp ScriptJump` into JumpTextScript.
Opcodes.TERMINATORS = {
sjump = true, farsjump = true, memjump = true, jumpstd = true,
jumptext = true, jumptextfaceplayer = true,
jumptext = true, farjumptext = true, jumptextfaceplayer = true,
stopandsjump = true, ["end"] = true, endall = true, endcallback = true,
reloadend = true,
fruittree = true, describedecoration = true,
@@ -198,10 +309,12 @@ Opcodes.TERMINATORS = {
-- behind it. Every row above is keyed by the opcode byte the cart carries, and
-- src/import/RomExtractorGen2.lua resolves a command as Opcodes[byte]: a byte
-- that is not in that table breaks the pointer walk into an `unknown` row. Give
-- a mod verb one of the free bytes ($a2..$ff) and ROM data that happens to start
-- with it would decode as a mod call instead of ending the walk, so the free
-- space stays free and the extension op is reachable only by NAME -- which is to
-- say, only from a row a mod wrote, never from one the extractor did.
-- a mod verb one of the free bytes and ROM data that happens to start with it
-- would decode as a mod call instead of ending the walk, so the free space stays
-- free and the extension op is reachable only by NAME -- which is to say, only
-- from a row a mod wrote, never from one the extractor did. The free space is
-- per dialect and the intersection is what matters: Gold leaves $a2..$ff
-- (events.asm:1015), Crystal only $aa..$ff (events.asm:1068).
--
-- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the
-- row shapes and the verb table is documented there.
@@ -211,4 +324,15 @@ function Opcodes.key(bank, address)
return string.format("%02x:%04x", bank, address)
end
CRYSTAL.TERMINATORS = Opcodes.TERMINATORS
CRYSTAL.MOD_COMMAND = Opcodes.MOD_COMMAND
CRYSTAL.key = Opcodes.key
-- Gold and Silver share one dialect (pokegold/macros/scripts/events.asm:540);
-- Crystal renumbers from $52 (pokecrystal/macros/scripts/events.asm:541).
function Opcodes.forEdition(edition)
if edition == "crystal" then return CRYSTAL end
return Opcodes
end
return Opcodes
+339 -39
View File
@@ -1,7 +1,7 @@
-- The `special` command's handlers (data/events/special_pointers.asm).
--
-- Lifted out of src/script/gen2/Vm.lua because the two are different kinds of
-- code: the VM is one interpreter with a shared control flow, and this is 112
-- code: the VM is one interpreter with a shared control flow, and this is 169
-- INDEPENDENT routines that happen to share a dispatch table. Growing them
-- inside runList's else-chain would have buried the interpreter.
--
@@ -9,9 +9,9 @@
-- which the extractor turns into constants.specialOrder, and Vm:specialName
-- resolves the one into the other; keying on the label rather than on the
-- number means a repointed table cannot silently call the wrong routine, and
-- it means a test can assert the mapping against the cache. This cache's
-- order has 112 rows -- the asm file's 113 `add_special` matches include the
-- MACRO line itself.
-- it means a test can assert the mapping against the cache. A Gold cache's
-- order has 112 rows and a Crystal one 169 -- the asm files' `add_special`
-- match counts are one higher each, because they include the MACRO line.
--
-- Three kinds of entry live here:
--
@@ -52,6 +52,7 @@
-- it, plus the fruit trees and the daily rollover Kurt waits on.
local Apricorns = require("src.core.gen2.Apricorns")
local BugContest = require("src.core.gen2.BugContest")
local GameVersion = require("src.core.GameVersion")
local Happiness = require("src.core.gen2.Happiness")
local Phone = require("src.core.gen2.Phone")
local Pokerus = require("src.core.gen2.Pokerus")
@@ -175,6 +176,20 @@ local function selectMon(vm, prompt)
return picked.index, picked.mon
end
Specials.shared = {
TRUE = TRUE,
FALSE = FALSE,
block = Specials.block,
hooks = hooks,
party = party,
save = save,
data = data,
answer = answer,
nameMon = nameMon,
selectMon = selectMon,
showRawHeld = showRawHeld,
}
--------------------------------------------------------------------------
-- Magikarp lengths (engine/events/magikarp.asm)
--------------------------------------------------------------------------
@@ -1058,7 +1073,7 @@ end
-- -- is drawn on the cartridge itself, and only the A press farcalls
-- PrintUnownStamp. src/ui/gen2/UnownPrinter.lua is that viewer and takes the
-- A press nowhere, which is what a cartridge with nothing in its link port
-- does; PrintDiploma next door has no viewer half at all and stays a stub.
-- does; PrintDiploma next door shows its page and prints nothing either.
--
-- `ld a, [wUnownDex] / and a / ret z` is the gate: with no Unown caught the
-- special returns before it draws anything. The routine never writes
@@ -1117,6 +1132,10 @@ H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end
H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end
H.FadeInFromBlack = function(vm) fade(vm, "inBlack") end
-- engine/tilesets/timeofday_pals.asm:130: FillWhiteBGColor, then the same
-- c=$9 / b=4 time-pal walk FadeInFromWhite runs, stepped by hand.
H.BattleTowerFade = function(vm) fade(vm, "inWhite") end
-- ClearBGPalettes / ClearBGPalettesBufferScreen / ClearTilemap: the screen is
-- blanked to the background colour under a fade that is already down. The
-- port fades with a flat sheet, so the sheet IS the cleared screen.
@@ -1152,6 +1171,20 @@ H.LoadUsedSpritesGFX = function(vm)
if h.reloadSprites then h.reloadSprites(false) end
end
-- ../pokecrystal/engine/overworld/warp_connection.asm:311, `ld b, SCGB_MAPPALS / jp
-- GetSGBLayout`: the map's own palette layout, reapplied and nothing else.
H.LoadMapPalettes = function(vm)
local h = hooks(vm)
if h.reloadSprites then h.reloadSprites(true) end
end
-- engine/overworld/overworld.asm:40: the used-sprite list rebuilt and its
-- VRAM pack reloaded, with no palette pass -- LoadUsedSpritesGFX's arm.
H.RefreshSprites = function(vm)
local h = hooks(vm)
if h.reloadSprites then h.reloadSprites(false) end
end
-- UpdatePlayerSprite: the player's sheet is a pure function of wPlayerState
-- (data/sprites/player_sprites.asm ChrisStateSprites), which is what makes
-- getting on and off a Lapras a one-byte change rather than an animation.
@@ -1160,6 +1193,16 @@ H.UpdatePlayerSprite = function(vm)
if h.updatePlayerSprite then h.updatePlayerSprite() end
end
-- engine/events/specials.asm:21 -> engine/overworld/map_objects.asm:2515:
-- bit 7 of wScriptVar gates the routine, bits 6-4 are the OBJ palette.
H.SetPlayerPalette = function(vm)
local value = (vm.scriptVar or 0) % 0x100
if value < 0x80 then return end
vm.playerPalette = math.floor(value / 0x10) % 8
local h = hooks(vm)
if h.setPlayerPalette then h.setPlayerPalette(vm.playerPalette) end
end
-- ---- 58-62 sound and the water --------------------------------------------
-- WaitSFX: hold until the sound effect that is playing finishes. The VM has
@@ -1445,6 +1488,22 @@ local function trailingDigitsShared(a, b)
end
Specials.trailingDigitsShared = trailingDigitsShared
-- pokegold/constants/pokemon_data_constants.asm:122-123
local NUM_BOXES, NUM_BOXES_JP = 14, 9
-- pokegold/engine/events/lucky_number.asm:22-102: sBox (the OPEN box) is walked
-- before .BoxesLoop skips it, and .BoxesLoop stops at NUM_BOXES_JP not NUM_BOXES.
local function luckyNumberBoxOrder(record)
local current = math.floor(tonumber(record and record.currentBox) or 1)
local last = GameVersion.fixes().luckyNumberBoxes and NUM_BOXES or NUM_BOXES_JP
local order = { current }
for index = 1, last do
if index ~= current then order[#order + 1] = index end
end
return order
end
Specials.luckyNumberBoxOrder = luckyNumberBoxOrder
local function luckyPrizeFor(shared)
if shared >= 5 then return 1 end
if shared >= 3 then return 2 end
@@ -1508,8 +1567,9 @@ H.CheckForLuckyNumberWinners = function(vm)
end
end
for _, mon in ipairs(party(vm)) do consider(mon, false) end
for _, box in pairs((record and record.boxes) or {}) do
for _, mon in ipairs(box or {}) do consider(mon, true) end
local boxes = (record and record.boxes) or {}
for _, index in ipairs(luckyNumberBoxOrder(record)) do
for _, mon in ipairs(boxes[index] or {}) do consider(mon, true) end
end
answer(vm, best)
if bestMon then
@@ -1586,9 +1646,17 @@ local KURT_MENU_FLAGS = 0x80 + 0x20
-- CANCEL. Both refusals -- FindApricornsInBag's `scf` for a pack with no
-- apricorn in it at all, and the `jr c, .nope` for pressing B -- answer
-- `xor a`, which is the FALSE `.Cancel` waits on.
--
-- ../pokecrystal/engine/events/kurt.asm:19-45 is the same routine with a
-- quantity menu bolted on, and its `xor a / ld [wKurtApricornQuantity], a`
-- (:24) and `ld a, [wItemQuantityChange] / ld [...], a` (:45) are what
-- ../pokecrystal/maps/KurtsHouse.asm:197's `verbosegiveitemvar LEVEL_BALL,
-- VAR_KURT_APRICORNS` counts out. Kurt_SelectQuantity is not ported, so the
-- count written here is the one apricorn Apricorns.takeApricorn tosses.
H.SelectApricornForKurt = function(vm)
local h = hooks(vm)
local record = save(vm)
if h.setKurtApricornQuantity then h.setKurtApricornQuantity(0) end
local list = Apricorns.bagList(record and record.inventory)
if list.empty then return answer(vm, FALSE) end
@@ -1612,7 +1680,10 @@ H.SelectApricornForKurt = function(vm)
local item = h.itemIndex and h.itemIndex(apricorn)
if not item or item == 0 then return answer(vm, FALSE) end
answer(vm, item)
Apricorns.takeApricorn(record, apricorn)
if Apricorns.takeApricorn(record, apricorn)
and h.setKurtApricornQuantity then
h.setKurtApricornQuantity(1)
end
end
-- ---- 88-89 the first party slot -------------------------------------------
@@ -1917,7 +1988,7 @@ local OAK_RATINGS = {
-- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs
-- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and
-- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's
-- `special PrintDiploma` (also stubbed: "printer: no Game Boy Printer").
-- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua).
-- A finished #DEX here just means every rating after this one is this
-- same line.
{ max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source(
@@ -2027,7 +2098,7 @@ Specials.ROAMERS = Roamers.SPECIES
H.InitRoamMons = function(vm)
local record = save(vm)
if not record then return end
Roamers.init(record, { force = true })
Roamers.init(record, { force = true, data = data(vm) })
end
-- ---- the #DEX-completion diploma -------------------------------------------
@@ -2328,7 +2399,7 @@ end
-- layout, PrintPartyMonPage1) and then SendScreenToPrinter walks it out the
-- serial port to a physical Game Boy Printer. There is no peripheral for it
-- to reach here -- the same reason H.UnownPrinter's A press goes nowhere and
-- PrintDiploma stays stubbed below -- so `ldh a, [hPrinter] / and a / jr nz,
-- PrintDiploma's own print goes nowhere -- so `ldh a, [hPrinter] / and a / jr nz,
-- .cancel` is
-- hardwired to the nz arm below: the portrait shows, then the print always
-- comes back as though the printer errored, which is the honest answer for
@@ -2370,11 +2441,137 @@ H.PhotoStudio = function(vm)
vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto))
end
-- ---- 21 the quick save -----------------------------------------------------
--
-- TryQuickSave (engine/link/link.asm:2356) is filed with the cable club but is
-- not a link routine: it is `farcall Link_SaveGame`, TRUE on carry clear and
-- FALSE on carry, then `ld c, 30 / call DelayFrames`. Link_SaveGame
-- (engine/menus/save.asm:63) is AskOverwriteSaveFile (:169) plus the ordinary
-- write, so the FALSE arm is a refusal at the overwrite prompt -- which is
-- what ../pokecrystal/maps/BattleTower1F.asm:84-85 backs a challenge out on,
-- and what the four PokeCenter2F cable rows share.
--
-- Two differences from the cart, neither observable in the answer.
-- AskOverwriteSaveFile's mismatched-ID arm runs ErasePreviousSave (:333) before
-- saving; Save.save replaces the whole file anyway. And SFX_SAVE rings at the
-- write here, where src/ui/gen2/SaveMenu.lua:107 already puts it, rather than
-- after the saved page has typed.
-- data/text/common_2.asm:1279-1299.
local SAVE_TEXT = {
already = Strings.source(
"There is already a\nsave file. Is it\vOK to overwrite?"),
another = Strings.source(
"There is another\nsave file. Is it\vOK to overwrite?"),
saving = Strings.source("SAVING… DON'T TURN\nOFF THE POWER."),
saved = Strings.source("%s saved\nthe game."),
}
-- engine/menus/save.asm:247 the 16 frames under SAVING and :251 the 32 the
-- write is followed by; :269 the 30 after the saved page, and link.asm:2367 the
-- 30 TryQuickSave adds on top. Both pages are `hold`s rather than `wait`s
-- because the world does not tick while a box owns the stack (Vm:showRaw).
local SAVING_HOLD = 16 + 32
local SAVED_HOLD = 30 + 30
H.TryQuickSave = function(vm)
local h = hooks(vm)
-- `ld a, [wSaveFileExists] / and a / jr z, .erase`, then
-- CompareLoadedAndSavedPlayerID (:212) picking which question is asked.
local exists, sameId = false, false
if h.saveFileState then exists, sameId = h.saveFileState() end
if exists then
showRawHeld(vm, Strings(sameId and SAVE_TEXT.already or SAVE_TEXT.another))
if not coroutine.yield({ kind = "yesorno" }) then
return answer(vm, FALSE)
end
end
vm:showRaw(Strings(SAVE_TEXT.saving), true, SAVING_HOLD)
-- _SaveGameData (:273). A veto from the save.write mod hook is the one way
-- this port can refuse a write the cart always completes, and a refusal is
-- the same FALSE the overwrite prompt's NO gives.
if not (h.writeSave and h.writeSave() ~= false) then
return answer(vm, FALSE)
end
if h.playSfxNamed then h.playSfxNamed("Sfx_Save") end
local record = save(vm)
local name = (record and record.player and record.player.name) or ""
vm:showRaw(Strings(SAVE_TEXT.saved, name), true, SAVED_HOLD)
answer(vm, TRUE)
end
-- ---- 111 the dummy --------------------------------------------------------
-- UnusedDummySpecial is a bare `ret`. Listed so the name resolves to a
-- handler rather than to the unimplemented ledger.
H.UnusedDummySpecial = function() end
-- ---- 109-165 the Crystal rows ---------------------------------------------
-- data/events/special_pointers.asm:124-181, the rows only Crystal has.
-- ../pokecrystal/engine/pokemon/search_owned.asm:48 CheckOwnMonAnywhere: party then boxes,
-- matching species, OT id and OT name; `ld a, [wPartyCount] / and a / ret z`.
local function ownsMonAnywhere(vm, wanted)
local h = hooks(vm)
local list = party(vm)
if #list == 0 then return false end
local record = save(vm)
local player = record and record.player
local function owns(mon)
if not mon then return false end
if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end
if player and player.id and mon.otId and mon.otId ~= player.id then
return false
end
if player and player.name and mon.ot and mon.ot ~= player.name then
return false
end
return true
end
for _, mon in ipairs(list) do
if owns(mon) then return true end
end
for _, box in pairs((record and record.boxes) or {}) do
for _, mon in ipairs(box or {}) do
if owns(mon) then return true end
end
end
return false
end
-- ../pokecrystal/engine/pokemon/search_owned.asm:31
H.MonCheck = function(vm)
answer(vm, ownsMonAnywhere(vm, vm.scriptVar) and TRUE or FALSE)
end
-- home/init.asm:1 falls into Init (home/init.asm:35) and on to the copyright
-- splash, which is Game2:softReset rather than Game2:returnToTitle.
H.Reset = function(vm)
local h = hooks(vm)
if h.softReset then h.softReset() end
end
-- ../pokecrystal/mobile/mobile_41.asm:320 is a bare `ret` with its SRAM counter left behind
-- it as dead code, so a no-op is the whole routine.
H.StubbedTrainerRankings_Healings = function() end
-- ../pokecrystal/mobile/mobile_41.asm:792: the international ROM answers 0 outright, which
-- is what sends every Pokecenter 2F mobile branch down its cable arm.
H.CheckMobileAdapterStatusSpecial = function(vm)
answer(vm, FALSE)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:187 and :1580, both bare `ret`.
H.UnusedBattleTowerDummySpecial1 = function() end
H.UnusedBattleTowerDummySpecial2 = function() end
-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown:
-- `call Random / and %11 / add 3`, three to six days into wKenjiBreakTimer,
-- which ../pokecrystal/maps/Route45.asm:50 reads back through VAR_KENJI_BREAK.
H.SampleKenjiBreakCountdown = function(vm)
local h = hooks(vm)
if h.setKenjiBreak then h.setKenjiBreak(Specials.random(4) - 1 + 3) end
end
--------------------------------------------------------------------------
-- The deliberate stubs
--------------------------------------------------------------------------
@@ -2394,7 +2591,6 @@ local STUB_ROWS = {
{ "SetBitsForLinkTradeRequest", nil, "link cable: no Gen 2 cable club" },
{ "WaitForLinkedFriend", 0, "link cable: nobody ever connects" },
{ "CheckLinkTimeout_Receptionist", 1, "link cable: always times out" },
{ "TryQuickSave", 0, "link cable: the cable club's own save is not ported" },
{ "CheckBothSelectedSameRoom", 0, "link cable: no second player" },
{ "FailedLinkToPast", 1, "link cable: the Time Capsule is not ported" },
{ "CloseLink", nil, "link cable: nothing to close" },
@@ -2415,45 +2611,149 @@ local STUB_ROWS = {
{ "CheckMysteryGift", 0, "Mystery Gift: no infrared, so no gift is waiting" },
{ "GetMysteryGiftItem", 0, "Mystery Gift: nothing to hand over" },
{ "UnlockMysteryGift", nil, "Mystery Gift: nothing to unlock" },
-- The Game Boy Printer. A second peripheral again, and two of the three
-- specials that want it are not in this table: PhotoStudio ports the
-- conversation and the portrait screen (H.PhotoStudio above) and
-- UnownPrinter ports the stamp viewer (H.UnownPrinter above), with only the
-- print itself stubbed inside each. PrintDiploma is the one that is
-- nothing BUT the print -- the diploma's own page is `special Diploma`, a
-- separate row -- so it is all that is left here.
-- The Game Boy Printer. A second peripheral again, and none of the three
-- specials that want it is stubbed any more: PhotoStudio ports the
-- conversation and the portrait screen (H.PhotoStudio above), UnownPrinter
-- ports the stamp viewer (H.UnownPrinter above) and PrintDiploma opens
-- PlaceDiplomaOnScreen (specials/crystal_extras.lua), with only the print
-- itself going nowhere inside each. The row below is superseded by that
-- handler and survives only as the reason.
{ "PrintDiploma", nil, "printer: no Game Boy Printer" },
-- Screens the port has not built. Each names what it needs; the value is
-- the arm a cancel takes, so the script backs out rather than proceeding
-- through a transaction that never happened.
-- OverworldTownMap's row is superseded by H.OverworldTownMap
-- (specials/crystal_extras.lua) and survives only as the reason.
-- ../pokecrystal/data/events/special_pointers.asm:58 and pokegold's :63 both
-- carry `add_special UnusedMemoryGame ; unused`, and no script names it.
{ "OverworldTownMap", nil, "needs the POKeGEAR map card in view mode" },
{ "UnusedMemoryGame", nil, "unused in Gold; needs the memory game screen" },
{ "UnusedMemoryGame", nil, "unused on both carts; no script reaches _MemoryGame" },
-- data/events/special_pointers.asm:124-181, the rows only Crystal has.
{ "BattleTowerRoomMenu", 10, "Battle Tower: $a is the menu's back-out arm" },
{ "BattleTowerBattle", nil, "Battle Tower: no tower battle to run" },
{ "BattleTowerAction", 0, "Battle Tower: 0 is sGSBallFlag clear" },
{ "CheckForBattleTowerRules", 0, "Battle Tower: no challenge in progress" },
{ "Menu_ChallengeExplanationCancel", 0, "Battle Tower: 0 ends the talk" },
{ "LoadOpponentTrainerAndPokemonWithOTSprite", 0, "Battle Tower: no roster" },
{ "BattleTowerMobileError", nil, "Battle Tower: no mobile error to report" },
{ "Function1700ba", nil, "Battle Tower: mobile challenge setup" },
{ "Function170114", nil, "Battle Tower: mobile challenge setup" },
{ "Function1704e1", nil, "Battle Tower: mobile challenge setup" },
{ "AskMobileOrCable", 0, "Mobile System GB: 0 is a B press off the menu" },
{ "Mobile_SelectThreeMons", 0, "Mobile System GB: no three mons picked" },
{ "Function1011f1", nil, "Mobile System GB: enters LINK_MOBILE" },
{ "Function101220", nil, "Mobile System GB: leaves LINK_MOBILE" },
{ "Function101225", 0, "Mobile System GB: mobile trade room teardown" },
{ "Function101231", 0, "Mobile System GB: mobile battle room teardown" },
{ "Function102142", nil, "Mobile System GB: mobile news feed" },
{ "Function103780", 0, "Mobile System GB: the mobile save never happens" },
{ "Function1037c2", 0, "Mobile System GB: no rematch on same settings" },
{ "Function1037eb", 0, "Mobile System GB: no battle time is left" },
{ "Function10383c", 0, "Mobile System GB: the three-mon pick cancels" },
{ "Function10387b", nil, "Mobile System GB: adapter status readback" },
{ "TradeCornerHoldMon", nil, "Mobile System GB: no mobile trade corner" },
{ "Function11ac3e", nil, "Mobile System GB: trade corner submenu" },
{ "Function11b5e8", nil, "Mobile System GB: trade corner submenu" },
{ "Function11b7e5", nil, "Mobile System GB: trade corner submenu" },
{ "Function11b879", 0, "Mobile System GB: trade corner submenu" },
{ "Function11b920", nil, "Mobile System GB: trade corner submenu" },
{ "Function11b93b", nil, "Mobile System GB: trade corner submenu" },
{ "Function11ba38", 0, "Mobile System GB: trade corner submenu" },
{ "Function17d2b6", nil, "Mobile System GB: mobile menu chrome" },
{ "Function17d2ce", 0, "Mobile System GB: mobile menu chrome" },
{ "Function11c1ab", nil, "Mobile System GB: the fixed-word entry screen" },
{ "UnusedFindItemInPCOrBag", 0, "Mobile System GB: unreferenced" },
{ "GiveOddEgg", nil, "the Odd Egg roster is not ported; Route 34 is later" },
{ "DisplayUnownWords", nil, "needs the Unown wall word box" },
{ "HoOhChamber", nil, "the Ruins of Alph secret chambers are not ported" },
{ "OmanyteChamber", nil, "the Ruins of Alph secret chambers are not ported" },
{ "PokeSeer", nil, "needs the Seer's caught-data page" },
{ "BeastsCheck", 0, "the three beasts cannot all be owned this early" },
{ "BuenasPassword", 0, "Buena's show is not ported; 0 is a wrong guess" },
{ "BuenaPrize", nil, "Buena's prize counter is not ported" },
{ "AskRememberPassword", 0, "Buena's show is not ported; 0 declines" },
{ "CelebiShrineEvent", nil, "the GS Ball event needs the mobile stadium" },
{ "CheckCaughtCelebi", 0, "the GS Ball event never runs, so Celebi is free" },
{ "GiveDratini", nil, "the Dragon Shrine moveset swap is past Phase 1" },
{ "MoveTutor", 255, "the tutor is past Phase 1; -1 is its cancel arm" },
}
Specials.HANDLERS = H
-- data/events/special_pointers.asm:124-181, the "; Crystal only" block: one
-- module per owner under src/script/gen2/specials/, merged into HANDLERS here.
Specials.MODULES = {
"crystal_story",
"battle_tower",
"crystal_extras",
"unown_words",
}
Specials.HANDLER_SOURCE = {}
for name in pairs(H) do Specials.HANDLER_SOURCE[name] = "Specials.lua" end
Specials.STUBS = {}
Specials.STUB_REASONS = {}
for _, row in ipairs(STUB_ROWS) do
local name, value, reason = row[1], row[2], row[3]
Specials.STUB_REASONS[name] = reason
Specials.STUBS[name] = function(vm)
if value ~= nil then vm.scriptVar = value end
end
end
Specials.SUPERSEDED_STUBS = {}
-- The dispatch table Vm.SPECIALS is. Built rather than written out so the two
-- sets cannot drift, and so a name that ends up in both is a hard error here
-- rather than a silent shadow at runtime.
Specials.ALL = {}
for name, fn in pairs(Specials.HANDLERS) do
Specials.ALL[name] = fn
end
for name, fn in pairs(Specials.STUBS) do
if Specials.ALL[name] then
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
end
Specials.ALL[name] = fn
local function clear(t)
for key in pairs(t) do t[key] = nil end
end
local function rebuild()
clear(Specials.STUBS)
clear(Specials.STUB_REASONS)
clear(Specials.SUPERSEDED_STUBS)
clear(Specials.ALL)
for _, row in ipairs(STUB_ROWS) do
local name, value, reason = row[1], row[2], row[3]
if H[name] then
if Specials.HANDLER_SOURCE[name] == "Specials.lua" then
error("gen2 special '" .. name .. "' is both implemented and stubbed", 0)
end
Specials.SUPERSEDED_STUBS[name] = reason
else
Specials.STUB_REASONS[name] = reason
Specials.STUBS[name] = function(vm)
if value ~= nil then vm.scriptVar = value end
end
end
end
for name, fn in pairs(H) do Specials.ALL[name] = fn end
for name, fn in pairs(Specials.STUBS) do Specials.ALL[name] = fn end
end
function Specials.merge(handlers, source)
if type(handlers) ~= "table" then
error("gen2 specials module '" .. tostring(source) .. "' returned "
.. type(handlers) .. ", expected a table of name -> function", 0)
end
for name, fn in pairs(handlers) do
if type(name) ~= "string" or type(fn) ~= "function" then
error("gen2 specials module '" .. tostring(source)
.. "' entry [" .. tostring(name) .. "] is not name -> function", 0)
end
local owner = Specials.HANDLER_SOURCE[name]
if owner then
error("gen2 special '" .. name .. "' is defined twice: "
.. owner .. " and " .. tostring(source), 0)
end
H[name] = fn
Specials.HANDLER_SOURCE[name] = source
end
rebuild()
return handlers
end
package.loaded["src.script.gen2.Specials"] = Specials
for _, name in ipairs(Specials.MODULES) do
Specials.merge(require("src.script.gen2.specials." .. name),
"specials/" .. name .. ".lua")
end
rebuild()
return Specials
+84 -6
View File
@@ -49,6 +49,14 @@ local MAX_MONEY, MAX_COINS = 999999, 9999
local SFX_ITEM, SFX_HANG_UP = 0x01, 0x6b
-- constants/script_constants.asm: EMOTE_FROM_MEM is -1, i.e. the byte $ff.
local EMOTE_FROM_MEM = 0xff
-- constants/item_constants.asm:300 DEF ITEM_FROM_MEM EQU $ff
local ITEM_FROM_MEM = 0xff
-- pokecrystal/constants/script_constants.asm:254-257 StoreSwarmMapIndices args.
local SWARM_DUNSPARCE = 0
-- pokecrystal/constants/text_constants.asm:13-19 wNamedObjectType values.
local NAMED_MON, NAMED_ITEM, NAMED_TRAINER = 1, 4, 7
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait
local WAIT_FRAMES_PER_UNIT = 6
-- constants/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver.
local GS_VERSION_GOLD = 0
-- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and
@@ -204,7 +212,9 @@ local function runCmd(self, cmd, op)
local value = self.callAsmFn(cmd.label, arg1(cmd) or 0, wordArg(cmd, 2))
if value ~= nil then self.scriptVar = value % 256 end
end
elseif op == "jumptext" then
elseif op == "jumptext" or op == "farjumptext" then
-- pokecrystal/engine/overworld/scripting.asm:318-327 Script_farjumptext:
-- Script_jumptext with a `dba` for the `dw`, same JumpTextScript.
self:emitFace(false)
self:showText(cmd.text)
return "end"
@@ -519,6 +529,36 @@ local function runCmd(self, cmd, op)
-- town map draws them two rows deep).
local name = self.getLandmarkNameFn and self.getLandmarkNameFn()
if name then self:setStringBuffer(name) end
elseif op == "getlandmarkname" then
-- pokecrystal/engine/overworld/scripting.asm:1615-1623: the landmark id
-- comes off the script, then ConvertLandmarkToText and a buffer byte.
local id = cmd.landmark or arg1(cmd) or 0
local name = self.getLandmarkNameFn and self.getLandmarkNameFn(id)
if name then self:setStringBuffer(name) end
elseif op == "gettrainerclassname" then
-- pokecrystal/engine/overworld/scripting.asm:1644-1647: TRAINER_NAME is
-- preset, so the one id byte ContinueToGetName reads is a trainer group.
if self.getTrainerClassNameFn then
local name = self.getTrainerClassNameFn(cmd.class or arg1(cmd) or 0)
if name then self:setStringBuffer(name) end
end
elseif op == "getname" then
-- pokecrystal/engine/overworld/scripting.asm:1633-1641: a
-- wNamedObjectType byte, an id byte, then GetStringBuffer's buffer byte.
local args = cmd.args or {}
local kind = cmd.kind or args[1] or 0
local id = cmd.id or args[2] or 0
local name
if kind == NAMED_MON and self.getMonNameFn then
name = self.getMonNameFn(id)
elseif kind == NAMED_ITEM and self.getItemNameFn then
name = self.getItemNameFn(id)
elseif kind == NAMED_TRAINER and self.getTrainerClassNameFn then
name = self.getTrainerClassNameFn(id)
elseif self.getNameFn then
name = self.getNameFn(kind, id)
end
if name then self:setStringBuffer(name) end
elseif op == "getnum" then
-- Script_getnum: PrintNum of wScriptVar (PRINTNUM_LEFTALIGN | 1 byte,
-- 3 chars) into wStringBuffer1, then GetStringBuffer copies that into
@@ -599,9 +639,17 @@ local function runCmd(self, cmd, op)
else
self.scriptVar = POKEMAIL_REFUSED
end
elseif op == "giveitem" or op == "verbosegiveitem" then
elseif op == "giveitem" or op == "verbosegiveitem"
or op == "verbosegiveitemvar" then
local item = cmd.item or arg1(cmd) or 0
local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1
if op == "verbosegiveitemvar" then
-- pokecrystal/engine/overworld/scripting.asm:486-510: ITEM_FROM_MEM
-- takes the item from wScriptVar, and byte two is a VAR_* id.
if item == ITEM_FROM_MEM then item = (self.scriptVar or 0) % 256 end
local varId = cmd.var or (cmd.args and cmd.args[2]) or 0
qty = self.readVarFn and self.readVarFn(varId) or 0
end
-- Script_giveitem's own `ld [wCurItem], a` (scripting.asm:1612). It is
-- what the standalone `specialsound` inside GiveItemScript reads back:
-- CheckItemPocket runs on wCurItem, not on anything the opcode carries.
@@ -611,7 +659,7 @@ local function runCmd(self, cmd, op)
ok = self.giveItemFn(item, qty) ~= false
end
self.scriptVar = ok and 1 or 0
if op == "verbosegiveitem" then
if op == "verbosegiveitem" or op == "verbosegiveitemvar" then
local name = self.getItemNameFn and self.getItemNameFn(item) or "?"
self:setStringBuffer(name)
-- GiveItemScript (engine/overworld/scripting.asm:441-449), command for
@@ -1048,10 +1096,20 @@ local function runCmd(self, cmd, op)
-- SetSwarmFlag -> DAILYFLAGS1_SWARM. Both halves matter: CheckSwarmFlag
-- is what makes the swarm expire, so a port that only stores the map
-- leaves the Dunsparce call permanently live.
--
-- pokecrystal/macros/scripts/events.asm:1003-1008 adds a leading flag byte
-- and specials.asm:290-298 picks the index pair off it, so three operand
-- bytes means the map_id has moved along one.
local args = cmd.args or {}
local group = cmd.group or args[1]
local mapNum = cmd.map or args[2]
if self.setSwarmFn then self.setSwarmFn(group, mapNum) end
local kind, group, mapNum
if #args >= 3 then
kind, group, mapNum = args[1], args[2], args[3]
else
kind = SWARM_DUNSPARCE
group = cmd.group or args[1]
mapNum = cmd.map or args[2]
end
if self.setSwarmFn then self.setSwarmFn(group, mapNum, kind) end
elseif op == "reloadmapafterbattle" or op == "reloadmap"
or op == "refreshmap" then
-- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult
@@ -1617,6 +1675,21 @@ local function runCmd(self, cmd, op)
coroutine.yield({ kind = "credits" })
end
return "end"
-- ---- Crystal-only verbs ------------------------------------------------
elseif op == "wait" then
-- pokecrystal/engine/overworld/scripting.asm:2336-2347 Script_wait: SIX
-- frames of DelayFrames per operand unit, not Script_pause's two.
self:waitFrames((cmd.frames or arg1(cmd) or 0) * WAIT_FRAMES_PER_UNIT)
elseif op == "checksave" then
-- pokecrystal/engine/overworld/scripting.asm:2349-2353 writes CheckSave's
-- c: 1 when both sCheckValue bytes match (events/checksave.asm:1-20).
local ok = true
if self.checkSaveFn then ok = self.checkSaveFn() and true or false end
self.scriptVar = ok and 1 or 0
elseif op == "battletowertext" then
-- pokecrystal/engine/overworld/scripting.asm:447-452 BattleTowerText.
-- Unported: the table consumes the operand and the verb warns once.
self:noteUnknownOp(op)
-- ---- commands with no engine behind them yet ---------------------------
elseif op == "deactivatefacing" then
-- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the
@@ -1960,6 +2033,11 @@ function Vm.new(scripts, text, events, hooks)
givePokeMailFn = hooks.givePokeMail,
checkPokeMailFn = hooks.checkPokeMail,
getLandmarkNameFn = hooks.getLandmarkName,
-- pokecrystal/engine/overworld/scripting.asm:1633-1647, and 2349-2353.
-- Absent on a Gold boot, where no opcode reaches them.
getTrainerClassNameFn = hooks.getTrainerClassName,
getNameFn = hooks.getName,
checkSaveFn = hooks.checkSave,
-- loadmenu stashes a header for the verticalmenu / _2dmenu that follows;
-- openMenu is the blocking half, modelled on yesorno.
openMenuFn = hooks.openMenu,
+397
View File
@@ -0,0 +1,397 @@
-- The Battle Tower specials: ../pokecrystal/data/events/special_pointers.asm:132-140 and
-- :150 BattleTowerAction, :152 Menu_ChallengeExplanationCancel.
local Specials = require("src.script.gen2.Specials")
local S = Specials.shared
local Bag = require("src.inventory.Bag")
local BattleTower = require("src.core.gen2.BattleTower")
local RomText = require("src.core.RomText")
local Save = require("src.core.gen2.Save")
local Strings = require("src.core.Strings")
local M = {}
local TRUE, FALSE = S.TRUE, S.FALSE
local A = BattleTower.ACTIONS
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:190-196
-- InitBattleTowerChallengeRAM. wNrOfBeatenBattleTowerTrainers is the byte the
-- room script `readmem`s, so it is zeroed in the VM's own memory store.
local function initChallengeRam(vm)
vm.btBattleEnded = 0
vm.btBeaten = 0
if vm.mem then vm.mem[BattleTower.WRAM_NR_BEATEN] = 0 end
end
-- ../pokecrystal/engine/events/battle_tower/rules.asm:57-92, the far labels
-- data/generated/rom_text.lua carries.
local RULE_FALLBACKS = {
_ExcuseMeYoureNotReadyText =
Strings.source("Excuse me.\nYou're not ready."),
_OnlyThreeMonMayBeEnteredText =
Strings.source("Only three POKéMON\nmay be entered."),
_TheMonMustAllBeDifferentKindsText =
Strings.source("The {STRBUF} POKéMON\nmust all be different kinds."),
_TheMonMustNotHoldTheSameItemsText =
Strings.source("The {STRBUF} POKéMON\nmust not hold the same items."),
_YouCantTakeAnEggText = Strings.source("You can't take an\nEGG!"),
_BattleTowerReturnWhenReadyText =
Strings.source("Please return when\nyou're ready."),
}
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
-- BattleTower_CheckSaveFileExistsAndIsYours; the running game IS the loaded
-- save here, so CompareLoadedAndSavedPlayerID can only match.
local function saveFileIsYours(vm)
local record = S.save(vm)
if not (record and record.version) then return FALSE end
return Save.exists(record.version) and TRUE or FALSE
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1024-1127 and
-- :1187-1242, :1314-1447: SRAM bank 5 and WRAM bank 3 belong to the Mobile
-- System GB adapter. A cartridge that never linked reads them as zero, and
-- these are the values that zero produces.
local MOBILE_ARMS = {
[A.ACTION_05] = { value = 0, why = "s5_be46 is 0 until a mobile challenge" },
[A.ACTION_06] = { why = "clears the mobile challenge bytes" },
[A.ACTION_0C] = { why = "stamps the mobile challenge day" },
[A.ACTION_0D] = { value = FALSE, why = "s5_aa47 is 0, so `and a / ret z`" },
[A.ACTION_0F] = { value = 0, why = "w3_d090 is the adapter's status byte" },
[A.ACTION_10] = { value = FALSE, why = "s5_a800 is 0, the .NoAction row" },
[A.ACTION_16] = { why = "stamps the mobile news day" },
[A.ACTION_17] = { value = FALSE, why = "s5_b2f9 is 0, so `and a / ret z`" },
[A.LEVEL_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
[A.UBERS_CHECK] = { value = 0, why = "s5_b2fb is the stadium's max level" },
}
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:951-953
-- BattleTower_SaveOptions writes options.lua, which no `special` hook reaches.
local UNHOOKED_ARMS = {
[A.SAVEOPTIONS] = "needs a saveOptions hook in World:specialHooks",
}
local ACTIONS = {}
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:978-990
ACTIONS[A.CHECK_EXPLANATION_READ] = function(vm, tower, record)
local yours = saveFileIsYours(vm)
S.answer(vm, yours)
if yours == 0 then return end
S.answer(vm, BattleTower.saveFileFlag(record,
BattleTower.SAVEFILE_EXPLANATION))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1001-1008
ACTIONS[A.SET_EXPLANATION_READ] = function(_vm, _tower, record)
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_EXPLANATION)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:992-999
ACTIONS[A.GET_CHALLENGE_STATE] = function(vm, tower)
S.answer(vm, tower.challenge)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1010-1012
ACTIONS[A.SAVE_AND_QUIT] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.SAVED_AND_LEFT)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1014-1022
ACTIONS[A.CHALLENGECANCELED] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.NO_CHALLENGE)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1129-1141
ACTIONS[A.SAVELEVELGROUP] = function(vm, tower)
tower.levelGroup = math.max(0, math.floor(tonumber(vm.btLevelGroup) or 0))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1143-1155
ACTIONS[A.LOADLEVELGROUP] = function(vm, tower)
vm.btLevelGroup = tower.levelGroup
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1157-1171
ACTIONS[A.CHECKSAVEFILEISYOURS] = function(vm)
S.answer(vm, saveFileIsYours(vm))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1173-1177: the
-- pending fade is dropped and the volume goes back to full, which at both
-- call sites follows a `musicfadeout MUSIC_NONE`.
ACTIONS[A.ACTION_0A] = function(vm)
local h = S.hooks(vm)
if h.stopMusic then h.stopMusic() end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1179-1185:
-- sGSBallFlag reads back as GS_BALL_AVAILABLE once the ball has been offered,
-- and the Goldenrod scene gates itself on its own event flag afterwards.
ACTIONS[A.GSBALL] = function(vm, _tower, record)
local crystal = record and Save.crystalState(record)
local flag = crystal and crystal.gsBall
S.answer(vm, Save.GS_BALL_STATES[flag] and BattleTower.GS_BALL_AVAILABLE or 0)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1311-1312
-- String_MysteryJP, the OT the Mobile Stadium stamps on an Odd Egg.
local MYSTERY_OT = "\227\129\170\227\129\158\227\131\138\227\131\142"
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1244-1309: the
-- EGG_TICKET is spent only on a party egg carrying String_MysteryJP.
ACTIONS[A.EGGTICKET] = function(vm, _tower, record)
S.answer(vm, FALSE)
local held = record and record.inventory and record.inventory.EGG_TICKET
if not held or held <= 0 then return end
for _, mon in ipairs(S.party(vm)) do
if mon.isEgg and mon.otName == MYSTERY_OT then
mon.otName = ""
Bag.remove(record, "EGG_TICKET", 1)
return S.answer(vm, TRUE)
end
end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1461
ACTIONS[A.ACTION_11] = function(_vm, tower) tower.reentry = false end
ACTIONS[A.ACTION_12] = function(_vm, tower) tower.reentry = true end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1463-1469
ACTIONS[A.ACTION_13] = function(vm, tower)
S.answer(vm, tower.reentry and TRUE or FALSE)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483
ACTIONS[A.ACTION_14] = function(vm, _tower, record)
local yours = saveFileIsYours(vm)
S.answer(vm, yours)
if yours == 0 then return end
S.answer(vm, BattleTower.saveFileFlag(record,
BattleTower.SAVEFILE_REGISTERED))
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1485-1492
ACTIONS[A.ACTION_15] = function(_vm, _tower, record)
BattleTower.setSaveFileFlag(record, BattleTower.SAVEFILE_REGISTERED)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
ACTIONS[A.RESETDATA] = function(_vm, _tower, record)
BattleTower.resetTrainers(record)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933
ACTIONS[A.GIVEREWARD] = function(vm, tower, record)
local h = S.hooks(vm)
local reward = tower.reward or BattleTower.FALLBACK_REWARD
local data = S.data(vm)
local fits = false
if record then
record.inventory = record.inventory or {}
fits = BattleTower.rewardFits(Bag.slots(record, data, "ITEM"),
Bag.capacity(data, "ITEM"), record.inventory[reward])
end
if not fits then reward = BattleTower.FALLBACK_REWARD end
S.answer(vm, (h.itemIndex and h.itemIndex(reward)) or 0)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:935-949
ACTIONS[A.ACTION_1C] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.WON_CHALLENGE)
end
ACTIONS[A.ACTION_1D] = function(_vm, _tower, record)
BattleTower.setChallengeState(record, BattleTower.RECEIVED_REWARD)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976.
-- Specials.random answers 1..n, and `% mask` turns that into the 0..mask-1
-- byte `maskbits` leaves in a.
ACTIONS[A.CHOOSEREWARD] = function(vm, tower)
local data = S.data(vm)
local order = data and data.gen2Constants and data.gen2Constants.itemOrder
tower.reward = BattleTower.rollReward(order, Specials.random)
or BattleTower.FALLBACK_REWARD
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:852-887, the
-- jumptable `special BattleTowerAction` dispatches on wScriptVar.
M.BattleTowerAction = function(vm)
local id = math.floor(tonumber(vm.scriptVar) or 0)
-- ../pokecrystal/ram/sram.asm:147-172, read as the zeroes a fresh cart holds.
local record = S.save(vm) or {}
local tower = BattleTower.state(record)
local run = ACTIONS[id]
if run then return run(vm, tower, record) end
local mobile = MOBILE_ARMS[id]
if mobile then
if mobile.value ~= nil then S.answer(vm, mobile.value) end
return
end
if UNHOOKED_ARMS[id] then return end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1583-1594: the
-- carry _CheckForBattleTowerRules returns means the party FAILED, and TRUE is
-- what the script's `ifnotequal FALSE` reads as "stop here".
M.CheckForBattleTowerRules = function(vm)
local lines, failed = BattleTower.checkRules(S.party(vm))
local data = S.data(vm)
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2
vm:setStringBuffer(BattleTower.RULE_PARTY_COUNT_TEXT)
for _, label in ipairs(lines) do
vm:showRaw(RomText(data, label, RULE_FALLBACKS[label] or label))
end
S.answer(vm, failed and TRUE or FALSE)
end
-- ../pokecrystal/mobile/mobile_5f.asm:425-468 and its MenuData at :490-495.
-- wScriptVar comes in TRUE for the English rows and leaves holding the row
-- number, or 4 for a B press.
local CHALLENGE_MENU_ROWS = {
Strings.source("Challenge"),
Strings.source("Explanation"),
Strings.source("Cancel"),
}
local CHALLENGE_MENU_CANCEL = 4
-- ../pokecrystal/mobile/mobile_5f.asm:484-488 MenuHeader, `menu_coords 0, 0,
-- 14, 7` with STATICMENU_CURSOR | STATICMENU_WRAP.
local CHALLENGE_MENU_FLAGS = 0x80 + 0x20
M.Menu_ChallengeExplanationCancel = function(vm)
local h = S.hooks(vm)
local choice = Specials.block(vm, function(done)
if not h.scriptMenu then return done(0) end
h.scriptMenu({ items = CHALLENGE_MENU_ROWS, left = 0, top = 0,
right = 14, bottom = 7, dataFlags = CHALLENGE_MENU_FLAGS, cursor = 1 },
done)
end)
choice = math.floor(tonumber(choice) or 0)
if choice < 1 or choice > #CHALLENGE_MENU_ROWS then
return S.answer(vm, CHALLENGE_MENU_CANCEL)
end
S.answer(vm, choice)
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1534-1550, whose
-- first act is `farcall LoadOpponentTrainerAndPokemon`: the trainer row, the
-- three mons and the two SRAM tables that stop either repeating. wScriptVar
-- comes in holding the object the opponent walks in as and is NOT written.
M.LoadOpponentTrainerAndPokemonWithOTSprite = function(vm)
local record = S.save(vm)
if not record then return end
-- wBT_OTTrainer is WRAM bank 3, so the drawn opponent rides the VM and not
-- the save; only the sBTTrainers slot and the two previous teams persist.
local opponent = BattleTower.drawOpponent(
S.data(vm), record, Specials.random)
vm.btOpponent = opponent
if not opponent then return end
-- :1552-1575: BTTrainerClassSprites[class - 1] goes into the map object
-- wScriptVar names, and GetUsedSprite loads the sheet.
local h = S.hooks(vm)
if h.setObjectSprite and opponent.sprite then
h.setObjectSprite(math.floor(tonumber(vm.scriptVar) or 0), opponent.sprite)
end
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-185 and the
-- RunBattleTowerTrainer arm its jumptable runs at :214-259.
--
-- The order there is: SET battle mode forced on, wInBattleTowerBattle set,
-- HealParty, ReadBTTrainerParty (which is what steps the streak counter and
-- arms the challenge), StartBattle, HealParty again, and wBattleResult into
-- wScriptVar. A win then copies sNrOfBeatenBattleTowerTrainers into
-- wNrOfBeatenBattleTowerTrainers -- the byte the room script `readmem`s to
-- decide whether all seven are done -- and leaves `count + 1` as the digit
-- Text_NextUpOpponentNo prints.
M.BattleTowerBattle = function(vm)
vm.btBattleEnded = 0
local record = S.save(vm)
local data = S.data(vm)
local h = S.hooks(vm)
local opponent = vm.btOpponent
if not opponent and record then
opponent = BattleTower.drawOpponent(data, record, Specials.random)
vm.btOpponent = opponent
end
-- A cache with no `battleTower` block on trainers.lua has nobody to send
-- out. LOSE is the room script's own back-out arm
-- (../pokecrystal/maps/BattleTowerBattleRoom.asm:34-37), so the challenge
-- ends rather than looping on an opponent that never appears.
if not (opponent and record) then
vm.btBattleEnded = 1
return S.answer(vm, 1)
end
-- :229 ReadBTTrainerParty -> CopyBTTrainer_FromBT_OT_TowBT_OTTemp (:549-570)
local streak = BattleTower.beginBattle(record)
-- :228 farcall HealParty, before the send-out.
if h.healParty then h.healParty() end
local classes = data and data.trainers and data.trainers.classes
local class = classes and classes[opponent.classId]
local className = (class and class.name) or opponent.classId
local party = BattleTower.battleParty(data, opponent.rows)
local outcome = Specials.block(vm, function(done)
if not h.startTowerBattle then return done("lose") end
local started = h.startTowerBattle({
class = opponent.class,
classId = opponent.classId,
className = className,
-- PlaceEnemysName prints the class then the trainer's own name, the
-- same pair World:startScriptedBattle builds for an overworld trainer.
name = className and (className .. " " .. opponent.name) or opponent.name,
trainerName = opponent.name,
party = party,
-- wOtherTrainerClass is a real class here, so the AI weights, the two
-- item slots and the payout all come off its own attributes row.
attributes = class and class.attributes,
baseMoney = class and class.baseMoney,
items = class and class.items,
}, done)
if not started then done("lose") end
end)
-- :235 farcall HealParty, on the way out whichever way it went.
if h.healParty then h.healParty() end
-- :236-237 wBattleResult: WIN is 0.
local won = outcome ~= "lose"
S.answer(vm, won and 0 or 1)
if won and vm.mem then
-- :240-250
vm.mem[BattleTower.WRAM_NR_BEATEN] = streak % 256
vm:setStringBuffer(tostring(streak + 1))
end
-- :257-258 wBattleTowerBattleEnded = TRUE, which ends _BattleTowerBattle.
vm.btBattleEnded = 1
vm.btOpponent = nil
end
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1-5, which is
-- InitBattleTowerChallengeRAM plus _BattleTowerRoomMenu
-- (../pokecrystal/mobile/mobile_46.asm:137-177). wScriptVar leaves 0 for a
-- chosen room and $a for the cancel the desk loops back on.
M.BattleTowerRoomMenu = function(vm)
initChallengeRam(vm)
local h = S.hooks(vm)
local record = S.save(vm)
local result = Specials.block(vm, function(done)
if not h.pushScreen then return done(nil) end
local ok = h.pushScreen("Gen2BattleTowerMenu", {
save = record,
party = S.party(vm),
rows = BattleTower.levelGroupRows(record),
monName = h.monName,
onDone = done,
})
if not ok then done(nil) end
end)
local group = math.floor(tonumber(result) or 0)
if group < 1 or group > BattleTower.MAX_LEVEL_GROUP then
-- ../pokecrystal/mobile/mobile_46.asm:4609-4610
return S.answer(vm, 0x0a)
end
vm.btLevelGroup = group
S.answer(vm, 0)
end
return M
+508
View File
@@ -0,0 +1,508 @@
-- ../pokecrystal/data/events/special_pointers.asm:147 MoveTutor, :161 PokeSeer,
-- :162 BuenasPassword, :163 BuenaPrize, :179 AskRememberPassword,
-- :181 UnusedFindItemInPCOrBag, plus the two rows both carts share:
-- :57 OverworldTownMap and :126 PrintDiploma.
local Bag = require("src.inventory.Bag")
local BugContest = require("src.core.gen2.BugContest")
local Mon = require("src.battle.gen2.Mon")
local Nests = require("src.core.gen2.Nests")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
local Strings = require("src.core.Strings")
local S = Specials.shared
local M = {}
-- engine/overworld/variables.asm:65 wBlueCardBalance, :66 wBuenasPassword.
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
-- maps/RadioTower2F.asm:1 BLUE_CARD_POINT_CAP.
local BLUE_CARD_POINT_CAP = 30
local function hooks(vm)
return (vm and vm.specials) or {}
end
--------------------------------------------------------------------------
-- MoveTutor -- ../pokecrystal/engine/events/move_tutor.asm:1
--------------------------------------------------------------------------
-- .GetMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:36) maps
-- MOVETUTOR_FLAMETHROWER..ICE_BEAM onto MT01..MT03, i.e. pokemon.tutorMoves.
local function tutorMove(vm, index)
local d = S.data(vm)
local list = d and d.pokemon and d.pokemon.tutorMoves
if type(list) ~= "table" then return nil end
if index ~= 1 and index ~= 2 then index = 3 end
return list[index]
end
M.MoveTutor = function(vm)
local h = hooks(vm)
local moveId = tutorMove(vm, vm.scriptVar or 0)
-- ../pokecrystal/engine/events/move_tutor.asm:29 .cancel, which is
-- maps/GoldenrodCity.asm:72 .Incompatible.
if not (moveId and h.pushScreen) then
vm.scriptVar = 255
return
end
local d = S.data(vm)
local moveDef = d and d.moves and d.moves[moveId]
local learned = Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2MoveTutor", {
move = moveId,
moveName = (moveDef and moveDef.name) or moveId,
onDone = function(taught) done(taught and true or false) end,
})
if not ok then done(false) end
end)
-- ../pokecrystal/engine/events/move_tutor.asm:25 `xor a ; FALSE` on the learn arm.
vm.scriptVar = learned and 0 or 255
end
--------------------------------------------------------------------------
-- Buena -- ../pokecrystal/engine/events/buena.asm:1, ../pokecrystal/engine/events/buena_menu.asm:1
--------------------------------------------------------------------------
-- data/radio/buenas_passwords.asm, in table order. `kind` is the BUENA_*
-- function of ../pokecrystal/constants/radio_constants.asm:128-131; `points` is both the
-- Blue Card value and the menu width ../pokecrystal/engine/events/buena.asm:9 adds.
local BUENA_PASSWORDS = {
{ kind = "mon", points = 10,
words = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" } },
{ kind = "item", points = 12,
words = { "FRESH_WATER", "SODA_POP", "LEMONADE" } },
{ kind = "item", points = 12,
words = { "POTION", "ANTIDOTE", "PARLYZ_HEAL" } },
{ kind = "item", points = 12,
words = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" } },
{ kind = "mon", points = 10,
words = { "PIKACHU", "RATTATA", "GEODUDE" } },
{ kind = "mon", points = 10,
words = { "HOOTHOOT", "SPINARAK", "DROWZEE" } },
{ kind = "string", points = 16,
words = { Strings.source("NEW BARK TOWN"), Strings.source("CHERRYGROVE CITY"),
Strings.source("AZALEA TOWN") } },
{ kind = "string", points = 6,
words = { Strings.source("FLYING"), Strings.source("BUG"),
Strings.source("GRASS") } },
{ kind = "move", points = 12,
words = { "TACKLE", "GROWL", "MUD_SLAP" } },
{ kind = "item", points = 12,
words = { "X_ATTACK", "X_DEFEND", "X_SPEED" } },
{ kind = "string", points = 13,
words = { Strings.source("#MON Talk"), Strings.source("#MON Music"),
Strings.source("Lucky Channel") } },
}
-- ../pokecrystal/constants/radio_constants.asm:120-121.
local NUM_PASSWORD_CATEGORIES = #BUENA_PASSWORDS
local NUM_PASSWORDS_PER_CATEGORY = 3
-- GetBuenasPassword.StringFunctionJumptable (../pokecrystal/engine/pokegear/radio.asm:1534).
local function passwordWord(vm, group, index)
local row = BUENA_PASSWORDS[group + 1]
if not row then return "?" end
local word = row.words[index + 1]
if not word then return "?" end
if row.kind == "string" then return Strings(word) end
local d = S.data(vm)
if row.kind == "mon" then
local def = d and d.pokemon and d.pokemon[word]
return (def and def.name) or word
end
if row.kind == "item" then
local def = d and d.items and d.items[word]
return (def and def.name) or word
end
local def = d and d.moves and d.moves[word]
return (def and def.name) or word
end
-- BuenasPassword4's two rejection rolls, packed group-high over word-low
-- (../pokecrystal/engine/pokegear/radio.asm:1470-1487).
local function rollPassword(save, day)
local buena = Save.crystalState(save).buenaPassword
buena.word = (Specials.random(NUM_PASSWORD_CATEGORIES) - 1) * 16
+ (Specials.random(NUM_PASSWORDS_PER_CATEGORY) - 1)
buena.day = day
return buena.word
end
-- DAILYFLAGS2_BUENAS_PASSWORD_F is what makes the roll once a day
-- (../pokecrystal/engine/pokegear/radio.asm:1467, :1489); the day stamp stands in for it.
local function currentPassword(vm)
local record = S.save(vm)
if not record then return 0 end
local buena = Save.crystalState(record).buenaPassword
local today = BugContest.now().day
if buena.word == nil or buena.day ~= today then rollPassword(record, today) end
if vm.writeVarFn then vm.writeVarFn(VAR_BUENASPASSWORD, buena.word % 256) end
return buena.word % 256
end
-- ../pokecrystal/engine/events/buena_menu.asm:1-9: carry (NO or B) is 0 and a YES is 1. The
-- question is the script's own writetext at maps/RadioTower2F.asm:119.
M.AskRememberPassword = function(vm)
local yes = coroutine.yield({ kind = "yesorno" })
S.answer(vm, yes and 1 or 0)
end
-- ../pokecrystal/engine/events/buena.asm:19-23 `ld a, [wBuenasPassword] / maskbits 3 / cp c`,
-- and :44-49 .PasswordIndices, which makes the menu's answer zero based.
M.BuenasPassword = function(vm)
local h = hooks(vm)
local packed = currentPassword(vm)
local group = math.floor(packed / 16) % 16
if group >= NUM_PASSWORD_CATEGORIES then group = 0 end
local answer = packed % 4
local words = {}
for row = 0, NUM_PASSWORDS_PER_CATEGORY - 1 do
words[row + 1] = passwordWord(vm, group, row)
end
if not h.pushScreen then
S.answer(vm, 0)
return
end
local picked = Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2BuenaPassword", {
mode = "password",
words = words,
width = BUENA_PASSWORDS[group + 1].points,
onDone = function(index) done(index or -1) end,
})
if not ok then done(-1) end
end)
S.answer(vm, (picked == answer) and 1 or 0)
end
-- data/items/buena_prizes.asm.
local BUENA_PRIZES = {
{ item = "ULTRA_BALL", cost = 2 },
{ item = "FULL_RESTORE", cost = 2 },
{ item = "NUGGET", cost = 3 },
{ item = "RARE_CANDY", cost = 3 },
{ item = "PROTEIN", cost = 5 },
{ item = "IRON", cost = 5 },
{ item = "CARBOS", cost = 5 },
{ item = "CALCIUM", cost = 5 },
{ item = "HP_UP", cost = 5 },
}
-- ../pokecrystal/data/text/common_3.asm:1082-1116, the six pages BuenaPrintText cycles.
local PRIZE_TEXT = {
which = Strings.source("Which prize would\nyou like?"),
confirm = Strings.source("{STRBUF}?\nIs that right?"),
hereYouGo = Strings.source("Here you go!"),
notEnough = Strings.source("You don't have\nenough points."),
noRoom = Strings.source("You have no room\nfor it."),
comeAgain = Strings.source("Oh. Please come\nback again!"),
}
-- wBlueCardBalance is a RETVAR_ADDR_DE var, so the point the script awards at
-- maps/RadioTower2F.asm:144-146 and this counter read one store.
local function blueCardBalance(vm)
if not vm.readVarFn then return 0 end
local value = math.floor(tonumber(vm.readVarFn(VAR_BLUECARDBALANCE)) or 0)
if value < 0 then value = 0 end
return math.min(value, BLUE_CARD_POINT_CAP)
end
local function setBlueCardBalance(vm, value)
if vm.writeVarFn then
vm.writeVarFn(VAR_BLUECARDBALANCE, math.max(0, value) % 256)
end
end
-- ReceiveItem into wNumItems (../pokecrystal/engine/events/buena.asm:104-110).
local function receiveItem(vm, itemId)
local record = S.save(vm)
if not record then return false end
record.inventory = record.inventory or {}
return Bag.add(record, itemId, 1, S.data(vm)) and true or false
end
M.BuenaPrize = function(vm)
local h = hooks(vm)
if not h.pushScreen then return end
local d = S.data(vm)
local items = d and d.items
local rows = {}
for i, prize in ipairs(BUENA_PRIZES) do
local def = items and items[prize.item]
rows[i] = {
item = prize.item,
cost = prize.cost,
name = (def and def.name) or prize.item,
}
end
while true do
-- ../pokecrystal/engine/events/buena.asm:71-83: the page is printed and the menu opens
-- over it, so the box is held the way a `yesorno` page is held.
S.showRawHeld(vm, Strings(PRIZE_TEXT.which))
local pick = Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2BuenaPassword", {
mode = "prize",
prizes = rows,
balance = blueCardBalance(vm),
onDone = function(index) done(index or 0) end,
})
if not ok then done(0) end
end)
-- ../pokecrystal/engine/events/buena.asm:84 `jr z, .done`: 0 is the B press.
if not (type(pick) == "number" and rows[pick]) then break end
local row = rows[pick]
vm:setStringBuffer(row.name)
S.showRawHeld(vm, Strings(PRIZE_TEXT.confirm))
local sure = coroutine.yield({ kind = "yesorno" })
if sure then
-- ../pokecrystal/engine/events/buena.asm:95-119: the cost is checked, then ReceiveItem,
-- and only a delivered item spends the points.
local balance = blueCardBalance(vm)
if balance < row.cost then
vm:showRaw(Strings(PRIZE_TEXT.notEnough))
else
if not receiveItem(vm, row.item) then
vm:showRaw(Strings(PRIZE_TEXT.noRoom))
else
setBlueCardBalance(vm, balance - row.cost)
if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction") end
vm:showRaw(Strings(PRIZE_TEXT.hereYouGo))
end
end
end
end
-- ../pokecrystal/engine/events/buena.asm:138-145 .done.
vm:showRaw(Strings(PRIZE_TEXT.comeAgain))
end
--------------------------------------------------------------------------
-- PokeSeer -- ../pokecrystal/engine/events/poke_seer.asm:18
--------------------------------------------------------------------------
-- data/text/common_3.asm:281-445, SeerTexts (../pokecrystal/engine/events/poke_seer.asm:289)
-- in jumptable order, with each text_ram buffer spelled as a directive.
local SEER_TEXT = {
intro = Strings.source("I see all.\nI know all…\fCertainly, I know\nof your #MON!"),
cantTell = Strings.source(
"Whaaaat? I can't\ntell a thing!\fHow could I not\nknow of this?"),
nameLocation = Strings.source("Hm… I see you met\n%s here:\v%s!"),
timeLevel = Strings.source(
"The time was\n%s!\fIts level was %s!\fAm I good or what?"),
trade = Strings.source(
"Hm… %s\ncame from %s\vin a trade?\f%s\nwas where %s\vmet %s!"),
noLocation = Strings.source(
"What!? Incredible!\fI don't understand\nhow, but it is\fincredible!\n"
.. "You are special.\fI can't tell where\nyou met it, but it\v"
.. "was at level %s.\fAm I good or what?"),
egg = Strings.source("Hey!\fThat's an EGG!\fYou can't say that\nyou've met it yet…"),
doNothing = Strings.source("Fufufu! I saw that\nyou'd do nothing!"),
}
-- SeerAdviceTexts (../pokecrystal/engine/events/poke_seer.asm:357), `dbw level, text`. Field
-- three marks the rows that splice the nickname in.
local SEER_ADVICE = {
{ 9, Strings.source(
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
{ 29, Strings.source(
"Incidentally…\fIt seems to have\ngrown a little.\f%s seems\nto be becoming\v"
.. "more confident."), true },
{ 59, Strings.source(
"Incidentally…\f%s has\ngrown. It's gained\vmuch strength."), true },
{ 89, Strings.source(
"Incidentally…\fIt certainly has\ngrown mighty!\fThis %s\nmust have come\f"
.. "through numerous\n#MON battles.\fIt looks brimming\nwith confidence."), true },
{ 100, Strings.source(
"Incidentally…\fI'm impressed by\nyour dedication.\fIt's been a long\n"
.. "time since I've\fseen a #MON as\nmighty as this\v%s.\fI'm sure that\n"
.. "seeing %s\fin battle would\nexcite anyone."), true },
{ 255, Strings.source(
"Incidentally…\fIt would be wise\nto raise your\f#MON with a\nlittle more care.") },
}
-- GetCaughtTime's .times (../pokecrystal/engine/events/poke_seer.asm:203) and
-- UnknownCaughtData's "Unknown@" (:215).
local SEER_TIMES = {
Strings.source("Morning"), Strings.source("Day"), Strings.source("Night"),
}
local SEER_UNKNOWN = Strings.source("Unknown")
local SEER_NO_LEVEL = Strings.source("???")
-- constants/pokemon_data_constants.asm:130 CAUGHT_EGG_LEVEL and
-- constants/battle_constants.asm:4 EGG_LEVEL.
local CAUGHT_EGG_LEVEL, EGG_LEVEL = 1, 5
-- GetCaughtLevel (../pokecrystal/engine/events/poke_seer.asm:148-179).
local function caughtLevel(byte0)
local level = byte0 % 0x40
if level == 0 then return nil, Strings(SEER_NO_LEVEL) end
if level == CAUGHT_EGG_LEVEL then level = EGG_LEVEL end
return level, tostring(level)
end
-- GetCaughtLocation (../pokecrystal/engine/events/poke_seer.asm:217-249); the second answer
-- is the SEERACTION_* its two sentinel arms override wSeerAction with.
local function caughtLocation(vm, byte1)
local landmark = byte1 % 0x80
if landmark == 0 then return Strings(SEER_UNKNOWN), nil end
if landmark == Mon.LANDMARK_EVENT then return nil, "level_only" end
if landmark == Mon.LANDMARK_GIFT then return nil, "cant_tell" end
local record = Nests.landmark(S.data(vm), landmark)
local name = record and record.name
if not name then return Strings(SEER_UNKNOWN), nil end
-- engine/overworld/landmarks.asm:16 GetLandmarkName copies the break byte
-- through; engine/pokegear/townmap_convertlinebreakcharacters.asm:1 is it.
return (tostring(name):gsub("\n", " ")), nil
end
-- SeerAdvice (../pokecrystal/engine/events/poke_seer.asm:331-355); `sub c` is one byte.
local function seerAdvice(vm, mon, level)
local diff = ((mon.level or 0) - (level or 0)) % 256
local name = Mon.displayName(mon)
for _, row in ipairs(SEER_ADVICE) do
if diff <= row[1] then
if row[3] then return vm:showRaw(Strings(row[2], name, name)) end
return vm:showRaw(Strings(row[2]))
end
end
end
M.PokeSeer = function(vm)
vm:showRaw(Strings(SEER_TEXT.intro))
local _, mon = S.selectMon(vm, "choose")
-- ../pokecrystal/engine/events/poke_seer.asm:38 .cancel.
if not mon then
vm:showRaw(Strings(SEER_TEXT.doNothing))
return
end
-- :28-29 `cp EGG / jr z, .egg`, plus the IsAPokemon test at :31.
if mon.isEgg then
vm:showRaw(Strings(SEER_TEXT.egg))
return
end
local byte0, byte1 = Mon.packCaughtData(mon)
-- ReadCaughtData's `.error` (../pokecrystal/engine/events/poke_seer.asm:104-105, :133).
if byte0 == 0 and byte1 == 0 then
vm:showRaw(Strings(SEER_TEXT.cantTell))
return
end
-- ../pokecrystal/engine/events/poke_seer.asm:110-119: the `cp [hl]` on the OT id's second
-- byte is commented out, so only the HIGH byte decides "traded".
local record = S.save(vm)
local playerId = (record and record.player and record.player.id) or 0
local traded = math.floor((mon.otId or 0) / 256) % 256
~= math.floor(playerId / 256) % 256
local level, levelText = caughtLevel(byte0)
local place, override = caughtLocation(vm, byte1)
local name = Mon.displayName(mon)
-- SeerAction2 / SeerAction3 (../pokecrystal/engine/events/poke_seer.asm:81-89).
if override == "cant_tell" then
vm:showRaw(Strings(SEER_TEXT.cantTell))
return
end
-- SeerAction4 (../pokecrystal/engine/events/poke_seer.asm:91-95).
if override == "level_only" then
vm:showRaw(Strings(SEER_TEXT.noLocation, levelText))
return seerAdvice(vm, mon, level)
end
local time = math.floor(byte0 / 0x40)
local timeText = (time > 0 and Strings(SEER_TIMES[time])) or Strings(SEER_UNKNOWN)
if traded then
-- SeerAction1 (../pokecrystal/engine/events/poke_seer.asm:72-79).
local ot = mon.otName or mon.ot or Strings(SEER_UNKNOWN)
vm:showRaw(Strings(SEER_TEXT.trade, name, ot, place, ot, name))
else
-- SeerAction0 (../pokecrystal/engine/events/poke_seer.asm:64-70).
vm:showRaw(Strings(SEER_TEXT.nameLocation, name, place))
end
vm:showRaw(Strings(SEER_TEXT.timeLevel, timeText, levelText))
seerAdvice(vm, mon, level)
end
--------------------------------------------------------------------------
-- UnusedFindItemInPCOrBag -- ../pokecrystal/mobile/mobile_12_2.asm:191
--------------------------------------------------------------------------
-- CheckItem against wNumPCItems, then wNumItems (../pokecrystal/mobile/mobile_12_2.asm:194,
-- :201); either hit is TRUE.
M.UnusedFindItemInPCOrBag = function(vm)
local h = hooks(vm)
local index = vm.scriptVar or 0
local record = S.save(vm)
local d = S.data(vm)
local id
for key, def in pairs((d and d.items) or {}) do
if type(def) == "table" and def.index == index then id = key break end
end
local pc = record and record.pcItems
if id and type(pc) == "table" and (tonumber(pc[id]) or 0) > 0 then
S.answer(vm, 1)
return
end
if h.hasItem and h.hasItem(index) then
S.answer(vm, 1)
return
end
S.answer(vm, 0)
end
--------------------------------------------------------------------------
-- The wall map -- engine/events/specials.asm:100
--------------------------------------------------------------------------
-- OverworldTownMap is `FadeToMenu / farcall _TownMap / ExitAllMenus`, so it
-- never writes wScriptVar; both callers (engine/events/std_scripts.asm:145
-- TownMapScript and engine/overworld/decorations.asm:1005
-- DecorationDesc_TownMapPoster) follow it with a bare `closetext`.
--
-- _TownMap (engine/pokegear/pokegear.asm:1709) draws the SAME region map the
-- POKeGEAR's MAP card draws -- it calls Pokegear_LoadGFX and PokegearMap for
-- it -- with no card strip, no ENGINE_MAP_CARD gate and no A press, which is
-- src/ui/gen2/Pokegear.lua's `townMap` mode.
M.OverworldTownMap = function(vm)
local h = S.hooks(vm)
if not h.pushScreen then return end
Specials.block(vm, function(done)
local ok = h.pushScreen("Gen2Pokegear", {
townMap = true,
onClose = function() done(true) end,
})
if not ok then done(false) end
end)
end
--------------------------------------------------------------------------
-- The printed diploma -- engine/events/specials.asm:448
--------------------------------------------------------------------------
-- _PrintDiploma (engine/printer/printer.asm:382) opens with the very page
-- `special Diploma` shows -- `farcall PlaceDiplomaOnScreen`,
-- engine/events/diploma.asm:12 -- and only then reaches for the serial port.
-- The second sheet is built with hBGMapMode zeroed and SafeLoadTempTilemapToTilemap
-- puts page 1 straight back, so PrintDiplomaPage2 never reaches the screen on
-- the cartridge either: page 1 IS the whole visible routine.
--
-- The two SendScreenToPrinter passes are the same missing peripheral
-- H.PhotoStudio and H.UnownPrinter degrade around, and .CancelPrinting
-- (maps/CeladonMansion3F.asm:60) is unreferenced, so the cart has no text for
-- a failed print and neither does this.
M.PrintDiploma = function(vm)
local h = S.hooks(vm)
if not h.showDiploma then return end
Specials.block(vm, function(done)
h.showDiploma(function() done(true) end)
end)
end
return M
+298
View File
@@ -0,0 +1,298 @@
-- ../pokecrystal/data/events/special_pointers.asm:141 GiveOddEgg, :148
-- OmanyteChamber, :157 HoOhChamber, :159 CelebiShrineEvent, :160
-- CheckCaughtCelebi, :164 GiveDratini, :166 BeastsCheck.
local Specials = require("src.script.gen2.Specials")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
local S = Specials.shared
local M = {}
-- ../pokecrystal/constants/script_constants.asm:51 VAR_BATTLETYPE.
local VAR_BATTLETYPE = 0x03
-- ../pokecrystal/constants/battle_constants.asm:102 BATTLETYPE_CELEBI.
local BATTLETYPE_CELEBI = 11
-- ../pokecrystal/engine/pokemon/search_owned.asm:6, :11, :16 -- the order the
-- routine writes into wScriptVar before each CheckOwnMonAnywhere.
local BEASTS = { "RAIKOU", "ENTEI", "SUICUNE" }
-- ../pokecrystal/engine/pokemon/search_owned.asm:1; MonCheck is
-- CheckOwnMonAnywhere (:48) with the answer already written.
M.BeastsCheck = function(vm)
local monCheck = Specials.HANDLERS.MonCheck
local h = S.hooks(vm)
for _, name in ipairs(BEASTS) do
vm.scriptVar = (h.monIndex and h.monIndex(name)) or name
monCheck(vm)
if vm.scriptVar ~= S.TRUE then
S.answer(vm, S.FALSE)
return
end
end
S.answer(vm, S.TRUE)
end
-- ../pokecrystal/engine/events/dratini.asm:72 .Moveset0, :79 .Moveset1.
local DRATINI = "DRATINI"
local DRATINI_MOVESETS = {
[0] = { "WRAP", "THUNDER_WAVE", "TWISTER", "EXTREMESPEED" },
[1] = { "WRAP", "LEER", "THUNDER_WAVE", "TWISTER" },
}
-- ../pokecrystal/engine/events/dratini.asm:1: `cp $2 / ret nc`, then :16
-- .CheckForDratini walks the party BACKWARDS from the last slot.
M.GiveDratini = function(vm)
local set = DRATINI_MOVESETS[vm.scriptVar or 0]
if not set then return end
local list = S.party(vm)
local target
for index = #list, 1, -1 do
local mon = list[index]
if mon and mon.species == DRATINI then
target = mon
break
end
end
if not target then return end
-- ../pokecrystal/engine/events/dratini.asm:54: each new move's PP comes from
-- Moves + MOVE_PP, so a PP Up on the replaced slot is dropped.
local defs = S.data(vm)
local moves = defs and defs.moves
target.moves = target.moves or {}
for index, id in ipairs(set) do
local pp = (moves and moves[id] and moves[id].pp) or 0
target.moves[index] = { id = id, pp = pp, maxPp = pp }
end
end
-- ../pokecrystal/data/events/odd_eggs.asm:14-33, the `odd_egg_prob` arguments in
-- order; the macro (:5) accumulates them and stores total * $ffff / 100.
local ODD_EGG_PERCENTS = { 8, 1, 16, 3, 16, 3, 14, 2, 10, 2, 12, 2, 10, 1 }
local ODD_EGG_PROBABILITIES = {}
do
local total = 0
for index, percent in ipairs(ODD_EGG_PERCENTS) do
total = total + percent
ODD_EGG_PROBABILITIES[index] = math.floor(total * 0xffff / 100)
end
end
-- ../pokecrystal/data/events/odd_eggs.asm:37 OddEggs, one row per
-- NICKNAMED_MON_STRUCT.
local ODD_EGGS = {
{ species = "PICHU", otId = 2048, experience = 125, level = 5, eggSteps = 20,
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
pp = { 30, 20, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 17, attack = 9, defense = 6, speed = 11,
specialAttack = 8, specialDefense = 8 } },
{ species = "PICHU", otId = 256, experience = 125, level = 5, eggSteps = 20,
moves = { "THUNDERSHOCK", "CHARM", "DIZZY_PUNCH" },
pp = { 30, 20, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 17, attack = 9, defense = 7, speed = 12,
specialAttack = 9, specialDefense = 9 } },
{ species = "CLEFFA", otId = 4096, experience = 125, level = 5, eggSteps = 20,
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
pp = { 35, 20, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 20, attack = 7, defense = 7, speed = 6,
specialAttack = 9, specialDefense = 10 } },
{ species = "CLEFFA", otId = 768, experience = 125, level = 5, eggSteps = 20,
moves = { "POUND", "CHARM", "DIZZY_PUNCH" },
pp = { 35, 20, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 20, attack = 7, defense = 8, speed = 7,
specialAttack = 10, specialDefense = 11 } },
{ species = "IGGLYBUFF", otId = 4096, experience = 125, level = 5,
eggSteps = 20,
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
pp = { 15, 20, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 24, attack = 8, defense = 6, speed = 6,
specialAttack = 9, specialDefense = 7 } },
{ species = "IGGLYBUFF", otId = 768, experience = 125, level = 5,
eggSteps = 20,
moves = { "SING", "CHARM", "DIZZY_PUNCH" },
pp = { 15, 20, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 24, attack = 8, defense = 7, speed = 7,
specialAttack = 10, specialDefense = 8 } },
{ species = "SMOOCHUM", otId = 3584, experience = 125, level = 5,
eggSteps = 20,
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
pp = { 35, 30, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 19, attack = 8, defense = 6, speed = 11,
specialAttack = 13, specialDefense = 11 } },
{ species = "SMOOCHUM", otId = 512, experience = 125, level = 5,
eggSteps = 20,
moves = { "POUND", "LICK", "DIZZY_PUNCH" },
pp = { 35, 30, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 19, attack = 8, defense = 7, speed = 12,
specialAttack = 14, specialDefense = 12 } },
{ species = "MAGBY", otId = 2560, experience = 125, level = 5, eggSteps = 20,
moves = { "EMBER", "DIZZY_PUNCH" },
pp = { 25, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 19, attack = 12, defense = 8, speed = 13,
specialAttack = 12, specialDefense = 10 } },
{ species = "MAGBY", otId = 512, experience = 125, level = 5, eggSteps = 20,
moves = { "EMBER", "DIZZY_PUNCH" },
pp = { 25, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 19, attack = 12, defense = 9, speed = 14,
specialAttack = 13, specialDefense = 11 } },
{ species = "ELEKID", otId = 3072, experience = 125, level = 5, eggSteps = 20,
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
pp = { 30, 30, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 19, attack = 11, defense = 8, speed = 14,
specialAttack = 11, specialDefense = 10 } },
{ species = "ELEKID", otId = 512, experience = 125, level = 5, eggSteps = 20,
moves = { "QUICK_ATTACK", "LEER", "DIZZY_PUNCH" },
pp = { 30, 30, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 19, attack = 11, defense = 9, speed = 15,
specialAttack = 12, specialDefense = 11 } },
{ species = "TYROGUE", otId = 2560, experience = 125, level = 5,
eggSteps = 20,
moves = { "TACKLE", "DIZZY_PUNCH" },
pp = { 35, 10 },
dvs = { attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 18, attack = 8, defense = 8, speed = 8,
specialAttack = 8, specialDefense = 8 } },
{ species = "TYROGUE", otId = 256, experience = 125, level = 5, eggSteps = 20,
moves = { "TACKLE", "DIZZY_PUNCH" },
pp = { 35, 10 },
dvs = { attack = 2, defense = 10, speed = 10, special = 10 },
stats = { hp = 18, attack = 8, defense = 9, speed = 9,
specialAttack = 9, specialDefense = 9 } },
}
-- ../pokecrystal/engine/events/odd_egg.asm:93 `.Odd` is the OT NAME
-- (../pokecrystal/mobile/mobile_46.asm:7561); wOddEggName ("EGG") is the nickname.
local ODD_EGG_OT = "ODD"
local ODD_EGG_NICKNAME = "EGG"
local EGG_TICKET = "EGG_TICKET"
-- ../pokecrystal/engine/events/odd_egg.asm:5-38, one Random word against the
-- cumulative table; the $ffff break is :17.
local function oddEggIndex(roll)
for index = 1, #ODD_EGG_PROBABILITIES do
local probability = ODD_EGG_PROBABILITIES[index]
if probability >= 0xffff then return index end
if roll <= probability then return index end
end
return #ODD_EGG_PROBABILITIES
end
-- ../pokecrystal/engine/events/odd_egg.asm:40-48, NICKNAMED_MON_STRUCT_LENGTH
-- bytes copied verbatim.
local function buildOddEgg(data, row, save)
if not (data and row) then return nil end
local moves = {}
for index, id in ipairs(row.moves) do
local pp = row.pp[index] or 0
moves[index] = { id = id, pp = pp, maxPp = pp }
end
local dvs = {
attack = row.dvs.attack, defense = row.dvs.defense,
speed = row.dvs.speed, special = row.dvs.special,
}
local egg = Mon.new(data, row.species, row.level, {
dvs = dvs,
moves = moves,
-- ../pokecrystal/data/events/odd_eggs.asm:57 `bigdw 0 ; HP`
hp = 0,
nickname = ODD_EGG_NICKNAME,
})
if not egg then return nil end
egg.experience = row.experience
egg.stats = {
hp = row.stats.hp, attack = row.stats.attack,
defense = row.stats.defense, speed = row.stats.speed,
specialAttack = row.stats.specialAttack,
specialDefense = row.stats.specialDefense,
}
egg.maxHp = row.stats.hp
egg.hp = 0
-- ../pokecrystal/mobile/mobile_46.asm:7528 writes EGG into wPartySpecies while the struct
-- keeps the hatchling's own species.
egg.isEgg = true
-- ../pokecrystal/data/events/odd_eggs.asm:53 `db 20 ; Step cycles to hatch`,
-- the byte MON_HAPPINESS holds while the thing is an egg.
egg.eggSteps = row.eggSteps
egg.ot = ODD_EGG_OT
egg.otName = ODD_EGG_OT
egg.otId = row.otId
if save then Mon.stampOT(save, egg) end
return egg
end
-- ../pokecrystal/engine/events/odd_egg.asm:1. The party-full refusal is the
-- caller's (../pokecrystal/maps/DayCare.asm:31-32), not the routine's.
M.GiveOddEgg = function(vm)
local record = S.save(vm)
local data = S.data(vm)
if not (record and data) then return end
local party = record.party or {}
record.party = party
if #party >= Mon.PARTY_SIZE then return end
local row = ODD_EGGS[oddEggIndex(Specials.random(0x10000) - 1)]
local egg = buildOddEgg(data, row, record)
if not egg then return end
-- ../pokecrystal/engine/events/odd_egg.asm:50-57 TossItem on the EGG TICKET,
-- ahead of the party write.
local h = S.hooks(vm)
local ticket = h.itemIndex and h.itemIndex(EGG_TICKET)
if ticket and h.takeItem then h.takeItem(ticket, 1) end
party[#party + 1] = egg
end
-- ../pokecrystal/engine/events/unown_walls.asm:1, run by
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:10.
M.HoOhChamber = function(vm)
if not UnownWords.leadIsHoOh(S.party(vm)) then return end
UnownWords.openWall(vm.events, "HO_OH")
end
-- ../pokecrystal/engine/events/unown_walls.asm:13, run by
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:10; :25 CheckItem then :38
-- MON_ITEM.
M.OmanyteChamber = function(vm)
if UnownWords.wallOpened(vm.events, "OMANYTE") then return end
local h = S.hooks(vm)
local index = h.itemIndex and h.itemIndex(UnownWords.WATER_STONE)
local inPack = index and h.hasItem and h.hasItem(index)
if not inPack and not UnownWords.waterStoneSlot(S.party(vm)) then return end
UnownWords.openWall(vm.events, "OMANYTE")
end
-- ../pokecrystal/engine/events/celebi.asm:9; :296 CelebiEvent_SetBattleType is
-- the only state it leaves, and src/world/gen2/World.lua:6161 clears that.
M.CelebiShrineEvent = function(vm)
if vm.writeVarFn then vm.writeVarFn(VAR_BATTLETYPE, BATTLETYPE_CELEBI) end
vm.celebiArmed = true
end
-- ../pokecrystal/engine/events/celebi.asm:301 reads the bit
-- ../pokecrystal/engine/items/item_effects.asm:545 sets, gated on :542.
M.CheckCaughtCelebi = function(vm)
local caught = vm.celebiArmed == true and vm.battleOutcome == "caught"
vm.celebiArmed = nil
if caught then
local record = S.save(vm)
if record then
local Save = require("src.core.gen2.Save")
Save.crystalState(record).celebiCaught = true
end
end
S.answer(vm, caught and S.TRUE or S.FALSE)
end
return M
+29
View File
@@ -0,0 +1,29 @@
-- ../pokecrystal/data/events/special_pointers.asm:151 DisplayUnownWords, run by all four
-- chambers: ../pokecrystal/maps/RuinsOfAlphKabutoChamber.asm:123,
-- ../pokecrystal/maps/RuinsOfAlphHoOhChamber.asm:86, ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:86,
-- ../pokecrystal/maps/RuinsOfAlphAerodactylChamber.asm:85.
local Specials = require("src.script.gen2.Specials")
local UnownWords = require("src.world.gen2.UnownWords")
local S = Specials.shared
local M = {}
M.DisplayUnownWords = function(vm)
local h = S.hooks(vm)
local world = h.world
local game = world and world.game
if not (game and game.stack) then return end
local wall = UnownWords.wallFor(game.data, vm.scriptVar or 0)
if not wall then return end
S.block(vm, function(done)
local screen = UnownWords.new(game, {
wall = wall, world = world, onClose = function() done(true) end,
})
local ok = pcall(game.stack.push, game.stack, screen)
if not ok then done(false) end
end)
end
return M
+1 -1
View File
@@ -7,7 +7,7 @@ SyncMods.MAX_OPTION_TEXT = 256
local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
return { "red", "blue", "yellow", "gold", "silver" }
return { "red", "blue", "yellow", "gold", "silver", "crystal" }
end
local function defaultDeps()
+44 -5
View File
@@ -39,14 +39,30 @@ local BUILTIN = {
-- screens and do have ids.
local GEN2 = {
"BankOfMom",
"BattleState", "BattleTransition", "BoxMenu", "CardFlip",
"BattleState",
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 the CHALLENGE
-- / EXPLANATION / CANCEL desk menu, Crystal only.
"BattleTowerMenu",
"BattleTransition", "BoxMenu",
-- ../pokecrystal/engine/events/buena.asm:1 the radio-password prompt behind
-- `special BuenasPassword`, Crystal only.
"BuenaPassword",
"CardFlip",
-- The Pokecenter PC's whose-PC top menu and the player's item PC behind it;
-- Gen2PcMenu below is the storage system both BILL's PC rows open.
"CenterPcMenu",
"ContestMenu",
"CopyrightSplash", "Credits", "DayCareMenu", "DecorationMenu", "Diploma",
"CopyrightSplash", "Credits",
-- ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, the Crystal-only
-- peer of GoldSilverIntro below.
"CrystalIntro",
"DayCareMenu", "DecorationMenu", "Diploma",
"EggHatchAnim", "ElevatorMenu", "EvolutionAnim",
"GameFreakPresents", "GoldSilverIntro", "HallOfFame", "HeldItemMenu",
"GameFreakPresents",
-- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, the Crystal-only
-- screen PlayerProfileSetup runs before Oak's speech.
"GenderSelect",
"GoldSilverIntro", "HallOfFame", "HeldItemMenu",
-- Gen2InitClock is both timeset.asm screens: the new-game hour/minute pair
-- OakSpeech opens with, and Mom's day-of-week wheel.
"InitClock",
@@ -57,7 +73,11 @@ local GEN2 = {
"MailCompose", "MailMenu", "MailRead", "MailboxMenu",
-- Gen2MapRadio is the in-house wall radio (`special MapRadio`), not a card.
"MapRadio",
"MainMenu", "MartMenu", "MoveDeleter", "NamePick", "NamingScreen", "OakSpeech",
"MainMenu", "MartMenu", "MoveDeleter",
-- ../pokecrystal/engine/events/move_tutor.asm:1 the Goldenrod tutor's own
-- move/party pages, Crystal only.
"MoveTutor",
"NamePick", "NamingScreen", "OakSpeech",
"OptionsMenu", "PackMenu", "PartyMenu", "PcMenu", "PhotoStudio", "PokedexMenu",
"Pokegear", "SaveMenu", "ScriptMenu", "SlotMachine",
"StartMenu", "SummaryMenu", "TitleState", "TradeAnim", "TradeMenu",
@@ -66,12 +86,31 @@ local GEN2 = {
"UnownPrinter", "UnownPuzzle",
}
local GEN2_PENDING = {
BattleTowerMenu = true,
BuenaPassword = true,
MoveTutor = true,
}
local function moduleExists(name)
local path = "src/ui/gen2/" .. name .. ".lua"
local handle = io.open(path, "r")
if handle then
handle:close()
return true
end
local lfs = love and love.filesystem
return (lfs and lfs.getInfo and lfs.getInfo(path)) ~= nil
end
-- The full ids, in the same order, for tests and for the mod docs.
Screens.GEN2_IDS = {}
for _, name in ipairs(GEN2) do
local id = "Gen2" .. name
BUILTIN[id] = "src.ui.gen2." .. name
Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id
if not GEN2_PENDING[name] or moduleExists(name) then
Screens.GEN2_IDS[#Screens.GEN2_IDS + 1] = id
end
end
local cache = {}
+84 -1
View File
@@ -25,10 +25,12 @@ local Catching = require("src.battle.gen2.Catching")
local Chrome = require("src.ui.gen2.Chrome")
local Evolution = require("src.core.gen2.Evolution")
local GbcPalette = require("src.render.GbcPalette")
local Gen2Save = require("src.core.gen2.Save")
local Font = require("src.render.Font")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local MonAnim = require("src.render.MonAnim")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
local Prize = require("src.battle.gen2.Prize")
@@ -306,6 +308,11 @@ function BattleState.new(game, opts)
self.playerBackTrueColor = false
local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud
local backPath = hudGfx and hudGfx.playerBack
-- GetTrainerBackpic's gender arm, under the Dude exception
-- (../pokecrystal/engine/battle/core.asm:8984-9008).
if hudGfx and hudGfx.playerBackFemale and Gen2Save.isFemale(self.save) then
backPath = hudGfx.playerBackFemale
end
if self.tutorial and hudGfx and hudGfx.dudeBack then
backPath = hudGfx.dudeBack
end
@@ -586,6 +593,65 @@ function BattleState:pic(mon, back)
return cached or nil, trueColor, path
end
-- Crystal's animated front pics. A cache with no `anim` row -- every Gold
-- and Silver one -- gets nil here and the static pic is drawn as before.
function BattleState:animData(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
if not def then return nil end
if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)]
if entry and entry.anim then return entry.anim end
end
return def.anim
end
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
-- ../pokecrystal/engine/battle/core.asm:9112-9113
function BattleState:startFrontAnim(mon)
self.frontAnim = nil
local data = self:animData(mon)
if not data then return end
local cached = self.picCache[data.sheet]
if cached == nil then
local ok, image = pcall(Assets.image, data.sheet)
cached = ok and image or false
self.picCache[data.sheet] = cached
end
if not cached then return end
local runner = MonAnim.new(data, "battle")
if not runner then return end
local size = data.tiles * 8
self.frontAnim = { mon = mon, runner = runner, sheet = cached, size = size,
quads = {} }
end
-- AnimateFrontpic's .loop, one scene command per frame.
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
function BattleState:stepFrontAnim()
local state = self.frontAnim
if not state then return end
state.runner:update()
if state.runner:finished() then self.frontAnim = nil end
end
-- The sheet is one column of whole pictures, base first, so a frame is one
-- quad at the size the static pic would have been.
function BattleState:frontAnimFrame(mon)
local state = self.frontAnim
if not (state and mon and state.mon == mon) then return nil end
local frame = state.runner:currentFrame()
if frame <= 0 then return nil end
local quad = state.quads[frame]
if not quad then
local w, h = state.sheet:getDimensions()
if (frame + 1) * state.size > h then return nil end
quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size,
w, h)
state.quads[frame] = quad
end
return state.sheet, quad, state.size
end
-- The battle_sprite_scales registry: record id -> { path, scale }, keyed by the
-- ASSET PATH the pic is drawn from rather than by species, which is the only
-- handle there is on the pics that are not a species' own (the player's
@@ -696,6 +762,13 @@ function BattleState:drawPic(mon, back)
end
local G = love.graphics
local w, h = image:getDimensions()
-- Crystal only: the frame the animation is showing replaces the static
-- picture in the same box, at the same size.
local animSheet, animQuad, animSize
if not (back or trainerBack or enemyTrainer or doll) then
animSheet, animQuad, animSize = self:frontAnimFrame(mon)
if animSheet then w, h = animSize, animSize end
end
local px, py
local boxTiles
if back then
@@ -772,6 +845,10 @@ function BattleState:drawPic(mon, back)
scale, scale)
return
end
if animQuad then
G.draw(animSheet, animQuad, px, py, 0, scale, scale)
return
end
G.draw(image, px, py, 0, scale, scale)
end
-- A mod-supplied pic that says it is already coloured is drawn as it is:
@@ -1588,7 +1665,10 @@ function BattleState:advanceQueue()
if event.intro then self.introTextShown = true end
-- BattleStartMessage's `.not_shiny` cries the wild mon before its own line
-- (engine/battle/core.asm:8718-8721).
if event.cry then self:playCry(event.cry) end
if event.cry then
self:playCry(event.cry)
self:startFrontAnim(event.cry)
end
-- A text_asm tail that plays its own sound, the way Text_BallCaught's
-- sound_caught_mon rides the "Gotcha!" line rather than following it.
if event.sfx then
@@ -1694,6 +1774,7 @@ function BattleState:finishSendOut(after)
if not after then return end
self:playCry(after.mon)
if after.side == "enemy" then
self:startFrontAnim(after.mon)
self.showEnemyHud = true
else
self.showPlayerHud = true
@@ -1964,6 +2045,7 @@ function BattleState:update(_dt)
-- this state is only still here because ExitBattle has not cleaned up yet.
if self.phase == "evolving" or self.phase == "done" then return end
self:updateAlarm()
self:stepFrontAnim()
local input = self.game and self.game.input
if not input then return end
@@ -2489,6 +2571,7 @@ function BattleState:openTutorialPack()
end
Screens.push(self.game, "Gen2PackMenu", {
battle = true,
tutorial = true,
save = CatchTutorial.dudeSave(),
-- An empty world rather than the real one: a DUDE pocket must not reach
-- World:useFieldItem, because these buffers are not the player's bag and
+220
View File
@@ -0,0 +1,220 @@
-- ../pokecrystal/mobile/mobile_46.asm:137-177 _BattleTowerRoomMenu and the
-- jumptable at :625-641 it drives.
local BattleTower = require("src.core.gen2.BattleTower")
local Chrome = require("src.ui.gen2.Chrome")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local BattleTowerMenu = {}
BattleTowerMenu.__index = BattleTowerMenu
BattleTowerMenu.isOpaque = false
-- ../pokecrystal/mobile/mobile_46.asm:3854-3858 MenuHeader_119cf7,
-- `menu_coords 12, 7, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`.
local PICK_X, PICK_Y, PICK_W, PICK_H = 12, 7, 8, 5
-- ../pokecrystal/mobile/mobile_46.asm:1178-1183, :1222 and :1147
local ROW_X, ROW_Y = 13, 9
local ARROW_X, UP_Y, DOWN_Y = 16, 8, 10
-- ../pokecrystal/mobile/mobile_46.asm:4635-4639 MenuHeader_11a2de and
-- :4516-4530 BattleTowerRoomMenu2_PlaceYesNoMenu.
local YN_X, YN_Y, YN_W, YN_H = 14, 7, 6, 5
local YN_TEXT_X, YES_Y, NO_Y = 16, 8, 10
-- ../pokecrystal/constants/text_constants.asm:25-32, the box SpeechTextbox
-- fills at ../pokecrystal/mobile/mobile_46.asm:5267.
local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4
local SAY_TEXT_X, SAY_TEXT_Y = 1, 14
-- ../pokecrystal/mobile/mobile_46.asm:3803-3813, `ld a, $80 / ld [wcd50]`
-- counted down one per frame before the menu restarts.
local MESSAGE_FRAMES = 0x80
-- ../pokecrystal/mobile/mobile_46.asm:4609-4610, the code
-- BattleTowerRoomMenu_Cleanup (:512-513) copies into wScriptVar. The chosen
-- room is a separate byte (w3_d800 at :1285), so onDone answers the level
-- group and nil for the cancel.
BattleTowerMenu.CANCELLED = 0x0a
-- ../pokecrystal/mobile/mobile_46.asm:5488-5491
local PICK_TEXT = Strings.source("What level do you\nwant to challenge?")
-- ../pokecrystal/mobile/mobile_46.asm:5459-5462
local TOPS_TEXT = Strings.source("A party POKéMON\ntops this level.")
-- ../pokecrystal/mobile/mobile_46.asm:5464-5471
local UBER_TEXT = Strings.source(
"%s may go\nonly to BATTLE\nROOMS that are\nLv.70 or higher.")
-- ../pokecrystal/mobile/mobile_46.asm:5473-5476
local QUIT_TEXT = Strings.source("Cancel your BATTLE\nROOM challenge?")
-- ../pokecrystal/constants/charmap.asm:89 and :192, tiles $61 and $ee.
local UP_ARROW = "\xe2\x96\xb2"
local DOWN_ARROW = "\xe2\x96\xbc"
-- ../pokecrystal/mobile/mobile_46.asm:3880 and :4623-4627
local CANCEL_LABEL = Strings.source("CANCEL")
local YES_LABEL = Strings.source("YES")
local NO_LABEL = Strings.source("NO")
-- ../pokecrystal/mobile/mobile_46.asm:3869-3879 Strings_L10ToL100, six tiles
-- a row.
function BattleTowerMenu.levelLabel(group)
return string.format(" L:%-3d", group * 10):sub(1, 6)
end
-- opts: save, party, rows (BattleTower.levelGroupRows), monName,
-- onDone(levelGroup) with nil for the cancel
function BattleTowerMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, BattleTowerMenu)
self.game = game
self.data = (game and game.data) or {}
self.save = opts.save or (game and game.save)
self.party = opts.party or (self.save and self.save.party) or {}
self.rows = opts.rows or BattleTower.levelGroupRows(self.save)
self.monName = opts.monName
self.onDone = opts.onDone
-- `ld a, $1 / ld [wcd4f], a` (../pokecrystal/mobile/mobile_46.asm:1152-1153)
self.cursor = 1
self.phase = "pick"
self.message = PICK_TEXT
return self
end
function BattleTowerMenu:wantsFillScale() return true end
function BattleTowerMenu:playSfx(name)
local sfx = self.data.audio and self.data.audio.sfx
if sfx and sfx[Sound.resolve(self.data, name)] then
Sound.play(self.data, name)
end
end
-- The last row is CANCEL (../pokecrystal/mobile/mobile_46.asm:1160, :1165).
function BattleTowerMenu:rowCount()
return #self.rows + 1
end
function BattleTowerMenu:finish(group)
if self.done then return end
self.done = true
local game = self.game
if game and game.stack and game.stack:top() == self then game.stack:pop() end
if self.onDone then self.onDone(group) end
end
-- ../pokecrystal/mobile/mobile_46.asm:3794-3816: the refusal is printed, held
-- for $80 frames and the menu restarts at jumptable index 0.
function BattleTowerMenu:refuse(text)
self.phase = "message"
self.message = text
self.wait = MESSAGE_FRAMES
end
-- ../pokecrystal/mobile/mobile_46.asm:1258-1286 `.a_button`
function BattleTowerMenu:confirm()
local row = self.rows[self.cursor]
if not row then
-- ../pokecrystal/mobile/mobile_46.asm:1291-1303 `.asm_118a3c`
self.phase = "quit"
self.message = QUIT_TEXT
self.yes = true
return
end
if BattleTower.levelCheck(self.party, row.group) then
return self:refuse(TOPS_TEXT)
end
local uber = BattleTower.ubersCheck(self.party, row.group)
if uber then
local name = (self.monName and self.monName(uber)) or uber
return self:refuse(string.format(UBER_TEXT, name))
end
self:finish(row.group)
end
-- ../pokecrystal/mobile/mobile_46.asm:1240-1256: UP walks the level up and
-- rolls over to the first row, DOWN walks it back and rolls over to CANCEL.
function BattleTowerMenu:updatePick()
local input = self.game and self.game.input
if not input then return end
local count = self:rowCount()
if input:wasPressed("up") then
self.cursor = (self.cursor < count) and self.cursor + 1 or 1
elseif input:wasPressed("down") then
self.cursor = (self.cursor > 1) and self.cursor - 1 or count
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
self:confirm()
elseif input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self.phase = "quit"
self.message = QUIT_TEXT
self.yes = true
end
end
-- ../pokecrystal/mobile/mobile_46.asm:4535-4621
-- BattleTowerRoomMenu2_UpdateYesNoMenu.
function BattleTowerMenu:updateQuit()
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("up") then
self.yes = true
elseif input:wasPressed("down") then
self.yes = false
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
if self.yes then return self:finish(nil) end
self.phase = "pick"
self.message = PICK_TEXT
self.cursor = 1
elseif input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self.phase = "pick"
self.message = PICK_TEXT
self.cursor = 1
end
end
function BattleTowerMenu:update(_dt)
if self.done then return end
if self.phase == "message" then
self.wait = (self.wait or 0) - 1
if self.wait > 0 then return end
self.phase = "pick"
self.message = PICK_TEXT
self.cursor = 1
return
end
if self.phase == "quit" then return self:updateQuit() end
return self:updatePick()
end
function BattleTowerMenu:drawPanel()
Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H)
Chrome.printWrapped(self.message, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H)
if self.phase == "message" then
love.graphics.setColor(1, 1, 1, 1)
return
end
if self.phase == "quit" then
Chrome.box(YN_X, YN_Y, YN_W, YN_H)
Chrome.print(YES_LABEL, YN_TEXT_X, YES_Y)
Chrome.print(NO_LABEL, YN_TEXT_X, NO_Y)
Chrome.cursor(YN_TEXT_X - 1, self.yes and YES_Y or NO_Y)
love.graphics.setColor(1, 1, 1, 1)
return
end
Chrome.box(PICK_X, PICK_Y, PICK_W, PICK_H)
local row = self.rows[self.cursor]
Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or CANCEL_LABEL,
ROW_X, ROW_Y)
Chrome.print(UP_ARROW, ARROW_X, UP_Y)
Chrome.print(DOWN_ARROW, ARROW_X, DOWN_Y)
love.graphics.setColor(1, 1, 1, 1)
end
function BattleTowerMenu:draw()
self:drawPanel()
end
return BattleTowerMenu
+170
View File
@@ -0,0 +1,170 @@
-- Buena's two windows, Crystal only, chosen by `mode`:
--
-- password ../pokecrystal/engine/events/buena.asm:1 BuenasPassword, whose
-- .MenuHeader is `menu_coords 0, 0, 10, 7` with the right edge
-- moved to left + the category's points byte + 2 (:9-12), and
-- whose .PasswordIndices answer is zero based (:44-49).
-- prize :64 BuenaPrize -- Buena_PlacePrizeMenuBox (:219),
-- Buena_PrizeMenu's scrolling list (:249-261) and
-- PrintBlueCardBalance's "Points" box (:210). 0 is a B press
-- (:236-245).
--
-- Neither is opaque: engine/events/buena.asm:71-83 prints the prize question
-- BEFORE the list goes up over it, and the password menu stands on the map.
local Chrome = require("src.ui.gen2.Chrome")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local BuenaPassword = {}
BuenaPassword.__index = BuenaPassword
BuenaPassword.isOpaque = false
-- GetMenuTextStartCoord (../pokecrystal/home/menu.asm:214) on STATICMENU_CURSOR
-- with no STATICMENU_NO_TOP_SPACING: labels at box + (2,2), cursor one left.
local WORD_X, WORD_Y, WORD_SPACING = 2, 2, 2
local WORD_BOX_H = 8
-- engine/events/buena.asm:219 and :249, as menu_coords lays them down.
local FIELD_X, FIELD_Y, FIELD_W, FIELD_H = 0, 0, 18, 12
local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 1, 16, 9
local LIST_X, LIST_Y, LIST_SPACING = 2, 2, 2
-- ScrollingMenu_CallFunctions1and2's `add hl, de` on
-- wMenuData_ScrollingMenuWidth (engine/menus/scrolling_menu.asm:424-431).
local POINTS_X = LIST_X + 13
local VISIBLE_ROWS = 4
local ARROW_X = LIST_BOX_X + LIST_BOX_W - 1
local ARROW_UP_Y, ARROW_DOWN_Y = LIST_BOX_Y, LIST_BOX_Y + LIST_BOX_H - 1
-- BlueCardBalanceMenuHeader (engine/events/buena.asm:208) and .DrawBox's
-- PlaceString / two spaces / `lb bc, 1, 2` PrintNum (:181-203).
local BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H = 0, 11, 12, 3
local BALANCE_LABEL_X, BALANCE_LABEL_Y = 1, 12
local BALANCE_NUM_X = 8
local POINTS_LABEL = Strings.source("Points")
local UP_ARROW = "\xe2\x96\xb2"
local DOWN_ARROW = "\xe2\x96\xbc"
-- opts: mode, and then words/width/onDone(0..2) or prizes/balance/onDone(row)
function BuenaPassword.new(game, opts)
opts = opts or {}
local self = setmetatable({}, BuenaPassword)
self.game = game
self.data = (game and game.data) or {}
self.mode = opts.mode == "prize" and "prize" or "password"
self.words = opts.words or {}
self.width = math.max(1, math.floor(tonumber(opts.width) or 10))
self.prizes = opts.prizes or {}
self.balance = math.max(0, math.floor(tonumber(opts.balance) or 0))
self.onDone = opts.onDone
-- engine/events/buena.asm:34 `db 1 ; default option` and :67-68
-- `ld a, $1 / ld [wMenuSelection], a`.
self.index = 1
self.scroll = 0
return self
end
function BuenaPassword:wantsFillScale() return true end
function BuenaPassword:count()
if self.mode == "prize" then return #self.prizes end
return #self.words
end
function BuenaPassword:playSfx(name)
local sfx = self.data.audio and self.data.audio.sfx
if sfx and sfx[Sound.resolve(self.data, name)] then
Sound.play(self.data, name)
end
end
function BuenaPassword:finish(value)
if self.done then return end
self.done = true
local stack = self.game and self.game.stack
if stack then stack:pop() end
if self.onDone then self.onDone(value) end
end
function BuenaPassword:ensureVisible()
local rows = math.min(VISIBLE_ROWS, self:count())
if rows <= 0 then return end
if self.index <= self.scroll then
self.scroll = self.index - 1
elseif self.index > self.scroll + rows then
self.scroll = self.index - rows
end
self.scroll = math.max(0, math.min(self.scroll, self:count() - rows))
end
function BuenaPassword:update(_dt)
if self.done then return end
local input = self.game and self.game.input
if not input then return end
local total = self:count()
if total <= 0 then return self:finish(self.mode == "prize" and 0 or -1) end
-- Neither header sets STATICMENU_WRAP (engine/events/buena.asm:39, :256).
if input:wasPressed("up") then
if self.index > 1 then self.index = self.index - 1 end
elseif input:wasPressed("down") then
if self.index < total then self.index = self.index + 1 end
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
if self.mode == "prize" then return self:finish(self.index) end
-- engine/events/buena.asm:44-49 .PasswordIndices is zero based.
return self:finish(self.index - 1)
elseif input:wasPressed("b") then
-- STATICMENU_DISABLE_B (engine/events/buena.asm:39).
if self.mode == "prize" then
self:playSfx("Sfx_ReadText2")
return self:finish(0)
end
end
self:ensureVisible()
end
function BuenaPassword:drawPasswordPanel()
Chrome.box(0, 0, self.width + 3, WORD_BOX_H)
for i, word in ipairs(self.words) do
local ty = WORD_Y + (i - 1) * WORD_SPACING
if i == self.index then Chrome.cursor(WORD_X - 1, ty) end
Chrome.print(word, WORD_X, ty)
end
end
function BuenaPassword:drawPrizePanel()
Chrome.box(FIELD_X, FIELD_Y, FIELD_W, FIELD_H)
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
local prize = self.prizes[i]
if prize then
local ty = LIST_Y + (row - 1) * LIST_SPACING
if i == self.index then Chrome.cursor(LIST_X - 1, ty) end
Chrome.print(prize.name, LIST_X, ty)
-- .PrintPrizePoints writes one char, `'0' + cost` (buena.asm:281-289).
Chrome.print(tostring(prize.cost % 10), POINTS_X, ty)
end
end
-- SCROLLINGMENU_DISPLAY_ARROWS: the ▲ only once scrolled, the ▼ every pass
-- (engine/menus/scrolling_menu.asm:348-358, :387-395).
if self.scroll > 0 then Chrome.print(UP_ARROW, ARROW_X, ARROW_UP_Y) end
Chrome.print(DOWN_ARROW, ARROW_X, ARROW_DOWN_Y)
Chrome.box(BALANCE_BOX_X, BALANCE_BOX_Y, BALANCE_BOX_W, BALANCE_BOX_H)
Chrome.print(Strings(POINTS_LABEL), BALANCE_LABEL_X, BALANCE_LABEL_Y)
Chrome.print(Chrome.number(self.balance, 2), BALANCE_NUM_X, BALANCE_LABEL_Y)
end
function BuenaPassword:drawPanel()
if self.mode == "prize" then return self:drawPrizePanel() end
return self:drawPasswordPanel()
end
function BuenaPassword:draw()
self:drawPanel()
love.graphics.setColor(1, 1, 1, 1)
end
return BuenaPassword
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -2,9 +2,9 @@
-- reached through `special Diploma` after Celadon Mansion 3F's game designer
-- checks VAR_DEXCAUGHT == 251). Only page 1 is ever shown in play --
-- PrintDiplomaPage2 is the Game Boy Printer's second sheet
-- (engine/printer/printer.asm _PrintDiploma), stubbed separately as
-- "printer: no Game Boy Printer" -- so this transcribes PlaceDiplomaOnScreen
-- alone, not diploma2.asm.
-- (engine/printer/printer.asm:420), built with hBGMapMode zeroed and undone by
-- SafeLoadTempTilemapToTilemap, so it never reaches the screen on the cart
-- either -- so this transcribes PlaceDiplomaOnScreen alone, not diploma2.asm.
--
-- THE CERTIFICATE IS A TILEMAP, NOT A TEXT BOX. PlaceDiplomaOnScreen
-- decompresses DiplomaGFX into vTiles2 and then CopyBytes' DiplomaPage1Tilemap
+139
View File
@@ -0,0 +1,139 @@
-- ../pokecrystal/engine/menus/init_gender.asm:23-41 InitGender, which
-- PlayerProfileSetup runs before OakSpeech (engine/menus/intro_menu.asm:61-83).
local Chrome = require("src.ui.gen2.Chrome")
local Music = require("src.core.Music")
local RomText = require("src.core.RomText")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local GenderSelect = {}
GenderSelect.__index = GenderSelect
GenderSelect.isOpaque = true
-- .MenuData's two items (../pokecrystal/engine/menus/init_gender.asm:50-53),
-- and the wPlayerGender byte each writes (`ld a, [wMenuCursorY] / dec a`).
GenderSelect.OPTIONS = {
{ label = "Boy", gender = "male" },
{ label = "Girl", gender = "female" },
}
-- menu_coords 6, 4, 12, 9 -- inclusive, so 7 columns by 6 rows.
local BOX_X, BOX_Y, BOX_W, BOX_H = 6, 4, 7, 6
local TEXT_X, TEXT_Y = BOX_X + 2, BOX_Y + 2
local CURSOR_X = TEXT_X - 1
local ROW_STEP = 2
-- TEXTBOX_X / TEXTBOX_Y / TEXTBOX_INNERX / TEXTBOX_INNERY
-- (../pokecrystal/constants/text_constants.asm:25-32), the box PrintText fills.
local SAY_X, SAY_Y, SAY_W, SAY_H = 0, 12, 18, 4
local SAY_TEXT_X, SAY_TEXT_Y = 1, 14
-- gfx/new_game/gender_screen.pal:1-4, the one palette LoadGenderScreenPal
-- writes; colour 1 is the tile the screen is filled with.
GenderSelect.GROUND = { 74, 247, 255 }
-- `ld a, $10 / ld [wMusicFade]` with MUSIC_NONE as the fade target
-- (../pokecrystal/engine/menus/init_gender.asm:59-65).
local FADE_CONTROL = 0x10
-- `ld c, 10 / call DelayFrames` on the way out
-- (../pokecrystal/engine/menus/init_gender.asm:39-40).
local EXIT_FRAMES = 10
local FALLBACK = Strings.source("Are you a boy?\nOr are you a girl?")
function GenderSelect:wantsFillScale() return true end
function GenderSelect:drawsWidescreen() return true end
-- opts: onDone(gender), save
function GenderSelect.new(game, opts)
opts = opts or {}
local self = setmetatable({}, GenderSelect)
self.game = game
self.save = opts.save or (game and game.save)
self.onDone = opts.onDone
self.data = (game and game.data) or {}
-- `db 1 ; default option`: the cursor opens on Boy.
self.cursor = 1
self.exit = nil
self.text = RomText(self.data, "_AreYouABoyOrAreYouAGirlText",
FALLBACK)
return self
end
function GenderSelect:enter()
Music.fadeOut(FADE_CONTROL)
end
function GenderSelect:playSfx(name)
local sfx = self.data.audio and self.data.audio.sfx
if sfx and sfx[Sound.resolve(self.data, name)] then
Sound.play(self.data, name)
end
end
function GenderSelect:choose(index)
local option = GenderSelect.OPTIONS[index] or GenderSelect.OPTIONS[1]
if self.save and self.save.player then
self.save.player.gender = option.gender
end
self.chosen = option.gender
self.exit = EXIT_FRAMES
end
function GenderSelect:update(_dt)
if self.exit then
self.exit = self.exit - 1
if self.exit > 0 then return end
self.exit = nil
if self.onDone then self.onDone(self.chosen) end
return
end
local input = self.game and self.game.input
if not input then return end
-- STATICMENU_WRAP, and STATICMENU_DISABLE_B: no `b` arm at all.
if input:wasPressed("up") then
self.cursor = self.cursor > 1 and self.cursor - 1 or #GenderSelect.OPTIONS
elseif input:wasPressed("down") then
self.cursor = self.cursor < #GenderSelect.OPTIONS and self.cursor + 1 or 1
elseif input:wasPressed("a") or input:wasPressed("start") then
-- MenuClickSound (../pokecrystal/home/menu.asm:793-803).
self:playSfx("Sfx_ReadText2")
self:choose(self.cursor)
end
end
function GenderSelect:drawPanel()
local G = love.graphics
local ground = GenderSelect.GROUND
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
G.setColor(1, 1, 1, 1)
Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H)
Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H - 1)
Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H)
for i, option in ipairs(GenderSelect.OPTIONS) do
local row = TEXT_Y + (i - 1) * ROW_STEP
Chrome.print(option.label, TEXT_X, row)
if i == self.cursor then Chrome.cursor(CURSOR_X, row) end
end
end
function GenderSelect:draw()
self:drawPanel()
end
function GenderSelect: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)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return GenderSelect
+21 -6
View File
@@ -31,10 +31,11 @@
-- can assert them without a graphics device (the same shape
-- src/ui/gen2/SummaryMenu.lua uses).
--
-- WHAT THE CACHE DOES NOT HAVE. GetTrainerPic for TRAINER_CLASS CAL -- the
-- 7x7 picture of the player HOF_AnimatePlayerPic ends on -- is not extracted.
-- The player's own 5x7 portrait from the trainer card is, and it is the same
-- character, so that stands in and is padded into the 7x7 block the way
-- WHAT THE CACHE MAY NOT HAVE. Gold's HOF_AnimatePlayerPic ends on
-- GetTrainerPic for TRAINER_CLASS CAL, which is not extracted; Crystal's
-- HOF_LoadTrainerFrontpic ends on ChrisPic / KrisPic, which are extracted when
-- the cache is new enough. Failing both, the player's own 5x7 portrait from
-- the trainer card stands in and is padded into the 7x7 block the way
-- PlaceGraphic would. ProfOaksPCRating, which the cart prints into the bottom
-- box afterwards, needs Oak's PC (still a stub in src/script/gen2/Specials.lua)
-- and so the box is drawn empty, exactly as it is before that farcall.
@@ -45,6 +46,7 @@ local CommonText = require("src.core.gen2.CommonText")
local Core = require("src.core.gen2.HallOfFame")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Gen2Save = require("src.core.gen2.Save")
local Music = require("src.core.Music")
local Palettes = require("src.world.gen2.Palettes")
local Sound = require("src.core.Sound")
@@ -250,15 +252,26 @@ function HallOfFame.new(game, opts)
-- The player's own two pictures. The backpic is a plain image (the battle
-- HUD's), the front is the trainer card's 5x7 portrait tile sheet.
local menuGfx = data.gen2MenuGfx or {}
-- HOF_AnimatePlayerPic calls GetPlayerBackpic (../pokecrystal/engine/events/
-- halloffame.asm:520-530, ../pokecrystal/engine/gfx/player_gfx.asm:123-128).
local female = Gen2Save.isFemale(self.save)
local hud = menuGfx.battleHud or {}
-- player.sprite, the same hook Gen 1's Hall of Fame raises through
-- Sprites.playerPath (src/ui/HallOfFame.lua:100).
self.playerBackPath = require("src.pokemon.Sprites").playerPic(
(menuGfx.battleHud or {}).playerBack,
(female and hud.playerBackFemale) or hud.playerBack,
{ side = "back", kind = "hof", data = data })
-- HOF_LoadTrainerFrontpic's ChrisPic / KrisPic under class CHRIS / KRIS
-- (../pokecrystal/engine/gfx/player_gfx.asm:138-168).
local pics = hud.trainerPics or {}
self.trainerPicPath = (female and pics.KRIS) or pics.CHRIS
local card = menuGfx.trainerCard
if card and card.card then
-- GetCardPic's KrisCardPic arm
-- (../pokecrystal/engine/gfx/player_gfx.asm:96-101).
self.portrait = TileSheet.new({
path = card.card, wide = card.cardTilesWide or 16, firstTile = 0,
path = (female and card.cardFemale) or card.card,
wide = card.cardTilesWide or 16, firstTile = 0,
})
self.portraitWide = card.portraitWide or 5
self.portraitTiles = card.portraitTiles or 35
@@ -559,6 +572,8 @@ end
-- run of tile ids TrainerCard_PrintTopHalfOfCard uses, translated by the
-- scroll rather than drawn at a tile coordinate.
function HallOfFame:drawPortrait(tileX, tileY)
local pic = self:image(self.trainerPicPath)
if pic then return self:drawScrolled(pic, tileX, tileY, nil) end
if not (self.portrait and self.portrait:available()) then return end
local G = love.graphics
local wide = self.portraitWide
+16 -5
View File
@@ -33,6 +33,7 @@
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GbcPalette = require("src.render.GbcPalette")
local MagnetTrain = require("src.core.gen2.MagnetTrain")
local Music = require("src.core.Music")
@@ -82,6 +83,8 @@ function MagnetTrainRide.new(game, opts)
self.data = game and game.data
self.onDone = opts.onDone
self.finished = false
local save = opts.save or (game and game.save)
self.gender = save and save.player and save.player.gender or nil
local field = self.data and self.data.gen2Field
local gfx = field and field.magnetTrain
@@ -155,8 +158,7 @@ end
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
-- the OAM data addresses the four tiles of a frame individually.
function MagnetTrainRide:playerSheet()
local sprites = self.data and self.data.gen2Sprites
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
local def = self:playerSpriteDef()
local path = def and def.image
if not path then return nil end
local ok, image = pcall(Assets.image, path)
@@ -357,14 +359,23 @@ function MagnetTrainRide:drawBands(canvas)
end
end
-- GetPlayerIcon's sheet, which .InitPlayerSpriteAnim pairs with
-- SPRITE_ANIM_OBJ_MAGNET_TRAIN_RED or _BLUE
-- (../pokecrystal/engine/events/magnet_train.asm:131-141, :291-305).
function MagnetTrainRide:playerSpriteDef()
local sprites = self.data and self.data.gen2Sprites
if not sprites then return nil end
return sprites[FieldMoves.playerSprite(self.gender)]
or sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS
end
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
-- names.
-- names, or PAL_OW_BLUE for the _BLUE object Kris rides under.
function MagnetTrainRide:playerPalette()
local palettes = self.data and self.data.gen2Palettes
local sprites = self.data and self.data.gen2Sprites
if not palettes then return nil end
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
sprites and sprites.SPRITE_CHRIS)
self:playerSpriteDef())
end
function MagnetTrainRide:drawPlayer()
+163
View File
@@ -0,0 +1,163 @@
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor, the submenu
-- FadeToMenu / ChooseMonToLearnTMHM / CloseSubmenu wraps, and :54
-- CheckCanLearnMoveTutorMove, whose refusals print into a Textbox over the
-- party list (:101-103 `menu_coords 0, 12, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1`).
--
-- ChooseMonToLearnTMHM (../pokecrystal/engine/items/tmhm.asm:73) is
-- InitPartyMenuWithCancel with PARTYMENUACTION_TEACH_TMHM, which is the list
-- src/ui/gen2/PartyMenu.lua already draws when it is handed `tmhm`.
local Chrome = require("src.ui.gen2.Chrome")
local Happiness = require("src.core.gen2.Happiness")
local Mon = require("src.battle.gen2.Mon")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local Strings = require("src.core.Strings")
local MoveTutor = {}
MoveTutor.__index = MoveTutor
MoveTutor.isOpaque = true
-- ../pokecrystal/data/text/common_2.asm:187 _TMHMNotCompatibleText and
-- common_3.asm:1424 _KnowsMoveText. CopyName1 leaves the MOVE in
-- wStringBuffer2 and GetNickname the nickname in wStringBuffer1.
local NOT_COMPATIBLE = Strings.source("%s is\nnot compatible\vwith %s.")
local KNOWS_MOVE = Strings.source("%s knows\n%s.")
-- CanLearnTMHMMove's bitfield: `add_mt` gives the three tutor moves TMHM
-- flags 58-60 (../pokecrystal/constants/item_constants.asm:295-307), which
-- the cache splits out as `tutorMoves`.
function MoveTutor.canLearn(species, moveId)
if type(species) ~= "table" or not moveId then return false end
for _, id in ipairs(species.tmhm or {}) do
if id == moveId then return true end
end
for _, id in ipairs(species.tutorMoves or {}) do
if id == moveId then return true end
end
return false
end
-- The same bitfield as a pokemon.lua view, for PartyMenu's ABLE / NOT ABLE
-- column (src/ui/gen2/PartyMenu.lua:735, which walks `tmhm` only).
function MoveTutor.speciesView(pokemon)
local cache = {}
return setmetatable({}, { __index = function(_, key)
local hit = cache[key]
if hit ~= nil then return hit or nil end
local def = pokemon and pokemon[key]
if type(def) ~= "table" then
cache[key] = false
return nil
end
local merged = {}
for field, value in pairs(def) do merged[field] = value end
local list = {}
for _, id in ipairs(def.tmhm or {}) do list[#list + 1] = id end
for _, id in ipairs(def.tutorMoves or {}) do list[#list + 1] = id end
merged.tmhm = list
cache[key] = merged
return merged
end })
end
-- opts: move, moveName, onDone(learned)
function MoveTutor.new(game, opts)
opts = opts or {}
local self = setmetatable({}, MoveTutor)
self.game = game
local data = (game and game.data) or {}
self.move = opts.move
self.moveName = opts.moveName or opts.move or "?"
self.onDone = opts.onDone
self.pokemon = opts.pokemon or data.pokemon
self.view = MoveTutor.speciesView(self.pokemon)
self.list = self:buildList()
return self
end
function MoveTutor:wantsFillScale() return true end
function MoveTutor:drawsWidescreen() return true end
function MoveTutor:buildList()
return PartyMenu.new(self.game, {
prompt = "teach",
tmhm = { move = self.move },
pokemon = self.view,
onChoose = function(_, mon) self:picked(mon) end,
onCancel = function() self:finish(false) end,
})
end
function MoveTutor:say(body, onDone)
if self.game and self.game.say then return self.game:say(body, onDone) end
if onDone then onDone() end
end
function MoveTutor:playSfx(name)
local world = self.game and self.game.world
if world and world.playSfxNamed then world:playSfxNamed(name) end
end
-- CheckCanLearnMoveTutorMove (../pokecrystal/engine/events/move_tutor.asm:54);
-- all three failures fall back into the list, `jr nc, .loop` at :24.
function MoveTutor:picked(mon)
if not mon then return end
local name = Mon.displayName(mon)
if not MoveTutor.canLearn(self.view[mon.species], self.move) then
self:playSfx("Sfx_Wrong")
return self:say(Strings(NOT_COMPATIBLE, self.moveName, name))
end
-- ../pokecrystal/engine/pokemon/knows_move.asm:16 sets carry and prints,
-- which is the same .didnt_learn arm.
for _, move in ipairs(mon.moves or {}) do
if move.id == self.move then
return self:say(Strings(KNOWS_MOVE, name, self.moveName))
end
end
local game = self.game
if not (game and game.learnMoveOn) then return self:finish(false) end
game:learnMoveOn(mon, self.move, function(learned)
if not learned then return end
-- :87-88 `ld c, HAPPINESS_LEARNMOVE / callfar ChangeHappiness`. The 4000
-- coins are the script's own takecoins (maps/GoldenrodCity.asm:124).
Happiness.change(mon, "LEARNMOVE")
self:finish(true)
end)
end
function MoveTutor:finish(learned)
if self.done then return end
self.done = true
local stack = self.game and self.game.stack
if stack then stack:pop() end
if self.onDone then self.onDone(learned) end
end
function MoveTutor:update(dt)
if self.done then return end
self.list:update(dt)
end
function MoveTutor:drawPanel()
self.list:drawPanel()
end
function MoveTutor:draw()
self.list:draw()
end
-- PartyMenu:drawWidescreen's own blit: the party page is white to the window
-- edge, at the one integer scale Chrome.fitScale picks.
function MoveTutor: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(Chrome.fitOrigin(winW, winH, scale))
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return MoveTutor
+33 -15
View File
@@ -34,13 +34,25 @@ local NamePick = {}
NamePick.__index = NamePick
NamePick.isOpaque = true
-- data/player_names.asm PlayerNameArray, one half of the IF per edition.
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" }
-- data/player_names.asm PlayerNameArray, one half of the IF per edition, and
-- Crystal's two arrays at ../pokecrystal/data/player_names.asm:12-16, :31-35.
local PRESETS = {
gold = { "GOLD", "HIRO", "TAYLOR", "KARL" },
silver = { "SILVER", "KAMON", "OSCAR", "MAX" },
crystal = { "CHRIS", "MAT", "ALLAN", "JON" },
}
local PRESETS_FEMALE = {
crystal = { "KRIS", "AMANDA", "JUANA", "JODI" },
}
local function presetsFor()
local silver = require("src.core.GameVersion").get() == "silver"
return silver and PRESETS_SILVER or PRESETS
-- ShowPlayerNamingChoices picks the header off wPlayerGender
-- (../pokecrystal/engine/gfx/player_gfx.asm:57-62).
local function presetsFor(gender)
local version = require("src.core.GameVersion").get()
if gender == "female" and PRESETS_FEMALE[version] then
return PRESETS_FEMALE[version]
end
return PRESETS[version] or PRESETS.gold
end
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
@@ -59,14 +71,16 @@ function NamePick:wantsFillScale() return true end
function NamePick:drawsWidescreen() return true end
-- opts: onDone(name), font, pic (the CAL frontpic already loaded by the Oak
-- speech), picColors, presets
-- speech), picColors, presets, gender
function NamePick.new(game, opts)
opts = opts or {}
local self = setmetatable({}, NamePick)
self.game = game
self.onDone = opts.onDone
self.gender = opts.gender
or (game and game.save and game.save.player and game.save.player.gender)
self.items = { "NEW NAME" }
for _, name in ipairs(opts.presets or presetsFor()) do
for _, name in ipairs(opts.presets or presetsFor(self.gender)) do
self.items[#self.items + 1] = name
end
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
@@ -96,15 +110,19 @@ end
function NamePick:openNaming()
local data = self.game and self.game.data or {}
local sprites = data.gen2Sprites
local chris = sprites and sprites.SPRITE_CHRIS
local NamingScreen = require("src.ui.gen2.NamingScreen")
local def = sprites and sprites[NamingScreen.playerSprite(self.gender)]
local Palettes = require("src.world.gen2.Palettes")
Screens.push(self.game, "Gen2NamingScreen", {
type = "player",
gender = self.gender,
menuGfx = data.gen2MenuGfx,
iconPath = chris and chris.image or nil,
-- Chris is PAL_OW_RED; the naming screen is lit like day.
iconPath = def and def.image or nil,
-- Chris is PAL_OW_RED and Kris PAL_OW_BLUE
-- (../pokecrystal/engine/overworld/player_object.asm:32-39); the naming
-- screen is lit like day.
iconColors = data.gen2Palettes
and Palettes.spritePalette(data.gen2Palettes, "DAY", chris) or nil,
and Palettes.spritePalette(data.gen2Palettes, "DAY", def) or nil,
onDone = function(name)
-- An empty name keeps the default, the way ending entry with nothing
-- typed leaves wPlayerName at its preset.
@@ -112,7 +130,7 @@ function NamePick:openNaming()
if name and #name > 0 then
self:choose(name)
else
self:choose(self.items[2] or presetsFor()[1])
self:choose(self.items[2] or presetsFor(self.gender)[1])
end
end,
})
@@ -152,7 +170,7 @@ function NamePick:update(_dt)
if self.fontOk then
self:openNaming()
else
self:choose(self.items[2] or presetsFor()[1])
self:choose(self.items[2] or presetsFor(self.gender)[1])
end
else
-- A preset returns through MovePlayerPicLeft, so the pic walks back
@@ -232,7 +250,7 @@ function NamePick:drawWidescreen(winW, winH)
end
NamePick.PRESETS = PRESETS
NamePick.PRESETS_SILVER = PRESETS_SILVER
NamePick.PRESETS_FEMALE = PRESETS_FEMALE
NamePick.presetsFor = presetsFor
return NamePick
+14 -3
View File
@@ -81,19 +81,28 @@ local BOTTOM_CURSOR_TILES = 5
-- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes.
-- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters.
NamingScreen.TYPES = {
player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS" },
player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS",
spriteFemale = "SPRITE_KRIS" },
rival = { prompt = "RIVAL'S NAME?", maxLength = 7, sprite = "SPRITE_RIVAL" },
mom = { prompt = "MOTHER'S NAME?", maxLength = 7, sprite = "SPRITE_MOM" },
box = { prompt = "BOX NAME?", maxLength = 8, isBox = true },
nickname = { prompt = nil, maxLength = 10 },
}
-- GetPlayerIcon's two sheets (../pokecrystal/engine/gfx/player_gfx.asm:85-93),
-- which is also the pair the header icon here is cut from.
function NamingScreen.playerSprite(gender)
local kind = NamingScreen.TYPES.player
if gender == "female" and kind.spriteFemale then return kind.spriteFemale end
return kind.sprite
end
function NamingScreen:wantsFillScale() return true end
function NamingScreen:drawsWidescreen() return true end
-- opts: type ("player"/"rival"/"mom"/"box"/"nickname"), prompt, maxLength,
-- initial, monName (nickname header), icon/sprite image path, onDone(name),
-- onCancel().
-- initial, monName (nickname header), icon/sprite image path, gender,
-- onDone(name), onCancel().
function NamingScreen.new(game, opts)
opts = opts or {}
local self = setmetatable({}, NamingScreen)
@@ -101,6 +110,8 @@ function NamingScreen.new(game, opts)
local kind = NamingScreen.TYPES[opts.type or "player"]
or NamingScreen.TYPES.player
self.kind = kind
self.gender = opts.gender
or (game and game.save and game.save.player and game.save.player.gender)
self.isBox = opts.isBox or kind.isBox or false
self.maxLength = opts.maxLength or kind.maxLength or 7
self.prompt = opts.prompt or kind.prompt or "NICKNAME?"
+94 -9
View File
@@ -37,7 +37,9 @@
-- love.graphics.newImage skips overrides/ and AssetTransform output.
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Font = require("src.render.Font")
local Gen2Save = require("src.core.gen2.Save")
local GbcPalette = require("src.render.GbcPalette")
local Logger = require("src.core.Logger")
local Music = require("src.core.Music")
@@ -98,6 +100,11 @@ function OakSpeech.new(game, opts)
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPic(
data.playerPic or "assets/generated/intro/cal.png",
{ side = "front", kind = "intro", data = game and game.data }))
-- DrawIntroPlayerPic's other arm, KrisPic under trainer class KRIS
-- (../pokecrystal/engine/gfx/player_gfx.asm:170-188).
self.playerPicFemale = tryImage(require("src.pokemon.Sprites").playerPic(
data.playerPicFemale or "assets/generated/intro/kris.png",
{ side = "front", kind = "intro", data = game and game.data }))
self.marillPic = tryImage(data.marillPic
or "assets/generated/battle/front/marill.png")
self.shrinkPic1 = tryImage(data.shrink1 or "assets/generated/intro/shrink1.png")
@@ -114,6 +121,9 @@ function OakSpeech.new(game, opts)
self.palettes = palettes
self.oakColors = Palettes.trainerColors(palettes, "POKEMON_PROF")
self.playerColors = Palettes.trainerColors(palettes, "CAL")
-- KrisPalette is Falkner's row, shared rather than shipped
-- (../pokecrystal/data/trainers/palettes.asm:11-12).
self.playerColorsFemale = Palettes.trainerColors(palettes, "FALKNER")
self.marillColors = Palettes.monColors(palettes, self.demoSpecies)
self.picColors = nil
self.fontOk = false
@@ -134,6 +144,23 @@ function OakSpeech.new(game, opts)
return self
end
-- wPlayerGender, the byte the gender step below writes
-- (../pokecrystal/engine/menus/init_gender.asm:36-38).
function OakSpeech:gender()
local save = self.game and self.game.save
return save and save.player and save.player.gender or nil
end
-- DrawIntroPlayerPic reads the byte every time it runs, so the pic follows the
-- answer rather than whatever was loaded when the speech opened
-- (../pokecrystal/engine/gfx/player_gfx.asm:170-188).
function OakSpeech:playerPicNow()
if self:gender() == "female" and self.playerPicFemale then
return self.playerPicFemale, self.playerColorsFemale or self.playerColors
end
return self.playerPic, self.playerColors
end
function OakSpeech:text(key)
local t = self.texts[key]
if type(t) == "string" and #t > 0 then return t end
@@ -144,8 +171,8 @@ end
-- The vanilla beat list. Ids are the stable anchors a build wrapper inserts
-- around; see the header for which of them are shared with Gen 1.
function OakSpeech.defaultSteps(_speech)
return {
function OakSpeech.defaultSteps(speech)
local steps = {
-- `farcall InitClock` is the first line of OakSpeech.
{ id = "init_clock", kind = "initclock" },
-- Intro_PrepTrainerPic POKEMON_PROF, FadeInIntroPic, OakText1.
@@ -170,6 +197,14 @@ function OakSpeech.defaultSteps(_speech)
{ id = "legend", kind = "say", textKey = "_OakText7", pic = "player" },
{ id = "shrink", kind = "shrink", textKey = "_OakText7" },
}
-- PlayerProfileSetup's `farcall InitGender` runs before OakSpeech is called.
-- ../pokecrystal/engine/menus/intro_menu.asm:61-67, :79-83
local data = speech and speech.game and speech.game.data
if FieldMoves.hasGenderChoice(data and data.gen2Sprites) then
table.insert(steps, 1, { id = "gender_select", kind = "gender",
saveKey = "gender" })
end
return steps
end
local function sameSteps(steps) return steps end
@@ -190,14 +225,24 @@ function OakSpeech:buildSteps()
return hooked
end
function OakSpeech:enter()
-- `ld de, MUSIC_ROUTE_30 / call PlayMusic`, after InitGender's fade.
-- ../pokecrystal/engine/menus/intro_menu.asm:632-633, init_gender.asm:59-65
function OakSpeech:startMusic()
if self.musicStarted then return end
self.musicStarted = true
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime then
Music.play(data, self.music, true, { reason = "oak_speech" })
end
end
function OakSpeech:enter()
self.step = 0
self.answers = {}
self.steps = self:buildSteps()
if not (self.steps[1] and self.steps[1].kind == "gender") then
self:startMusic()
end
if Runtime.wants("intro.oak_speech.started") then
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
end
@@ -211,7 +256,7 @@ end
-- same way every pic on this screen does (two shipped colours, bracketed).
function OakSpeech:resolvePic(desc)
if desc == "oak" then return self.oakPic, self.oakColors end
if desc == "player" then return self.playerPic, self.playerColors end
if desc == "player" then return self:playerPicNow() end
if desc == "demo" then return self.marillPic, self.marillColors end
if type(desc) ~= "table" then return nil, nil end
if desc.type == "pokemon" then
@@ -339,21 +384,59 @@ function OakSpeech:openInitClock()
end
end
-- InitGender, pushed where PlayerProfileSetup farcalls it: before the speech
-- says anything and before its music starts
-- (../pokecrystal/engine/menus/intro_menu.asm:79-83).
function OakSpeech:openGenderSelect(step)
local game = self.game
if not (game and game.stack) then
self:startMusic()
return self:advance()
end
self.busy = true
local pushed = Screens.push(game, "Gen2GenderSelect", {
save = game.save,
onDone = function(gender)
game.stack:pop()
self.busy = false
-- `ld hl, wPlayerName / ld de, .Chris|.Kris / call InitName`, the default
-- NamePlayer lays down before the menu opens
-- (../pokecrystal/engine/menus/intro_menu.asm:768-781).
local save = game.save
if save and save.player then
save.player.name = Gen2Save.defaultPlayerName(save.version, gender)
end
self:recordAnswer(step, gender == "female" and 2 or 1,
gender == "female" and "Girl" or "Boy", gender)
self:startMusic()
self:advance()
end,
})
if not pushed then
self.busy = false
self:startMusic()
self:advance()
end
end
function OakSpeech:openNamePick(step)
self.busy = true
local NamePick = require("src.ui.gen2.NamePick")
local gender = self:gender()
local pic, picColors = self:playerPicNow()
Screens.push(self.game, "Gen2NamePick", {
font = self.game.fontData,
gender = gender,
-- NamePlayer opens with MovePlayerPicRight, so the name menu owns the
-- pic while it is up: it is the same CAL frontpic this speech has been
-- showing, walked over to make room for the box.
pic = self.playerPic,
picColors = self.playerColors,
pic = pic,
picColors = picColors,
presets = step.presets
or namePresets(self.game, step.presetsWho or step.who or "player",
step.presetsFallback or NamePick.presetsFor()),
step.presetsFallback or NamePick.presetsFor(gender)),
onDone = function(name)
name = name or NamePick.presetsFor()[1]
name = name or NamePick.presetsFor(gender)[1]
self.game.save.player.name = name
self.game.stack:pop() -- NamePick
self.busy = false
@@ -450,7 +533,9 @@ end
function OakSpeech:runStep(step)
local kind = step.kind or "say"
if kind == "initclock" then
if kind == "gender" then
self:openGenderSelect(step)
elseif kind == "initclock" then
self:openInitClock()
elseif kind == "say" then
self:applyPic(step)
+18 -1
View File
@@ -12,6 +12,7 @@
local Bag = require("src.inventory.Bag")
local Chrome = require("src.ui.gen2.Chrome")
local Gen2Save = require("src.core.gen2.Save")
local PackGfx = require("src.ui.gen2.PackGfx")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
@@ -78,6 +79,21 @@ local ASK_ITEM_MOVE = { "Where should this", "be moved to?" }
local NO_POKEMON = { "You don't have a", "#MON!" }
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
-- _CGB_PackPals' .KrisPackPals arm, and the BATTLETYPE_TUTORIAL test above it
-- that forces the DUDE's (../pokecrystal/engine/gfx/cgb_layouts.asm:770-786).
local function packGfxFor(menuGfx, save, tutorial)
local pack = menuGfx and menuGfx.pack
if not (pack and pack.palettesFemale) then return menuGfx end
if tutorial or not Gen2Save.isFemale(save) then return menuGfx end
local female = {}
for key, value in pairs(pack) do female[key] = value end
female.palettes = pack.palettesFemale
local out = {}
for key, value in pairs(menuGfx) do out[key] = value end
out.pack = female
return out
end
-- The PACK's cursor bytes. Every pocket menu restores its own cursor and
-- scroll before ScrollingMenu and writes them back after -- `ld a,
-- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY]
@@ -157,7 +173,8 @@ function PackMenu.new(game, opts)
end
self:restoreCursor()
-- The cart's own PACK tiles, when the cache has them.
self.gfx = PackGfx.new(game and game.data and game.data.gen2MenuGfx)
self.gfx = PackGfx.new(packGfxFor(
game and game.data and game.data.gen2MenuGfx, self.save, opts.tutorial))
self:rebuild()
return self
end
+1 -1
View File
@@ -9,7 +9,7 @@
-- then a second card (PrintPartyMonPage2, the mon's other three moves and
-- its non-HP stats) goes out behind it. There is no Game Boy Printer here
-- -- same reason src/ui/gen2/UnownPrinter.lua takes its A press nowhere and
-- PrintDiploma is stubbed in Specials.lua
-- PrintDiploma's own print goes nowhere
-- -- so only page 1 is worth transcribing: it is the portrait itself, the
-- part a player actually watches happen, while page 2 only ever existed as
-- ink on a strip of thermal paper nobody in this port owns. H.PhotoStudio
+135 -29
View File
@@ -21,6 +21,8 @@
-- itself at (0,0) from $46.
local Chrome = require("src.ui.gen2.Chrome")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Gen2Save = require("src.core.gen2.Save")
local Clock = require("src.core.gen2.Clock")
local Font = require("src.render.Font")
local Palettes = require("src.world.gen2.Palettes")
@@ -56,6 +58,10 @@ local CARDS = {
-- card over. One row, so `#self.cards` stays 1 and nothing pages.
local FLY_MAP_CARD = { id = "map", label = "FLY" }
-- ../pokecrystal/engine/events/specials.asm:102 OverworldTownMap -> _TownMap
-- (:1757): the wall map and the DECO_TOWN_MAP poster, no strip and no card gate.
local TOWN_MAP_CARD = { id = "map", label = "MAP" }
-- ---------------------------------------------------------------- the radio
--
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
@@ -877,7 +883,14 @@ function Pokegear.new(game, opts)
self.save = opts.save or (game and game.save)
local data = game and game.data or {}
self.landmarks = opts.landmarks or data.gen2Landmarks
-- TownMap_GetCurrentLandmark (../pokecrystal/engine/pokegear/pokegear.asm:1783)
-- reads the map header itself, so a caller that has no landmark to hand still
-- gets one.
self.currentLandmark = opts.currentLandmark
if self.currentLandmark == nil and game and game.currentLandmark then
local ok, id = pcall(game.currentLandmark, game)
if ok then self.currentLandmark = id end
end
self.clock = opts.clock
self.onClose = opts.onClose
self.cards = self:visibleCards()
@@ -930,6 +943,15 @@ function Pokegear.new(game, opts)
self.flyIndex = (self:region() == "kanto") and #self.fly or 1
end
-- _TownMap (../pokecrystal/engine/pokegear/pokegear.asm:1757): the same map,
-- one card, no ENGINE_MAP_CARD test and no strip to page.
self.townMap = (not self.fly) and opts.townMap and true or nil
if self.townMap then
self.cards = { TOWN_MAP_CARD }
self.cardIndex = 1
self.mode = "card"
end
-- _CGB_PokegearPals writes wBGPals1 only (engine/gfx/cgb_layouts.asm:157),
-- so the RED_WALK icon keeps the overworld's own OBJ palette.
self.sprites = opts.sprites or data.gen2Sprites
@@ -937,6 +959,10 @@ function Pokegear.new(game, opts)
local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
self.gfx = gfx
-- _CGB_PokegearPals picks FemalePokegearPals off wPlayerGender
-- (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190).
self.gearPals = gfx and ((Gen2Save.isFemale(self.save)
and gfx.palettesFemale) or gfx.palettes) or nil
if gfx then
self.sheet = TileSheet.new({
path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0,
@@ -950,19 +976,25 @@ function Pokegear:styled()
return self.sheet ~= nil and self.sheet:available()
end
-- MalePokegearPals or FemalePokegearPals, whichever _CGB_PokegearPals would
-- have copied into wBGPals1 (../pokecrystal/engine/gfx/cgb_layouts.asm:179-190).
function Pokegear:pals()
return self.gearPals
end
-- TownMapPals: a nybble per tile id for $00..$5f, palette 0 above that.
function Pokegear:colorsFor(tile)
local gfx = self.gfx
if not (gfx and gfx.palettes) then return nil end
if tile >= 0x60 then return gfx.palettes[1] end
return gfx.palettes[(gfx.palMap and gfx.palMap[tile + 1]) or 1]
local pals = self:pals()
if not pals then return nil end
if tile >= 0x60 then return pals[1] end
return pals[(self.gfx.palMap and self.gfx.palMap[tile + 1]) or 1]
end
-- Every string on a Pokegear card is a run of font tiles laid straight into
-- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather
-- than drawing as black ink over whatever was underneath.
function Pokegear:text(str, tx, ty)
local pals = self.gfx and self.gfx.palettes
local pals = self:pals()
return Chrome.printThrough(str, tx, ty, pals and pals[1])
end
@@ -1079,6 +1111,7 @@ function Pokegear:update(_dt)
-- The fly picker owns the whole screen: no strip, no card paging, and B
-- answers -1 rather than backing out to the strip.
if self.fly then return self:updateFlyMap(input) end
if self.townMap then return self:updateTownMap(input) end
if self.mode == "strip" then
local stripCard = self:card()
if not (stripCard and stripCard.id == "phone") then
@@ -1160,8 +1193,9 @@ function Pokegear:region()
and landmarks[self.currentLandmark]
local index = current and current.index or 0
-- LANDMARK_FAST_SHIP is $5e = 94, past every Kanto landmark and still Johto.
if index == 94 then return "johto" end
if index >= 46 then return "kanto" end
if index == self:landmarkIndex("FAST_SHIP", 0x5e) then return "johto" end
-- `cp KANTO_LANDMARK`, which is PALLET_TOWN's own index.
if index >= self:landmarkIndex("PALLET_TOWN", 0x2e) then return "kanto" end
return "johto"
end
@@ -1605,18 +1639,31 @@ local LANDMARK_PALLET_TOWN = 0x2e
local LANDMARK_VICTORY_ROAD = 0x57
local LANDMARK_ROUTE_28 = 0x5d
-- ../pokecrystal/constants/landmark_constants.asm:34 inserts BATTLE_TOWER, so
-- every index above it is one higher than pokegold's; the cache's own record is
-- the authority and the numbers above are the fallback for a dataset without one.
function Pokegear:landmarkIndex(id, fallback)
local records = (self.landmarks or {}).landmarks
local record = records and (records["LANDMARK_" .. id] or records[id])
local index = record and tonumber(record.index)
return index or fallback
end
-- Returns d (last) and e (first). Kanto's pair comes from
-- TownMap_GetKantoLandmarkLimits, which withholds everything west of Victory
-- Road until the Hall of Fame is on the record -- before that the Kanto map
-- only walks the seven landmarks on the road to Indigo Plateau.
function Pokegear:cursorLimits()
if self:region() ~= "kanto" then
return LANDMARK_SILVER_CAVE, LANDMARK_NEW_BARK_TOWN
-- `ld d, KANTO_LANDMARK - 1`, which is SILVER_CAVE either way round.
return self:landmarkIndex("SILVER_CAVE", LANDMARK_SILVER_CAVE),
self:landmarkIndex("NEW_BARK_TOWN", LANDMARK_NEW_BARK_TOWN)
end
local last = self:landmarkIndex("ROUTE_28", LANDMARK_ROUTE_28)
if ((self.save or {}).flags or {}).HALL_OF_FAME then
return LANDMARK_ROUTE_28, LANDMARK_PALLET_TOWN
return last, self:landmarkIndex("PALLET_TOWN", LANDMARK_PALLET_TOWN)
end
return LANDMARK_ROUTE_28, LANDMARK_VICTORY_ROAD
return last, self:landmarkIndex("VICTORY_ROAD", LANDMARK_VICTORY_ROAD)
end
-- The cursor's landmark index. Unset, it is the player's own, which is what
@@ -1637,32 +1684,59 @@ end
-- PokegearMap_ContinueMap's .DPad. Both branches share the increment or
-- decrement that follows them, which is why the wrap writes e - 1 / d + 1
-- rather than e / d.
function Pokegear:moveMapCursor(input)
-- rather than e / d. _TownMap's own .pressed_up / .pressed_down
-- (../pokecrystal/engine/pokegear/pokegear.asm:1853-1877) are the same pair of
-- wraps against the same d/e, so the poster steps through here too.
function Pokegear:stepMapCursor(delta)
local last, first = self:cursorLimits()
local cursor = self:mapCursorIndex()
if input:wasPressed("up") then
if delta > 0 then
-- `cp d / jr c, .wrap_around_up`: below the last landmark the value is
-- left alone, at or past it the cursor is slammed to e - 1 first.
if cursor >= last then cursor = first - 1 end
cursor = cursor + 1
elseif input:wasPressed("down") then
else
-- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps.
if cursor == first then cursor = last + 1 end
cursor = cursor - 1
end
self.mapCursor = cursor
end
function Pokegear:moveMapCursor(input)
if input:wasPressed("up") then
self:stepMapCursor(1)
elseif input:wasPressed("down") then
self:stepMapCursor(-1)
elseif input:wasPressed("right") then
-- Left and right do not move the cursor at all on this card: they page
-- the POKeGEAR. .right takes the PHONE if it is owned and the RADIO if
-- it is not; .left always takes the CLOCK.
self:switchCard("phone", "radio")
return
elseif input:wasPressed("left") then
self:switchCard("clock")
return
else
end
end
-- --------------------------------------------------------------- town map
--
-- _TownMap's `.loop` (../pokecrystal/engine/pokegear/pokegear.asm:1831-1845)
-- reads hJoyPressed for B and hJoyLast for UP and DOWN, and nothing else: A
-- takes nothing, and left/right have no card to page to.
function Pokegear:updateTownMap(input)
if input:wasPressed("b") then
if self.townMapClosed then return end
self.townMapClosed = true
local stack = self.game and self.game.stack
if stack then stack:pop() end
if self.onClose then self.onClose() end
return
end
self.mapCursor = cursor
if input:wasPressed("up") then
self:stepMapCursor(1)
elseif input:wasPressed("down") then
self:stepMapCursor(-1)
end
end
-- ------------------------------------------------------------------ fly map
@@ -1796,7 +1870,7 @@ function Pokegear:loadArrowSheet()
-- 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]),
or (self:pals() and self:pals()[1]),
})
end
end
@@ -1914,7 +1988,7 @@ end
-- painted white underneath its strings is what puts a cream bar behind every
-- run printThrough lays down.
function Pokegear:paperColor()
local pals = self.gfx and self.gfx.palettes
local pals = self:pals()
return (pals and pals[1] and pals[1][1]) or { 255, 255, 255 }
end
@@ -1929,7 +2003,7 @@ end
-- space IS a font-page cell on colour 0, which is the contrast the day/time
-- window and the map's KANTO label are drawn against.
function Pokegear:groundColor()
local pals = self.gfx and self.gfx.palettes
local pals = self:pals()
local pal = pals and pals[1]
return (pal and pal[#pal]) or { 0, 0, 0 }
end
@@ -1989,6 +2063,20 @@ function Pokegear:drawFlyBubble()
Chrome.cursor(18, 1)
end
-- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918):
-- with no card strip above it the rule turns down at (7,0) and runs back out
-- along row 2, boxing the name plate into the top right corner instead.
function Pokegear:drawTownMapRule()
self:tile(0x06, 0, 0)
for x = 1, 6 do self:tile(0x07, x, 0) end
self:tile(0x17, 7, 0)
self:tile(0x16, 7, 1)
self:tile(0x26, 7, 2)
-- `ld bc, NAME_LENGTH` from (8,2), so the run stops one short of the cap.
for x = 8, 18 do self:tile(0x07, x, 2) end
self:tile(0x17, 19, 2)
end
function Pokegear:drawMap()
-- The REGION follows the player (`cp KANTO_LANDMARK` in
-- PokegearMap_CheckRegion); the name box follows the CURSOR, which the
@@ -2000,11 +2088,15 @@ function Pokegear:drawMap()
if self.fly then
self:drawFlyBubble()
else
self:drawStrip()
-- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps.
self:tile(0x06, 0, 2)
for x = 1, 18 do self:tile(0x07, x, 2) end
self:tile(0x17, 19, 2)
if self.townMap then
self:drawTownMapRule()
else
self:drawStrip()
-- The header's own bottom rule: $07 across (1,2), with $06 and $17 as caps.
self:tile(0x06, 0, 2)
for x = 1, 18 do self:tile(0x07, x, 2) end
self:tile(0x17, 19, 2)
end
-- PokegearMap_UpdateLandmarkName: ClearBox(8,0) 2 rows by 12 columns --
-- with ' ', which is a font-page cell and so reads as BG palette 0's
@@ -2047,7 +2139,12 @@ end
function Pokegear:loadPlayerIcon()
if self.playerIcon ~= nil then return end
self.playerIcon = false
local def = self.sprites and self.sprites.SPRITE_CHRIS
-- SPRITE_ANIM_OBJ_RED_WALK or _BLUE_WALK, which is the sheet GetPlayerIcon
-- loaded plus PAL_OW_RED or PAL_OW_BLUE
-- (../pokecrystal/engine/pokegear/pokegear.asm:2737, :2752-2757).
local def = self.sprites
and self.sprites[FieldMoves.playerSprite(
self.save and self.save.player and self.save.player.gender)]
if not (def and def.image) then return end
local ok, icon = pcall(SpriteRenderer.new, def, "player")
if not (ok and icon) then return end
@@ -2205,6 +2302,14 @@ function Pokegear:drawPlain()
end
return
end
if self.townMap then
-- No town-map art in this cache, so the name plate the cursor walks is all
-- there is to show (../pokecrystal/engine/pokegear/pokegear.asm:700).
Chrome.box(7, 0, 13, 3)
local current = self:mapLandmark()
Chrome.print(flatName(current and current.name), 9, 1)
return
end
Chrome.box(0, 0, 20, 4)
local card = self:card()
Chrome.print(card and card.label or "", 2, 1)
@@ -2282,8 +2387,9 @@ function Pokegear:drawPanel()
end
-- Last: the arrow is an OBJ and composites over whatever the card drew.
-- _FlyMap has no card strip and never animates it
-- (engine/pokegear/pokegear.asm:1999).
if not self.fly then self:drawModeArrow() end
-- (engine/pokegear/pokegear.asm:1999); neither does _TownMap
-- (../pokecrystal/engine/pokegear/pokegear.asm:1757).
if not (self.fly or self.townMap) then self:drawModeArrow() end
G.setColor(1, 1, 1, 1)
end
+62 -3
View File
@@ -64,6 +64,7 @@ local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local MonAnim = require("src.render.MonAnim")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
local Unown = require("src.core.gen2.Unown")
@@ -314,6 +315,55 @@ function SummaryMenu:playCry()
if ok and Sound and Sound.playCry then
pcall(Sound.playCry, self.game.data, mon.species)
end
self:startPicAnim()
end
-- StatsScreen_PlaceFrontpic loads ANIM_MON_MENU, the longer scene. A cache
-- with no `anim` row -- every Gold and Silver one -- leaves picAnim nil.
-- ../pokecrystal/engine/pokemon/stats_screen.asm:889-901
function SummaryMenu:startPicAnim()
self.picAnim = nil
local mon = self.mon
local def = mon and self.pokemon and self.pokemon[mon.species]
if not def then return end
local data = def.anim
if mon.species == Unown.SPECIES and def.letters then
local entry = def.letters[Unown.monLetter(mon)]
if entry and entry.anim then data = entry.anim end
end
if not data then return end
local sheet = self:picImage(data.sheet)
if not sheet then return end
local runner = MonAnim.new(data, "menu")
if not runner then return end
self.picAnim = { runner = runner, sheet = sheet, size = data.tiles * 8,
quads = {} }
end
-- AnimateFrontpic's .loop, one scene command per frame.
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
function SummaryMenu:stepPicAnim()
local state = self.picAnim
if not state then return end
state.runner:update()
if state.runner:finished() then self.picAnim = nil end
end
-- The sheet is one column of whole pictures, base picture first.
function SummaryMenu:picAnimFrame()
local state = self.picAnim
if not state then return nil end
local frame = state.runner:currentFrame()
if frame <= 0 then return nil end
local quad = state.quads[frame]
if not quad then
local w, h = state.sheet:getDimensions()
if (frame + 1) * state.size > h then return nil end
quad = love.graphics.newQuad(0, frame * state.size, state.size, state.size,
w, h)
state.quads[frame] = quad
end
return state.sheet, quad, state.size
end
function SummaryMenu:speciesDef()
@@ -767,6 +817,7 @@ function SummaryMenu:updateMoveDetail(input)
end
function SummaryMenu:update(_dt)
self:stepPicAnim()
local input = self.game and self.game.input
if not input then return end
if self.moveDetail then
@@ -943,7 +994,7 @@ end
-- PrepMonFrontpic at hlcoord 0, 0: a 7x7 block with the pic padded into it and
-- the rest of the block left at the palette's colour 0.
function SummaryMenu:drawPicBlock(image, colors)
function SummaryMenu:drawPicBlock(image, colors, quad, size)
if not image then return end
local G = love.graphics
-- A fill behind the pic reads a palette colour directly, so it has to come
@@ -952,10 +1003,16 @@ function SummaryMenu:drawPicBlock(image, colors)
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", 0, 0, 7 * 8, 7 * 8)
local wide = math.floor(image:getWidth() / 8)
local wide = math.floor((size or image:getWidth()) / 8)
local pad = PIC_PAD[wide] or PIC_PAD[7]
G.setColor(1, 1, 1, 1)
local function body() G.draw(image, pad[1] * 8, pad[2] * 8) end
local function body()
if quad then
G.draw(image, quad, pad[1] * 8, pad[2] * 8)
else
G.draw(image, pad[1] * 8, pad[2] * 8)
end
end
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
@@ -970,6 +1027,8 @@ function SummaryMenu:drawPic()
if not image then return end
local colors = self.palettes and mon.species
and Palettes.monColors(self.palettes, mon.species, mon.shiny) or nil
local sheet, quad, size = self:picAnimFrame()
if sheet then return self:drawPicBlock(sheet, colors, quad, size) end
self:drawPicBlock(image, colors)
end
+160 -2
View File
@@ -11,6 +11,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
local TitleState = {}
@@ -121,15 +122,75 @@ function TitleState.new(game, opts)
-- How far past the 160px frame trails may fly (GB pixels); set each draw.
self.trailMaxX = 200
self.musicStarted = false
-- engine/movie/title.asm:104-127,217-273,304-338
self.suicuneColor, self.suicuneGray = {}, {}
if type(title.suicuneFrames) == "table" then
for i, path in ipairs(title.suicuneFrames) do
self.suicuneColor[i] = tryImage(path)
end
end
if type(title.suicuneFramesGray) == "table" then
for i, path in ipairs(title.suicuneFramesGray) do
self.suicuneGray[i] = tryImage(path)
end
end
self.suicuneX = tonumber(title.suicuneX) or 48
self.suicuneY = tonumber(title.suicuneY) or 96
-- SuicuneFrameIterator's `and %111` clock (engine/movie/title.asm:217-243).
self.suicuneEvery = tonumber(title.suicuneEvery) or 8
self.suicuneTick = 0
self.suicuneFrame = 1
self.gemColor = tryImage(title.gem)
self.gemGray = tryImage(title.gemGray)
self.gemX = tonumber(title.gemX) or 56
self.gemRestY = tonumber(title.gemY) or 6
self.gemStep = tonumber(title.gemStep) or 2
-- TitleScreenEntrance (engine/menus/intro_menu.asm:1078-1123): hSCX walks
-- to 0 while alternating logo lines converge and the gem descends.
local entrance = type(title.entrance) == "table" and title.entrance or nil
self.entrance = entrance
self.entranceScx = entrance and (tonumber(entrance.scx) or 112) or 0
self.entranceStep = entrance and (tonumber(entrance.step) or 4) or 4
self.entranceLines = entrance and (tonumber(entrance.lines) or 80) or 0
self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
self.entranceSfx = title.entranceSfx
self.timeoutFrames = tonumber(title.timeoutFrames)
self.onTimeout = opts.onTimeout
-- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
-- colour 0 backs the whole band.
local pals = type(title.palettes) == "table" and title.palettes.bg or nil
local band = type(pals) == "table" and type(pals[8]) == "table"
and pals[8][1] or nil
self.bandColor = (type(band) == "table" and #band >= 3)
and { band[1] / 255, band[2] / 255, band[3] / 255, 1 } or { 0, 0, 0, 1 }
return self
end
function TitleState:enter()
function TitleState:startMusic()
if self.musicStarted then return end
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime and not self.musicStarted then
if data and data.audio and data.audio.runtime then
Music.play(data, "Music_TitleScreen", true, { reason = "title" })
self.musicStarted = true
end
end
function TitleState:enter()
if self.entrance then
-- _TitleScreen silences the channels and plays the entrance sting;
-- MUSIC_TITLE waits for the entrance to land
-- (engine/movie/title.asm:184,210-213; engine/menus/intro_menu.asm:1118-1119).
Music.stop()
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime and self.entranceSfx
and data.audio.sfx and data.audio.sfx[self.entranceSfx] then
Sound.play(data, self.entranceSfx)
end
else
self:startMusic()
end
-- intro.boot.title: the last card of the GS boot cinema, up with its music
-- started. Gen 2 only, like the rest of intro.boot.* (see
-- src/ui/gen2/CopyrightSplash.lua for why the set is new rather than shared).
@@ -197,15 +258,51 @@ function TitleState:stepTrails()
self.trails = alive
end
-- SuicuneFrameIterator (engine/movie/title.asm:217-249): advance once per
-- `suicuneEvery` frames, cycling the four frame bases.
function TitleState:advanceSuicune()
if #self.suicuneColor == 0 then return end
local c = self.suicuneTick
self.suicuneTick = (c + 1) % 256
if c % self.suicuneEvery ~= 0 then return end
self.suicuneFrame = math.floor(c % (self.suicuneEvery * 4)
/ self.suicuneEvery) + 1
end
function TitleState:update(_dt)
self.frameCounter = self.frameCounter + 1
self:advanceHooh()
self:advanceSuicune()
if self.frameCounter % self.cloudScrollEvery == 0 then
self.cloudScroll = (self.cloudScroll - 1) % 160
end
self:spawnTrail()
self:stepTrails()
if self.entranceScx > 0 then
-- TitleScreenEntrance polls no buttons and moves the gem by 2 a frame
-- (engine/menus/intro_menu.asm:1078-1107; engine/movie/title.asm:340-362).
self.entranceScx = math.max(0, self.entranceScx - self.entranceStep)
if self.gemY < self.gemRestY then
self.gemY = math.min(self.gemRestY, self.gemY + self.gemStep)
end
if self.entranceScx == 0 then
self:startMusic()
-- TitleScreenTimer only starts once the entrance scene hands over
-- (engine/menus/intro_menu.asm:1110-1136).
self.timeoutStart = self.frameCounter
end
return
end
-- TitleScreenTimer / TitleScreenMain's run-down back into the attract
-- loop (engine/menus/intro_menu.asm:1125-1236).
if self.timeoutFrames and self.onTimeout
and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
self.onTimeout()
return
end
local input = self.game.input
if input and (input:wasPressed("a") or input:wasPressed("start")) then
if self.onContinue then self.onContinue() end
@@ -242,10 +339,71 @@ function TitleState:drawCloudSpan(x0, x1)
end
end
-- TitleScreenEntrance's interlace: even lines slide in from the left, odd
-- from the right, converging as hSCX walks to 0
-- (engine/menus/intro_menu.asm:1084-1103).
function TitleState:drawEntranceScreen(screen)
local G = love.graphics
local scx = self.entranceScx
if scx <= 0 then
G.draw(screen, 0, 0)
return
end
local w, h = screen:getDimensions()
local lines = math.min(self.entranceLines, h)
self.entranceQuad = self.entranceQuad or G.newQuad(0, 0, w, 1, w, h)
for line = 0, lines - 1 do
self.entranceQuad:setViewport(0, line, w, 1, w, h)
G.draw(screen, self.entranceQuad, line % 2 == 0 and -scx or scx, line)
end
-- hWY holds the copyright window off screen until the entrance lands
-- (engine/movie/title.asm:198-199; engine/menus/intro_menu.asm:1121-1122).
local bottom = (self.entranceHideBelow or h) - lines
if bottom > 0 then
self.entranceQuad:setViewport(0, lines, w, bottom, w, h)
G.draw(screen, self.entranceQuad, 0, lines)
end
end
function TitleState:drawContent()
local G = love.graphics
local screen, _, trail, hoohFrames = self:art()
G.setColor(1, 1, 1, 1)
local gem = self:gray() and (self.gemGray or self.gemColor) or self.gemColor
local suicuneFrames = nil
if #self.suicuneColor > 0 then
suicuneFrames = (self:gray() and #self.suicuneGray > 0)
and self.suicuneGray or self.suicuneColor
end
if gem or suicuneFrames then
-- Crystal's layering: the gem is OAM_PRIO so every BG colour 1-3 pixel
-- beats it; Suicune is BG; the window copyright covers Suicune's last
-- row (engine/movie/title.asm:81-85,334; engine/menus/intro_menu.asm:1121-1122).
local fill = self:gray() and SKY_GRAY or self.sky
G.setColor(fill[1], fill[2], fill[3], 1)
G.rectangle("fill", 0, 0, 160, 144)
G.setColor(1, 1, 1, 1)
if gem then G.draw(gem, self.gemX, self.gemY) end
if suicuneFrames then
local frame = suicuneFrames[self.suicuneFrame] or suicuneFrames[1]
if frame then G.draw(frame, self.suicuneX, self.suicuneY) end
end
if self.entranceScx <= 0 then
-- The pal-7 window line covers the BG once hWY lands at $88
-- (engine/movie/title.asm:40-43; engine/menus/intro_menu.asm:1121-1122).
local bandTop = self.entranceHideBelow or 136
local band = self.bandColor
G.setColor(band[1], band[2], band[3], 1)
G.rectangle("fill", 0, bandTop, 160, 144 - bandTop)
G.setColor(1, 1, 1, 1)
end
if screen then
self:drawEntranceScreen(screen)
end
return
end
if screen then
-- title_screen.png already carries the cart's © GAME FREAK line on row 17.
G.draw(screen, 0, 0)
+31 -2
View File
@@ -39,6 +39,7 @@
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Gen2Save = require("src.core.gen2.Save")
local TileSheet = require("src.ui.gen2.TileSheet")
local TrainerCard = {}
@@ -68,6 +69,19 @@ local KANTO_BADGES = {
"EARTH",
}
-- The two slots _CGB_TrainerCard swaps by gender: CHRIS and FALKNER/KrisPalette.
-- ../pokecrystal/engine/gfx/cgb_layouts.asm:619-624, data/trainers/palettes.asm:9-12
local PLAYER_PALETTE, BORDER_PALETTE = 1, 2
-- Kris's attrmap: the border takes CHRIS's palette, the portrait takes hers,
-- the top-right corner follows the border, and Clair's face borrows hers
-- (../pokecrystal/engine/gfx/cgb_layouts.asm:649-666, :698-712).
local FEMALE_ZONES = {
{ 14, 1, 5, 7, BORDER_PALETTE },
{ 18, 1, 1, 1, PLAYER_PALETTE },
{ 14, 14, 4, 2, BORDER_PALETTE },
}
-- TrainerCard_JohtoBadgesOAM lists the badges in wJohtoBadges bit order,
-- which is not the order they are drawn in: Mineral comes before Storm.
local BADGE_OAM_ORDER = {
@@ -92,10 +106,13 @@ function TrainerCard.new(game, opts)
local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).trainerCard
self.gfx = gfx
self.female = Gen2Save.isFemale(self.save) and gfx ~= nil
and gfx.cardFemale ~= nil
if gfx then
-- One palette lookup per cell, flattened from the FillBoxCGB zones the
-- way PackGfx does it. Anything outside a zone is palette 1.
self.zone = {}
self.zoneDefault = BORDER_PALETTE
for _, z in ipairs(gfx.paletteZones or {}) do
for y = z[2], z[2] + z[4] - 1 do
for x = z[1], z[1] + z[3] - 1 do
@@ -103,11 +120,22 @@ function TrainerCard.new(game, opts)
end
end
end
if self.female then
self.zoneDefault = PLAYER_PALETTE
for _, z in ipairs(FEMALE_ZONES) do
for y = z[2], z[2] + z[4] - 1 do
for x = z[1], z[1] + z[3] - 1 do
self.zone[y * SCREEN_W + x] = z[5]
end
end
end
end
local function paletteFor(_, tx, ty)
return self:colorsAt(tx, ty)
end
self.card = TileSheet.new({
path = gfx.card, wide = gfx.cardTilesWide or 16, firstTile = 0,
path = (self.female and gfx.cardFemale) or gfx.card,
wide = gfx.cardTilesWide or 16, firstTile = 0,
paletteFor = paletteFor,
})
self.status = TileSheet.new({
@@ -152,7 +180,8 @@ function TrainerCard:pair(colors)
end
function TrainerCard:colorsAt(tx, ty)
local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 2
local index = (self.zone and self.zone[ty * SCREEN_W + tx])
or self.zoneDefault or BORDER_PALETTE
return self:palette(index)
end
+1 -1
View File
@@ -9,7 +9,7 @@
-- is drawn and driven entirely on the cartridge, and only the A press
-- (`farcall PrintUnownStamp`, engine/printer/printer.asm) walks the screen
-- out the serial port to a Game Boy Printer. There is no printer here, the
-- same reason PrintDiploma stays stubbed in Specials.lua, so A lands on the
-- same reason PrintDiploma's own print goes nowhere, so A lands on the
-- same arm a cartridge with nothing plugged into its link port takes: the
-- stamp does not print and the screen stays up. Everything else is the
-- cart's.
+4 -1
View File
@@ -65,6 +65,7 @@ local PAL = {
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
railCrystal = { 168, 120, 236 }, -- Crystal cartridge (translucent violet)
}
-- Semantic aliases kept so ported call sites read the same as before.
PAL.cardBorder = PAL.line
@@ -322,11 +323,13 @@ function Theme.meter(x, y, w, h, pct, c)
end
-- The 4px version rail across the top of both windows: the only brand
-- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours).
-- colour on screen (Red / Blue / Yellow / Gold / Silver / Crystal cartridge
-- colours).
function Theme.versionRail(x, y, w, h)
if not G then return end
local bars = {
PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver,
PAL.railCrystal,
}
local seg = w / #bars
for i, c in ipairs(bars) do
+77 -7
View File
@@ -36,6 +36,7 @@
-- `action` names the World method that carries it out; everything else in the
-- table is that action's argument.
local GameVersion = require("src.core.GameVersion")
local Permissions = require("src.world.gen2.Permissions")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
@@ -152,14 +153,41 @@ FieldMoves.KANTO_BADGES = {
-- World:setEngineFlag / World:engineFlag route badge ids here so there is one
-- store again, the same way ENGINE_BUG_CONTEST_TIMER is routed to
-- save.bugContest rather than kept as a second copy.
FieldMoves.BADGE_FLAG = {}
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
FieldMoves.BADGE_FLAG[25 + index] = { store = "badges", name = name }
end
for index, name in ipairs(FieldMoves.KANTO_BADGES) do
FieldMoves.BADGE_FLAG[33 + index] = { store = "kantoBadges", name = name }
-- Crystal declares 162 engine flags to Gold's 93 and the badge block sits one
-- higher (constants/engine_flags.asm:39 vs pokegold's :38), so the ids come
-- from the cache's engineFlagOrder when it has one.
-- Crystal only; pokegold constants/engine_flags.asm:4-111 has no such row.
FieldMoves.FEMALE_FLAG_NAME = "ENGINE_PLAYER_IS_FEMALE"
function FieldMoves.bindEngineFlags(order)
local byName = {}
if type(order) == "table" then
-- pairs, not ipairs: a const_skip leaves a hole and ipairs would stop
-- there, silently dropping every badge past it.
for index, name in pairs(order) do
if type(index) == "number" and type(name) == "string" then
byName[name] = index - 1
end
end
end
local flags = {}
local function place(names, store, goldBase)
for index, name in ipairs(names) do
local id = byName["ENGINE_" .. name .. "BADGE"] or (goldBase + index)
flags[id] = { store = store, name = name }
end
end
place(FieldMoves.JOHTO_BADGES, "badges", 25)
place(FieldMoves.KANTO_BADGES, "kantoBadges", 33)
FieldMoves.BADGE_FLAG = flags
-- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte
-- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121).
FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME]
return flags
end
FieldMoves.bindEngineFlags(nil)
function FieldMoves.hasBadge(save, badge)
if not badge then return true end
local owned = save and save.player and save.player.badges
@@ -357,6 +385,39 @@ FieldMoves.STATE_SPRITE = {
surf_pika = "SPRITE_SURFING_PIKACHU",
}
-- data/sprites/player_sprites.asm:8-13 KrisStateSprites, the other half of the
-- table GetPlayerSprite picks between (engine/overworld/overworld.asm:55-64).
FieldMoves.STATE_SPRITE_FEMALE = {
normal = "SPRITE_KRIS",
bike = "SPRITE_KRIS_BIKE",
surf = "SPRITE_SURF",
surf_pika = "SPRITE_SURFING_PIKACHU",
}
-- wPlayerGender's PLAYERGENDER_FEMALE_F, as the save spells it
-- (constants/ram_constants.asm:176-177).
function FieldMoves.isFemale(gender)
return gender == "female"
end
-- GetPlayerSprite's table pick and row walk
-- (engine/overworld/overworld.asm:57-64, :67-75).
function FieldMoves.stateSprite(state, gender)
local table_ = FieldMoves.isFemale(gender)
and FieldMoves.STATE_SPRITE_FEMALE or FieldMoves.STATE_SPRITE
return table_[state] or table_[FieldMoves.PLAYER_NORMAL]
end
function FieldMoves.playerSprite(gender)
return FieldMoves.stateSprite(FieldMoves.PLAYER_NORMAL, gender)
end
-- Whether the cache carries Kris at all; Gold and Silver have no
-- KrisStateSprites to extract (pokegold data/sprites/player_sprites.asm:1-6).
function FieldMoves.hasGenderChoice(sprites)
return (sprites and sprites[FieldMoves.STATE_SPRITE_FEMALE.normal]) ~= nil
end
function FieldMoves.isBiking(state)
return state == FieldMoves.PLAYER_BIKE
end
@@ -536,10 +597,14 @@ end
-- FlashFunction.CheckUseFlash: badge, then wTimeOfDayPalset == DARKNESS_PALSET
-- -- so FLASH is refused in a lit cave and on a route alike, and the refusal
-- is FieldMoveFailed's generic "Can't use that here."
--
-- ../pokecrystal/engine/events/overworld.asm:284-287 puts SpecialAerodactylChamber
-- between the two, and its carry is a second way into `.useflash`.
function FieldMoves.flashFromMenu(ctx)
local refused = badgeGate(ctx, "FLASH")
if refused then return refused end
if not ctx.dark then
local chamber = ctx.openAerodactylWall and ctx.openAerodactylWall()
if not ctx.dark and not chamber then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH }
@@ -562,6 +627,11 @@ function FieldMoves.surfFromMenu(ctx)
or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
-- Crystal's added `farcall CheckFacingObject`, which pokegold's :339 tags
-- BUG (../pokecrystal/engine/events/overworld.asm:364-365).
if ctx.facingObject and GameVersion.fixes().surfOntoNpc then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
return {
ok = true, action = "surf",
state = FieldMoves.surfType(ctx.mon),
+225
View File
@@ -0,0 +1,225 @@
-- ../pokecrystal/engine/events/unown_walls.asm:102 DisplayUnownWords, and the
-- Unown alphabet it writes out of ../pokecrystal/constants/charmap.asm:424;
-- :1 HoOhChamber, :13 OmanyteChamber, :54 SpecialAerodactylChamber and :81
-- SpecialKabutoChamber, the four routines that open the chambers' walls.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local Sound = require("src.core.Sound")
local UnownWords = {}
UnownWords.__index = UnownWords
UnownWords.isOpaque = false
-- engine/tilesets/map_palettes.asm:40, ../pokecrystal/constants/tileset_constants.asm:55
UnownWords.BANK1 = 0x80
UnownWords.BROWN = 6
-- ../pokecrystal/engine/events/unown_walls.asm:225 .YChar, :237 .ZChar, :249 .DashChar
local FIXED = {
[0x60] = { 0x5b, 0x5c, 0x4d, 0x5d },
[0x62] = { 0x4e, 0x4f, 0x5e, 0x5f },
[0x64] = { 0x02, 0x03, 0x03, 0x02 },
}
function UnownWords.square(char)
local fixed = FIXED[char]
if fixed then return fixed[1], fixed[2], fixed[3], fixed[4] end
local base = UnownWords.BANK1 + char
return base, base + 1, base + 0x10, base + 0x11
end
-- ../pokecrystal/engine/events/unown_walls.asm:122-127
function UnownWords.origin(wall)
return (wall.x1 or 0) + 1, (wall.y1 or 0) + 2
end
-- ../pokecrystal/engine/events/unown_walls.asm:119 MenuBox, home/menu.asm:131 GetMenuBoxDims
function UnownWords.boxRect(wall)
local x1, y1 = wall.x1 or 0, wall.y1 or 0
return x1, y1, (wall.x2 or x1) - x1 + 1, (wall.y2 or y1) - y1 + 1
end
-- ../pokecrystal/engine/events/unown_walls.asm:182 _DisplayUnownWords_CopyWord
function UnownWords.layout(wall)
local tx, ty = UnownWords.origin(wall)
local out = {}
for index, char in ipairs(wall.chars or {}) do
local tl, tr, bl, br = UnownWords.square(char)
out[index] = {
tx = tx + (index - 1) * 2, ty = ty,
tl = tl, tr = tr, bl = bl, br = br,
}
end
return out
end
-- ../pokecrystal/constants/event_flags.asm:486-489, the four Crystal-only
-- EVENT_WALL_OPENED_IN_*_CHAMBER bits.
UnownWords.WALL_OPENED = {
HO_OH = 806,
KABUTO = 807,
OMANYTE = 808,
AERODACTYL = 809,
}
-- ../pokecrystal/engine/events/unown_walls.asm:60, :87 -- the
-- GetMapAttributesPointer compare the two non-special routines are gated on.
UnownWords.CHAMBER_MAPS = {
HO_OH = "RUINS_OF_ALPH_HO_OH_CHAMBER",
KABUTO = "RUINS_OF_ALPH_KABUTO_CHAMBER",
OMANYTE = "RUINS_OF_ALPH_OMANYTE_CHAMBER",
AERODACTYL = "RUINS_OF_ALPH_AERODACTYL_CHAMBER",
}
-- ../pokecrystal/engine/events/unown_walls.asm:4, :22
UnownWords.HO_OH = "HO_OH"
UnownWords.WATER_STONE = "WATER_STONE"
-- ../pokecrystal/engine/events/unown_walls.asm:16 EventFlagAction CHECK_FLAG
function UnownWords.wallOpened(events, chamber)
local flag = UnownWords.WALL_OPENED[chamber]
if not (events and flag) then return false end
return events:get(flag) and true or false
end
-- ../pokecrystal/engine/events/unown_walls.asm:8 EventFlagAction SET_FLAG
function UnownWords.openWall(events, chamber)
local flag = UnownWords.WALL_OPENED[chamber]
if not (events and flag) then return false end
if events:get(flag) then return false end
events:set(flag, true)
return true
end
-- ../pokecrystal/engine/events/unown_walls.asm:2-5: wPartySpecies[0], which
-- holds EGG rather than the hatchling's species while a slot is an egg.
function UnownWords.leadIsHoOh(party)
local lead = party and party[1]
if not lead or lead.isEgg then return false end
return lead.species == UnownWords.HO_OH
end
-- ../pokecrystal/engine/events/unown_walls.asm:28-43: wPartyCount down to 1,
-- MON_ITEM on each, so the LAST slot holding one is the one it stops at.
function UnownWords.waterStoneSlot(party)
party = party or {}
for slot = #party, 1, -1 do
local mon = party[slot]
if mon and mon.item == UnownWords.WATER_STONE then return slot end
end
return nil
end
-- ../pokecrystal/engine/events/unown_walls.asm:54, whose carry FlashFunction
-- jumps on at ../pokecrystal/engine/events/overworld.asm:285.
function UnownWords.aerodactylChamber(events, mapId)
if mapId ~= UnownWords.CHAMBER_MAPS.AERODACTYL then return false end
UnownWords.openWall(events, "AERODACTYL")
return true
end
-- ../pokecrystal/engine/events/unown_walls.asm:81, off the escape rope arm of
-- EscapeRopeOrDig (../pokecrystal/engine/events/overworld.asm:809).
function UnownWords.kabutoChamber(events, mapId)
if mapId ~= UnownWords.CHAMBER_MAPS.KABUTO then return false end
UnownWords.openWall(events, "KABUTO")
return true
end
-- ../pokecrystal/constants/script_constants.asm:319 UNOWNWORDS_*
function UnownWords.wallFor(data, scriptVar)
local walls = data and data.gen2EventTables and data.gen2EventTables.unownWalls
if not walls then return nil end
return walls[(scriptVar or 0) + 1]
end
-- opts: wall (an events.unownWalls row), world, onClose()
function UnownWords.new(game, opts)
opts = opts or {}
local self = setmetatable({}, UnownWords)
self.game = game
self.wall = opts.wall
self.world = opts.world
self.onClose = opts.onClose
self.done = false
self.squares = self.wall and UnownWords.layout(self.wall) or {}
return self
end
-- ../pokecrystal/engine/events/unown_walls.asm:155 _DisplayUnownWords_FillAttr
function UnownWords:tileset()
local world = self.world
local map = world and world.map
local def = map and map.def
local tileset = def and world.tilesets and world.tilesets[def.tileset]
if not tileset or not tileset.image then return nil end
if self.atlas == nil then
local ok, image = pcall(Assets.image, tileset.image)
self.atlas = ok and image or false
if self.atlas then self.atlas:setFilter("nearest", "nearest") end
local set = world.palettes
and Palettes.bgSet(world.palettes, def, world.daytime or "DAY")
self.colors = set and set[UnownWords.BROWN] or nil
end
if not self.atlas then return nil end
return tileset, self.atlas
end
-- ../pokecrystal/engine/events/unown_walls.asm:147 PlayClickSFX, :148 CloseWindow
function UnownWords:finish()
if self.done then return end
self.done = true
local game = self.game
if game and game.data then Sound.play(game.data, "SFX_READ_TEXT_2") end
if game and game.stack then game.stack:pop() end
if self.onClose then self.onClose() end
end
function UnownWords:update(_dt)
if self.done then return end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("a") or input:wasPressed("b") then self:finish() end
end
function UnownWords:draw()
local wall = self.wall
if not wall then return end
local bx, by, bw, bh = UnownWords.boxRect(wall)
Font.drawBox(bx, by, bw, bh)
local tileset, atlas = self:tileset()
if not tileset then return end
local perRow = tileset.tilesPerRow or 16
local aw, ah = atlas:getDimensions()
local quads = {}
local function quadFor(tile)
local q = quads[tile]
if not q then
q = love.graphics.newQuad((tile % perRow) * 8,
math.floor(tile / perRow) * 8, 8, 8, aw, ah)
quads[tile] = q
end
return q
end
local function body()
love.graphics.setColor(1, 1, 1, 1)
for _, square in ipairs(self.squares) do
local x, y = square.tx * 8, square.ty * 8
love.graphics.draw(atlas, quadFor(square.tl), x, y)
love.graphics.draw(atlas, quadFor(square.tr), x + 8, y)
love.graphics.draw(atlas, quadFor(square.bl), x, y + 8)
love.graphics.draw(atlas, quadFor(square.br), x + 8, y + 8)
end
end
if self.colors and GbcPalette.available() then
GbcPalette.with(self.colors, body)
else
body()
end
love.graphics.setColor(0, 0, 0, 1)
end
return UnownWords
+214 -25
View File
@@ -41,6 +41,7 @@ local HiddenItems = require("src.world.gen2.HiddenItems")
local Mail = require("src.core.gen2.Mail")
local Map = require("src.world.gen2.Map")
local Palettes = require("src.world.gen2.Palettes")
local UnownWords = require("src.world.gen2.UnownWords")
local Mon = require("src.battle.gen2.Mon")
local Movement = require("src.script.gen2.Movement")
local Music = require("src.core.Music")
@@ -116,6 +117,14 @@ local VAR = {
XCOORD = 0x12,
YCOORD = 0x13,
SPECIALPHONECALL = 0x14,
-- ../pokecrystal/constants/script_constants.asm:69-74, the six rows Gold's
-- table stops short of; ../pokecrystal/engine/overworld/variables.asm:62-67.
BT_WIN_STREAK = 0x15,
KURT_APRICORNS = 0x16,
CALLERID = 0x17,
BLUECARDBALANCE = 0x18,
BUENASPASSWORD = 0x19,
KENJI_BREAK = 0x1a,
}
-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row:
@@ -374,6 +383,13 @@ local function itemByIndex(items, index)
return nil
end
-- One WRAM byte, the width every VAR_* store is (`ld a, [de]` / `ld [de], a`).
local function byteOf(value)
local n = math.floor(tonumber(value) or 0)
if n < 0 then n = 0 end
return n % 256
end
-- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN
-- and VAR.BADGES are all "how many of these are set" reads off one.
local function countFlags(flags)
@@ -810,6 +826,7 @@ function World:load()
self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {}
self.constants =
self:dataTable("gen2Constants", "data/generated/constants.lua") or {}
FieldMoves.bindEngineFlags(self.constants.engineFlagOrder)
-- The side tables a script command NAMES rather than carries: the phone
-- book, the in-game trades, the elevator's floor labels and the decoration
-- descriptions. A cache built before the extractor reached them has no
@@ -1256,7 +1273,7 @@ function World:load()
end,
-- ---- encounters --------------------------------------------------------
setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end,
setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end,
rollWild = function() return self:rollWild() end,
-- The WRAM bytes the ENGINE owns rather than the script: nil means "not
-- mine", and the VM falls back to its own sparse store.
@@ -1573,9 +1590,78 @@ function World:readVar(varId)
if varId == VAR.SPECIALPHONECALL then
return self:specialCall()
end
if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then
return self:crystalVar(varId)
end
return 0
end
-- ../pokecrystal/engine/overworld/variables.asm:62-67, the six .VarActionTable
-- rows Crystal appends past VAR_SPECIALPHONECALL.
function World:crystalVar(varId)
-- ../pokecrystal/ram/wram.asm:3286 wCurCaller, which this port parks on the
-- VM (src/script/gen2/CallAsm.lua:190).
if varId == VAR.CALLERID then
return (self.vm and self.vm.curPhoneCaller) or 0
end
local save = self.game and self.game.save
if not save then return 0 end
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers.
if varId == VAR.BT_WIN_STREAK then
return byteOf(Gen2Save.battleTowerState(save).streak)
end
-- ../pokecrystal/engine/events/kurt.asm:24,45 wKurtApricornQuantity.
if varId == VAR.KURT_APRICORNS then
return byteOf(save.kurtApricornQuantity)
end
local crystal = Gen2Save.crystalState(save)
if varId == VAR.BLUECARDBALANCE then
return byteOf(crystal.buenaPassword.balance)
end
if varId == VAR.BUENASPASSWORD then
return byteOf(crystal.buenaPassword.word)
end
-- ../pokecrystal/engine/overworld/time.asm:136 SampleKenjiBreakCountdown.
if varId == VAR.KENJI_BREAK then
return byteOf(crystal.kenjiBreak)
end
return 0
end
-- The three of them Script_writevar can reach: RETVAR_ADDR_DE rows write the
-- variable itself, RETVAR_STRBUF2 rows write the scratch buffer and are lost
-- (../pokecrystal/engine/overworld/variables.asm:21-25).
function World:setCrystalVar(varId, value)
value = byteOf(value)
if varId == VAR.CALLERID then
if self.vm then self.vm.curPhoneCaller = value end
return
end
local save = self.game and self.game.save
if not save then return end
local buena = Gen2Save.crystalState(save).buenaPassword
if varId == VAR.BLUECARDBALANCE then
buena.balance = value
elseif varId == VAR.BUENASPASSWORD then
buena.word = value
end
end
-- ../pokecrystal/engine/events/kurt.asm:19-45 SelectApricornForKurt, whose
-- byte is what `verbosegiveitemvar <BALL>, VAR_KURT_APRICORNS` hands over.
function World:setKurtApricornQuantity(count)
local save = self.game and self.game.save
if not save then return end
save.kurtApricornQuantity = byteOf(count)
end
-- ../pokecrystal/engine/overworld/time.asm:136-142, the 3..6 day roll.
function World:setKenjiBreak(days)
local save = self.game and self.game.save
if not save then return end
Gen2Save.crystalState(save).kenjiBreak = byteOf(days)
end
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
-- GS_VERSION).
function World:gsVersion()
@@ -1622,6 +1708,11 @@ function World:engineFlag(flag)
local owned = player and player[badge.store]
return type(owned) == "table" and owned[badge.name] == true
end
-- Same one-store rule for ENGINE_PLAYER_IS_FEMALE, which IS wPlayerGender
-- (../pokecrystal/data/events/engine_flags.asm:131); Gold's FEMALE_FLAG is nil.
if flag == FieldMoves.FEMALE_FLAG then
return Gen2Save.isFemale(save)
end
-- Same one-store rule for the day care. data/events/engine_flags.asm:18-20
-- maps the three ids onto DAYCAREMAN_HAS_EGG_F / DAYCAREMAN_HAS_MON_F /
-- DAYCARELADY_HAS_MON_F, i.e. they ARE the bits DayCare_InitBreeding,
@@ -1661,6 +1752,13 @@ function World:setEngineFlag(flag, value)
save.player[badge.store][badge.name] = value and true or nil
return
end
-- InitGender is the only writer on the cart, so this exists only to keep a
-- stray setflag out of save.engineFlags (../pokecrystal/engine/menus/init_gender.asm:23-42).
if flag == FieldMoves.FEMALE_FLAG and save then
save.player = save.player or {}
save.player.gender = value and "female" or "male"
return
end
-- The write half of the day-care aliases. DayCareManScript_Outside's
-- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart
-- script that writes any of the three, and it is idempotent because
@@ -1697,6 +1795,9 @@ function World:writeVar(varId, value)
local state = PLAYER_STATE_BY_ID[value or 0]
if state then self:applyPlayerState(state) end
end
if varId >= VAR.BT_WIN_STREAK and varId <= VAR.KENJI_BREAK then
self:setCrystalVar(varId, value)
end
end
function World:battleType()
@@ -1854,6 +1955,16 @@ end
-- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) hands
-- the same slot to the Route 37 twins. So the pooled objects that read
-- through the slot have to go with it.
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1564-1575 writes
-- the sprite byte into wMapObjects, the LIVE copy, so the map def is untouched.
function World:setObjectSprite(objectId, spriteName)
local npc = self:objectEntity(objectId)
local spriteDef = spriteName and self.sprites and self.sprites[spriteName]
if not (npc and spriteDef) then return false end
if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end
return true
end
function World:setVariableSprite(slot, spriteIndex)
if slot == nil then return end
self.variableSprites[slot] = spriteIndex
@@ -2318,12 +2429,10 @@ end
-- the map pair and DAILYFLAGS1_SWARM are set by the one command. A port that
-- stored only the map would leave the Dunsparce call live forever, because
-- CheckSwarmFlag answers off the flag and clears the pair itself.
function World:setSwarm(group, mapNum)
function World:setSwarm(group, mapNum, kind)
local save = self.game and self.game.save
if not save then return end
save.dailyFlags = save.dailyFlags or {}
save.dailyFlags.swarm = true
save.swarmMap = self:mapIdByGroupMap(group, mapNum)
Roamers.Swarm.set(save, self:mapIdByGroupMap(group, mapNum), kind)
end
-- Script_loadwildmon's other half: roll the CURRENT map's own table the way a
@@ -3472,7 +3581,7 @@ function World:specialHooks()
takeItem = function(index, qty) return self:takeItem(index, qty) end,
engineFlag = function(flag) return self:engineFlag(flag) end,
setEngineFlag = function(flag, v) self:setEngineFlag(flag, v) end,
setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) end,
setSwarm = function(group, mapNum, kind) self:setSwarm(group, mapNum, kind) end,
dayCare = function(side, onDone) self:dayCare(side, onDone) end,
givePokeMail = function(mail) return self:givePokeMail(mail) end,
checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end,
@@ -3504,6 +3613,14 @@ function World:specialHooks()
magnetTrain = function(toGoldenrod, onDone)
self:magnetTrain(toGoldenrod, onDone)
end,
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:220-223
startTowerBattle = function(trainer, onDone)
return self:startBattle({ trainer = trainer, battleTower = true }, onDone)
end,
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1552-1575
setObjectSprite = function(objectId, spriteName)
return self:setObjectSprite(objectId, spriteName)
end,
pushScreen = function(id, opts) return self:pushScreen(id, opts) end,
monName = function(index)
local id, def = speciesByIndex(
@@ -3537,9 +3654,36 @@ function World:specialHooks()
self:openScriptMenu(header, "vertical", onChoose)
end,
rareWildMon = function() return self:rareWildMon() end,
-- ../pokecrystal/engine/menus/save.asm:181 AskOverwriteSaveFile and :266
-- _SaveGameData, the two halves of Link_SaveGame (:63).
saveFileState = function() return self:saveFileState() end,
writeSave = function() return self:writeSave() end,
setKurtApricornQuantity = function(n) self:setKurtApricornQuantity(n) end,
setKenjiBreak = function(days) self:setKenjiBreak(days) end,
}
end
-- AskOverwriteSaveFile's two reads: wSaveFileExists, and
-- CompareLoadedAndSavedPlayerID (../pokecrystal/engine/menus/save.asm:224),
-- which is what picks AlreadyASaveFileText over AnotherSaveFileText.
function World:saveFileState()
local save = self.game and self.game.save
local version = save and save.version
if not Gen2Save.exists(version) then return false, false end
local stored = Gen2Save.load(version)
local mine = save and save.player and save.player.id
local theirs = stored and stored.player and stored.player.id
return true, (mine ~= nil and mine == theirs)
end
-- _SaveGameData, through the writer the SAVE menu is handed
-- (src/core/Game2.lua:435) so the save.write veto holds here too.
function World:writeSave()
local game = self.game
if not (game and game.writeSave) then return false end
return game:writeSave() ~= false
end
-- RandomUnseenWildMon's lookup half. The routine picks one of the THREE
-- RAREST grass slots on the map (`and %11 / jr z` rerolls 0, so it is slots 5,
-- 6 or 7 of the seven) and drops it if that species is also one of the FOUR
@@ -4441,6 +4585,9 @@ function World:useEscapeRope(itemId)
local items = self.game and self.game.data and self.game.data.items
local def = items and items[itemId or "ESCAPE_ROPE"]
self:takeItem(def and def.index, 1)
-- ../pokecrystal/engine/events/overworld.asm:809, between .escaperope and
-- QueueScript.
UnownWords.kabutoChamber(self.events, self.map and self.map.id)
self.queuedFieldMove = {
ok = true, action = "escaperope",
destMap = destMapId, destWarp = destWarp,
@@ -5186,6 +5333,9 @@ function World:fieldContext(mon)
facingX = fx, facingY = fy,
facingColl = map:cellCollision(fx, fy),
playerColl = map:cellCollision(p.cellX, p.cellY),
-- Crystal's SurfFunction.TrySurf is the only field move that asks
-- (../pokecrystal/engine/events/overworld.asm:364).
facingObject = self:facingObject(),
upColl = map:cellCollision(p.cellX, p.cellY - 1),
tileset = map.def and map.def.tileset,
facingBlock = blockId,
@@ -5199,6 +5349,11 @@ function World:fieldContext(mon)
-- FlashFunction tests wTimeOfDayPalset, not the map header, so a
-- PALETTE_DARK map that FLASH has already lit refuses a second FLASH.
dark = Palettes.isDarkness(map.def, self:hour(), self.flashUsed),
-- ../pokecrystal/engine/events/overworld.asm:285, called by FLASH only and
-- only after the badge gate, because it SETS the wall-opened flag.
openAerodactylWall = function()
return UnownWords.aerodactylChamber(self.events, map.id)
end,
}
end
@@ -5328,12 +5483,26 @@ function World:refreshMapImages()
return true
end
-- wPlayerGender, the byte GetPlayerSprite and AddMapObject both branch on
-- (engine/overworld/overworld.asm:61-64, engine/overworld/player_object.asm:32-39).
function World:playerGender()
local save = self.game and self.game.save
return save and save.player and save.player.gender or nil
end
-- The Chris/Kris sheet the player wears with no state on it
-- (data/sprites/player_sprites.asm:2, :9).
function World:playerSpriteName()
return FieldMoves.playerSprite(self:playerGender()) or PLAYER_SPRITE
end
-- UpdatePlayerSprite (data/sprites/player_sprites.asm ChrisStateSprites): the
-- player's sprite is a pure function of wPlayerState, which is what makes
-- getting on and off a Lapras a one-byte change rather than an animation.
function World:applyPlayerState(state)
self.playerState = state or FieldMoves.PLAYER_NORMAL
local name = FieldMoves.STATE_SPRITE[self.playerState] or PLAYER_SPRITE
local name = FieldMoves.stateSprite(self.playerState, self:playerGender())
or PLAYER_SPRITE
local def = self.sprites and self.sprites[name]
if def and self.player then
self.player:setSprite(def)
@@ -5884,6 +6053,9 @@ function World:startBattle(opts, onDone)
-- wBattleType, when the script armed one: the FORCESHINY / TRAP
-- no-escape rules live in Battle:tryRun and the force-switch handler.
battleType = opts.battleType,
-- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/
-- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off.
battleTower = opts.battleTower,
})
self:playBattleMusic(opts)
local function pushBattle()
@@ -7397,26 +7569,43 @@ function World:interact()
return self:interactBody()
end
-- CheckFacingObject (engine/overworld/npc_movement.asm:229-248): "Double the
-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand
-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter
-- itself and the object is one further on. Without this the press finds an
-- empty wall and nothing happens -- which is to say no nurse and no clerk in
-- the game could be talked to at all.
--
-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events
-- and the tile-collision events still read the tile actually faced.
function World:facingObjectCell()
local p = self.player
if not p then return nil end
local d = Map.DELTA[p.facing] or Map.DELTA.down
local fx, fy = p.cellX + d[1], p.cellY + d[2]
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
return p.cellX + d[1] * 2, p.cellY + d[2] * 2
end
return fx, fy
end
-- The carry CheckFacingObject answers with: IsNPCAtCoord, and then only when
-- that object's OBJECT_WALKING reads STANDING (npc_movement.asm:250-266).
function World:facingObject()
local ox, oy = self:facingObjectCell()
if not ox then return nil end
local npc = self:npcAt(ox, oy)
if npc and npc.moving then return nil end
return npc
end
function World:interactBody()
if self:busy() or not self.player or not self.vm then return false end
local p = self.player
if p.moving then return false end
local d = Map.DELTA[p.facing]
local fx, fy = p.cellX + d[1], p.cellY + d[2]
-- CheckFacingObject (engine/overworld/npc_movement.asm:229): "Double the
-- distance for counter tiles." A Pokecenter nurse and a Mart clerk stand
-- BEHIND a COLL_COUNTER tile, so the cell the player faces is the counter
-- itself and the object is one further on. Without this the press finds an
-- empty wall and nothing happens -- which is to say no nurse and no clerk in
-- the game could be talked to at all.
--
-- Only the OBJECT lookup is doubled, exactly as the cart does it: bg events
-- and the tile-collision events below still read the tile actually faced.
local ox, oy = fx, fy
if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then
ox, oy = p.cellX + d[1] * 2, p.cellY + d[2] * 2
end
local npc = self:npcAt(ox, oy)
local npc = self:npcAt(self:facingObjectCell())
-- TryObjectEvent writes hLastTalked for EVERY A-press dispatch; scripts
-- then use LAST_TALKED (`disappear`, `applymovementlasttalked`) without any
-- setlasttalked of their own. The port only wrote it from the explicit
@@ -8510,13 +8699,13 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- GetWarpDestCoords / EnterMapConnection / EnterMapSpawnPoint write wXCoord
-- and wYCoord BEFORE HandleNewMap (data/maps/setup_scripts.asm:79-106).
local face = facing or (self.player and self.player.facing) or "down"
local chris = self.sprites and self.sprites[PLAYER_SPRITE]
local playerDef = self.sprites and self.sprites[self:playerSpriteName()]
if self.player then
self.player.cellX, self.player.cellY = cx, cy
self.player.px, self.player.py = cx * 16, cy * 16
self.player.facing = face
if chris and not self.player.sprite then
self.player:setSprite(chris)
if playerDef and not self.player.sprite then
self.player:setSprite(playerDef)
end
if not opts.seamless then
self.player.moving = false
@@ -8524,7 +8713,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
self.player.targetX, self.player.targetY = nil, nil
end
else
self.player = Player.new(cx, cy, face, chris)
self.player = Player.new(cx, cy, face, playerDef)
end
-- LoadMapObjects rebuilds OBJECT_FLAGS2 from scratch, so IN_GRASS is decided
-- by the cell the player arrives on (engine/overworld/map_objects.asm:247).
+165
View File
@@ -0,0 +1,165 @@
-- Crystal manifest shape and the specials surface the extractor pins to it.
-- ROM-free: reads tools/rom_manifest_crystal.json out of the source tree.
-- luajit tests/crystal_import_test.lua
-- Also dofile'd by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("crystal import")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Json = require("src.link.Json")
local GameVersion = require("src.core.GameVersion")
local function manifest(path)
local file = assert(io.open(path, "r"))
local data = assert(Json.decode(file:read("*a")))
file:close()
return data
end
local function size(tbl)
local n = 0
for _ in pairs(tbl or {}) do n = n + 1 end
return n
end
local crystal = manifest("tools/rom_manifest_crystal.json")
local gold = manifest("tools/rom_manifest_gold.json")
-- ------- 1. the manifest the launcher row points at
eq(GameVersion.VERSIONS.crystal.manifest, "tools/rom_manifest_crystal.json",
"the crystal row names this manifest")
eq(crystal.romSha1, GameVersion.VERSIONS.crystal.sha1,
"and the manifest carries the same sha1")
eq(crystal.generation, 2, "generation 2")
eq(crystal.format, gold.format, "same manifest format as Gold")
-- ------- 2. content counts
eq(size(crystal.maps), 388, "388 maps")
eq(size(crystal.tilesets), 36, "36 tilesets")
eq(size(crystal.pokemonAssets), 251, "251 pokemon asset rows")
eq(size(crystal.constants), 50, "50 constants keys")
eq(type(crystal.constants.engineFlagOrder), "table",
"the 50th is engineFlagOrder, the key Gold has no counterpart for")
check(size(crystal.symbols) > 2000,
("symbols table is populated (%d)"):format(size(crystal.symbols)))
check(size(crystal.charmap) > 200,
("charmap is populated (%d)"):format(size(crystal.charmap)))
check(#(crystal.fontCharmap or {}) > 200,
("fontCharmap is populated (%d)"):format(#(crystal.fontCharmap or {})))
-- pokecrystal/constants/map_constants.asm:504, pokegold:484
eq(size(gold.maps), 368, "Gold names 368")
local added, removed = 0, 0
for name in pairs(crystal.maps) do
if not gold.maps[name] then added = added + 1 end
end
for name in pairs(gold.maps) do
if not crystal.maps[name] then removed = removed + 1 end
end
eq(added, 21, "Crystal adds 21 maps")
eq(removed, 1, "and drops one")
-- pokegold/constants/map_constants.asm:152
eq(crystal.maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil,
"the one Gold map Crystal drops is ECRUTEAK_TIN_TOWER_BACK_ENTRANCE")
check(crystal.maps.BATTLE_TOWER_1F ~= nil, "and BATTLE_TOWER_1F is new")
-- ------- 3. every map row is addressable
local badMap
for name, row in pairs(crystal.maps) do
if type(row) ~= "table" then badMap = name; break end
end
eq(badMap, nil, "every map row is a table")
-- ------- 4. the text labels resolve, as they must for the Dialogue stage
local labels = (crystal.text or {}).labels or {}
check(#labels > 800, ("crystal names %d text labels"):format(#labels))
local unresolved = {}
for _, label in ipairs(labels) do
if not crystal.symbols[label] then unresolved[#unresolved + 1] = label end
end
eq(#unresolved, 0,
("every crystal text label resolves to a symbol (%s)")
:format(table.concat(unresolved, ", "):sub(1, 60)))
-- ------- 5. specials: constants.specialOrder against the handler table
local Specials = require("src.script.gen2.Specials")
local order = crystal.constants.specialOrder
check(type(order) == "table", "constants.specialOrder is a list")
eq(#order, 169, "Crystal's SpecialsPointers has 169 rows")
local missing = {}
for index, name in ipairs(order) do
if not Specials.ALL[name] then
missing[#missing + 1] = ("%d (%s)"):format(index - 1, name)
end
end
eq(#missing, 0,
("every crystal special has a handler (%s)")
:format(table.concat(missing, ", "):sub(1, 80)))
local goldOrder = gold.constants.specialOrder
local goldMissing = {}
for index, name in ipairs(goldOrder or {}) do
if not Specials.ALL[name] then
goldMissing[#goldMissing + 1] = ("%d (%s)"):format(index - 1, name)
end
end
eq(#goldMissing, 0,
("every gold special has a handler too (%s)")
:format(table.concat(goldMissing, ", "):sub(1, 80)))
local sameOrder = #order == #(goldOrder or {})
if sameOrder then
for index, name in ipairs(order) do
if goldOrder[index] ~= name then sameOrder = false; break end
end
end
check(not sameOrder, "the Crystal and Gold special orders are not the same")
-- ------- 6. handler bookkeeping
local overlap = {}
for name in pairs(Specials.STUBS) do
if Specials.HANDLERS[name] then overlap[#overlap + 1] = name end
end
eq(#overlap, 0,
("HANDLERS and STUBS are disjoint (%s)"):format(table.concat(overlap, ", ")))
local unexplained = {}
for name in pairs(Specials.STUBS) do
local reason = (Specials.STUB_REASONS or {})[name]
if type(reason) ~= "string" or reason == "" then
unexplained[#unexplained + 1] = name
end
end
eq(#unexplained, 0,
("every stub records a reason (%s)")
:format(table.concat(unexplained, ", "):sub(1, 80)))
-- ------- 7. the Crystal-only assets the completeness gate pins
local importerSource = assert(io.open("src/import/RomImporter.lua", "r"))
local importerText = importerSource:read("*a")
importerSource:close()
for _, path in ipairs({
"assets/generated/title/crystal_logo.png",
"assets/generated/title/crystal_wordmark.png",
"assets/generated/title/crystal_suicune.png",
"assets/generated/splash/ditto.png",
"assets/generated/intro/chris.png",
"assets/generated/intro/kris.png",
}) do
check(importerText:find(path, 1, true) ~= nil,
"RomImporter still requires " .. path)
end
S.finish()
+158
View File
@@ -0,0 +1,158 @@
-- Crystal world data: the roamer roster and swarm pairs that differ from
-- Gold, then the facts only a real Crystal cache can answer.
-- Self-contained: `luajit tests/crystal_world_test.lua`; also dofile'd by
-- tests/run_tests.lua. The cache half SKIPs when no crystal cache is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("crystal world")
local check, eq = S.check, S.eq
local Permissions = require("src.world.gen2.Permissions")
local Roamers = require("src.core.gen2.Roamers")
local Swarm = Roamers.Swarm
-- ---------------------------------------------------------------- ROM-free
check(Permissions.isWalkable(0x00), "COLL_FLOOR walkable")
check(not Permissions.isWalkable(0x07), "COLL_WALL blocked")
-- pokecrystal/constants/collision_constants.asm:19
check(Permissions.isGrass(0x18), "COLL_TALL_GRASS is grass")
check(Permissions.isWater(0x29), "COLL_WATER is water")
check(Permissions.isWarpCollision(0x71), "COLL_DOOR is a warp")
check(Permissions.isLedge(0xa0), "COLL_HOP_DOWN is a ledge")
eq(Permissions.of(0xff), Permissions.WALL, "a missing coll reads as wall")
local bare = {}
Roamers.init(bare)
eq(#bare.roamers, 3, "no cache -> the three-beast Gold roster")
check(Roamers.SPECIES ~= nil and #Roamers.SPECIES == 3,
"Roamers.SPECIES survives as the ROM-free fallback")
-- pokecrystal/constants/script_constants.asm:256-257
local pair = {}
Swarm.set(pair, "DARK_CAVE_VIOLET_ENTRANCE", 0)
Swarm.set(pair, "ROUTE_35", 1)
eq(pair.swarmMaps.DUNSPARCE, "DARK_CAVE_VIOLET_ENTRANCE", "SWARM_DUNSPARCE key")
eq(pair.swarmMaps.YANMA, "ROUTE_35", "SWARM_YANMA key, independently")
eq(Swarm.onMap(pair, "ROUTE_35"), "YANMA", "the Yanma map answers YANMA")
eq(Swarm.mapId(pair, "YANMA"), "ROUTE_35", "mapId takes a kind")
eq(Swarm.mapId(pair), "DARK_CAVE_VIOLET_ENTRANCE", "and defaults to Gold's key")
Swarm.timeEvents(pair, 100)
check(Swarm.timeEvents(pair, 101), "the next day ends the swarm")
eq(Swarm.onMap(pair, "ROUTE_35"), nil, "and both pairs are stranded")
local legacy = { swarmMap = "ROUTE_35", dailyFlags = { swarm = true } }
eq(Swarm.mapId(legacy), "ROUTE_35", "an old scalar save answers mapId")
eq(Swarm.onMap(legacy, "ROUTE_35"), "DUNSPARCE", "normalized onto the Gold key")
eq(Swarm.onMap(legacy, "ROUTE_36"), nil, "another map is not the swarm map")
check(Swarm.entry(legacy, { swarmGrass = { ROUTE_35 = { "row" } }, grass = {} },
"ROUTE_35", "grass") ~= nil, "and its swarm table still resolves")
-- ------------------------------------------------------------ cache-gated
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local mapsPath = cache .. "/data/generated/maps.lua"
local mapsFile = io.open(mapsPath, "r")
if not mapsFile then
check(true, "crystal cache absent : SKIP")
S.finish()
return
end
mapsFile:close()
local function loadLua(rel)
local chunk = loadfile(cache .. "/" .. rel)
if not chunk then return nil end
local ok, value = pcall(chunk)
return ok and value or nil
end
local maps = loadLua("data/generated/maps.lua")
local encounters = loadLua("data/generated/encounters.lua")
local tilesets = loadLua("data/generated/tilesets.lua")
local constants = loadLua("data/generated/constants.lua")
check(maps ~= nil, "maps.lua loads")
check(encounters ~= nil, "encounters.lua loads")
check(tilesets ~= nil, "tilesets.lua loads")
check(constants ~= nil, "constants.lua loads")
-- ---- 1. the maps Phase 1 has to reach
local count = 0
for _ in pairs(maps or {}) do count = count + 1 end
eq(count, 388, "the cache carries 388 maps")
for _, id in ipairs({
"NEW_BARK_TOWN", "PLAYERS_HOUSE_1F", "PLAYERS_HOUSE_2F", "ELMS_LAB",
"ROUTE_29", "CHERRYGROVE_CITY", "ROUTE_30", "ROUTE_31", "VIOLET_CITY",
"SPROUT_TOWER_1F", "VIOLET_GYM",
}) do
check(maps[id] ~= nil, "the New Bark to Violet route has " .. id)
end
check(maps.BATTLE_TOWER_1F ~= nil, "and the Crystal-only Battle Tower")
-- pokegold/constants/map_constants.asm:152
eq(maps.ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, nil,
"the one map Crystal drops is absent")
-- ---- 2. roamers (CT-4)
check(type(encounters.roamMons) == "table",
"the cache emits encounters.roamMons")
eq(#encounters.roamMons, 2, "Crystal seeds two beasts, not three")
eq(encounters.roamMons[1].species, "RAIKOU", "slot 1 is Raikou")
eq(encounters.roamMons[1].map, "ROUTE_42", "on Route 42")
eq(encounters.roamMons[2].species, "ENTEI", "slot 2 is Entei")
eq(encounters.roamMons[2].map, "ROUTE_37", "on Route 37")
eq(encounters.roamMons[1].level, 40, "both start at level 40")
eq(encounters.roamMons[2].level, 40, "both start at level 40")
local save = {}
Roamers.init(save, { encounters = encounters })
eq(#save.roamers, 2, "Roamers.init over the cache builds two slots")
eq(save.roamers[1].species, "RAIKOU", "slot 1 Raikou")
eq(save.roamers[2].species, "ENTEI", "slot 2 Entei")
eq(save.roamers[3], nil, "and no Suicune slot")
eq(save.roamers[1].hp, 0, "hp is zeroed so stats regenerate on contact")
local data = {}
Roamers.init(data, { data = { gen2Encounters = encounters } })
eq(#data.roamers, 2, "the opts.data path resolves the same roster")
eq(Roamers.checkEncounter(save, "ROUTE_38", false, function() return 3 end), nil,
"the Suicune slot roll finds nothing on a Crystal save")
local hit = Roamers.checkEncounter(save, "ROUTE_42", false, function() return 1 end)
check(hit and hit.species == "RAIKOU", "the Raikou roll still hits")
-- ---- 3. the swarm tables the kind byte indexes
check(encounters.swarmGrass ~= nil, "the cache carries swarmGrass")
check(encounters.swarmGrass.DARK_CAVE_VIOLET_ENTRANCE ~= nil,
"with the Dunsparce table")
check(encounters.swarmGrass.ROUTE_35 ~= nil, "and the Yanma table")
-- ---- 4. the tileset palette-map bank
-- pokecrystal.sym 13:40e5 TilesetJohtoPalMap (pokegold.sym 02:40c7)
local johto = (tilesets or {}).TILESET_JOHTO
check(johto ~= nil, "TILESET_JOHTO is in the cache")
eq(johto and johto.palMap and johto.palMap.bank, 0x13,
"TilesetJohtoPalMap is read out of bank $13")
eq(johto and johto.palMap and johto.palMap.address, 0x40e5,
"at $40e5")
local kanto = (tilesets or {}).TILESET_KANTO
eq(kanto and kanto.palMap and kanto.palMap.bank, 0x13,
"TilesetKantoPalMap is bank $13 too")
eq(kanto and kanto.palMap and kanto.palMap.address, 0x4075, "at $4075")
check(johto and johto.tilePalettes and #johto.tilePalettes > 0,
"and the map decoded to a per-tile palette list")
-- ---- 5. the special table the cache pins
eq(#(constants.specialOrder or {}), 169,
"the cache's specialOrder has Crystal's 169 rows")
S.finish()
@@ -0,0 +1,173 @@
-- The Battle Tower lobby, driven in a real game.
--
-- POKEPORT_IDENTITY=lead-card POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower \
-- POKEPORT_DRIVER=tests/drivers/crystal_battle_tower_shots.lua love .
--
-- Three sections, all on maps/BattleTower1F.asm's own extracted script:
-- A an illegal party, so _CheckForBattleTowerRules prints its refusals
-- B a legal party through Menu_ChallengeExplanationCancel and the room menu
-- C the walk to the elevator the chosen room starts
--
-- Section B overrides the TryQuickSave stub for the length of the run:
-- src/script/gen2/Specials.lua:2516 answers 0, and BattleTower1F.asm:84-85
-- backs out of the whole challenge on that, so the desk cannot be walked
-- past without it.
local U = require("tests.drivers.util")
local BattleTower = require("src.core.gen2.BattleTower")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
local Vm = require("src.script.gen2.Vm")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
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 4)
end
local function top() return game.stack:top() end
local function isMenu() return getmetatable(top()) == ScriptMenu end
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local function party(spec)
local list = {}
for _, row in ipairs(spec) do
local m = Mon.new(game.data, row[1], row[2])
m.item = row[3]
list[#list + 1] = m
end
game.save.party = list
end
local function enterLobby()
while top() do tap("b", 2) end
assert(world:setMap("BATTLE_TOWER_1F", 7, 7, "up"), "no BATTLE_TOWER_1F")
U.wait(40)
end
-- maps/BattleTower1F.asm:55-73: A through the welcome, NO to the
-- explanation offer, and the menu is the next command.
local function talkToMenu(limit)
for _ = 1, limit or 200 do
if isMenu() then return true end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
return false
end
say("--- A: an illegal party at the desk")
party({ { "TYPHLOSION", 20 } })
enterLobby()
shot("lobby")
ok(talkToMenu(), "the receptionist reaches Menu_ChallengeExplanationCancel")
shot("challenge-menu")
ok(isMenu() and #top().items == 3, "three rows: Challenge/Explanation/Cancel")
tap("a", 6)
local pages = {}
for _ = 1, 40 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
shot("rules-" .. #pages)
end
if not world:busy() and top() == nil then break end
tap("a", 4)
end
local joined = table.concat(pages, " | ")
say("pages: " .. joined)
ok(joined:find("You're not ready", 1, true) ~= nil,
"_ExcuseMeYoureNotReadyText printed")
ok(joined:find("Only three", 1, true) ~= nil,
"_OnlyThreeMonMayBeEnteredText printed")
ok(joined:find("Please return when", 1, true) ~= nil,
"_BattleTowerReturnWhenReadyText printed")
say("--- B: a legal party, the room menu, the walk out")
local savedQuickSave = Vm.SPECIALS.TryQuickSave
Vm.SPECIALS.TryQuickSave = function(vm) vm.scriptVar = 1 end
party({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20, "BERRY" },
{ "MEGANIUM", 20, "GOLD_BERRY" } })
enterLobby()
ok(talkToMenu(), "the desk reaches the menu again")
tap("a", 6)
local sawSavePrompt = false
for _ = 1, 120 do
if isRoomMenu() then break end
if world.choicebox then
sawSavePrompt = true
shot("save-prompt")
tap("a", 6)
else
tap("a", 4)
end
end
ok(sawSavePrompt, "Text_SaveBeforeEnteringBattleRoom asked first")
ok(isRoomMenu(), "special BattleTowerRoomMenu pushed Gen2BattleTowerMenu")
shot("room-menu")
if isRoomMenu() then
local screen = top()
ok(#screen.rows == BattleTower.PRE_HOF_LEVEL_GROUPS,
"no Hall of Fame, so four rooms are offered")
-- L:10 with an L20 party is the refusal at mobile_46.asm:3915-3917.
tap("a", 6)
ok(screen.phase == "message", "picking L:10 prints the level refusal")
shot("tops-this-level")
for _ = 1, 150 do
if screen.phase == "pick" then break end
U.wait(1)
end
ok(screen.phase == "pick", "and the menu comes back after the hold")
tap("up", 6)
shot("room-menu-l20")
tap("a", 6)
end
for _ = 1, 240 do
if not world:busy() and top() == nil then break end
tap("a", 4)
end
local tower = BattleTower.state(game.save)
ok(tower.levelGroup ~= nil, "the level group survived the menu")
say("levelGroup=" .. tostring(game.world.vm and game.world.vm.btLevelGroup)
.. " reward=" .. tostring(tower.reward)
.. " challenge=" .. tostring(tower.challenge)
.. " map=" .. tostring(world.map and world.map.id))
ok(tower.reward ~= nil,
"BATTLETOWERACTION_CHOOSEREWARD banked a prize on the way out")
shot("after-desk")
U.wait(180)
shot("elevator-or-hallway")
say("map after the walk: " .. tostring(world.map and world.map.id))
Vm.SPECIALS.TryQuickSave = savedQuickSave
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+249
View File
@@ -0,0 +1,249 @@
-- Smoke: the whole Crystal boot chain, with no driver shortcut.
--
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_boot_smoke.lua love .
--
-- copyright -> GameFreak -> Crystal intro -> title -> intro menu -> NEW GAME
-- -> clock -> Oak speech -> name pick -> naming screen -> the bedroom ->
-- start menu -> outdoors -> a wild battle. Every step is asserted by the
-- class of the state on the stack, so a broken hand-off names the screen it
-- stalled on instead of just hanging.
local U = require("tests.drivers.util")
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local StartMenu = require("src.ui.gen2.StartMenu")
local TitleState = require("src.ui.gen2.TitleState")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-boot"
local log = io.open(os.getenv("POKEPORT_BOOT_LOG")
or (out .. "/boot.log"), "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
local function top()
return game.stack:top()
end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
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 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
local function pick(menu, value)
for i, item in ipairs(menu.list.items) do
if item.value == value then menu.list.index = i end
end
end
if GameVersion.get() ~= "crystal" then
bail("booted " .. tostring(GameVersion.get()) .. ", not crystal")
end
if GameVersion.engine() ~= "crystal" then
bail("engine lineage is " .. tostring(GameVersion.engine()))
end
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
U.wait(10)
if not isA(CopyrightSplash) then
bail("boot did not start at the copyright splash (top "
.. tostring(top()) .. ")")
end
say("OK copyright")
U.shot(game, out .. "/01-copyright.png")
waitFor("GameFreak presents", function() return isA(GameFreakPresents) end)
U.wait(90)
say("OK gamefreak")
U.shot(game, out .. "/02-gamefreak.png")
-- ../pokecrystal/engine/movie/intro.asm:1
waitFor("the Crystal intro", function() return isA(CrystalIntro) end)
U.wait(240)
say("OK crystal intro")
U.shot(game, out .. "/03-intro.png")
U.wait(240)
U.shot(game, out .. "/03b-intro.png")
tap("start")
waitFor("the title screen", function() return isA(TitleState) end)
U.wait(90)
say("OK title")
U.shot(game, out .. "/04-title.png")
for _ = 1, 3 do tap("start", 3) end
waitFor("the intro menu", function() return isA(MainMenu) end)
say("OK main menu")
U.shot(game, out .. "/05-mainmenu.png")
local menu = top()
pick(menu, "option")
tap("a")
waitFor("the options screen", function() return isA(OptionsMenu) end)
U.shot(game, out .. "/06-options.png")
tap("b")
waitFor("the intro menu again", function() return isA(MainMenu) end)
menu = top()
pick(menu, "new")
U.shot(game, out .. "/07-newgame.png")
tap("a")
-- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, which
-- PlayerProfileSetup runs first (engine/menus/intro_menu.asm:80-84).
waitFor("the gender screen", function() return isA(GenderSelect) end, 300)
say("OK gender select")
U.shot(game, out .. "/07b-gender.png")
tap("a")
-- ../pokecrystal/engine/menus/intro_menu.asm:628
waitFor("the clock screen", function() return isA(InitClock) end, 300)
say("OK init clock")
U.shot(game, out .. "/08-initclock.png")
for _ = 1, 60 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 300)
local oak = top()
say("OK oak speech, demo mon = " .. tostring(oak.demoSpecies))
if oak.demoSpecies ~= "WOOPER" then
bail("Oak's demo mon is " .. tostring(oak.demoSpecies)
.. ", Crystal's is WOOPER")
end
U.wait(60)
U.shot(game, out .. "/09-oak.png")
local shotDemo = false
for _ = 1, 400 do
if isA(NamePick) then break end
if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then
U.wait(45)
U.shot(game, out .. "/10-wooper.png")
shotDemo = true
end
tap("a", 2)
end
if not isA(NamePick) then
bail("Oak speech never reached the name picker (top "
.. tostring(top()) .. ")")
end
say("OK name pick" .. (shotDemo and " (demo pic captured)" or ""))
-- ../pokecrystal/engine/menus/intro_menu.asm:738 NamePlayer
waitFor("the name menu to slide in",
function() return top().slide == nil end, 240)
U.shot(game, out .. "/11-namepick.png")
local picker = top()
picker.cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
tap("a")
tap("right")
tap("a")
if naming.text ~= "AB" then
bail("typed name is " .. tostring(naming.text) .. ", expected AB")
end
say("OK naming screen")
U.shot(game, out .. "/12-naming.png")
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
for _ = 1, 600 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
if game.save.player.name ~= "AB" then
bail("player name is " .. tostring(game.save.player.name))
end
if game.world.map.id ~= "PLAYERS_HOUSE_2F" then
bail("new game started on " .. tostring(game.world.map.id)
.. ", expected the bedroom")
end
say("OK overworld, map = " .. game.world.map.id
.. ", name = " .. tostring(game.save.player.name))
U.wait(20)
U.shot(game, out .. "/13-bedroom.png")
tap("start")
waitFor("the start menu", function() return isA(StartMenu) end)
say("OK start menu")
U.shot(game, out .. "/14-startmenu.png")
tap("b")
waitFor("the overworld again", function() return top() == nil end)
if not game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") then
bail("could not load NEW_BARK_TOWN")
end
U.wait(30)
if game.world.map.id ~= "NEW_BARK_TOWN" then
bail("setMap landed on " .. tostring(game.world.map.id))
end
local tileset = game.world.map.tileset or {}
say("OK outdoors, tileset = " .. tostring(tileset.id)
.. ", tilePalettes = " .. tostring(#(tileset.tilePalettes or {})))
U.shot(game, out .. "/15-newbark.png")
local player = Mon.new(game.data, "CYNDAQUIL", 12)
if not (player and #player.moves > 0) then
bail("could not build a CYNDAQUIL from the Crystal pokemon.lua")
end
local wild = Mon.new(game.data, "WOOPER", 5)
if not wild then bail("could not build a wild WOOPER") end
game.save.party = { player }
if not game.world:startBattle({ wild = wild }) then
bail("startBattle failed")
end
U.wait(240)
say("OK battle, " .. player.species .. " L" .. player.level
.. " vs " .. wild.species .. " L" .. wild.level)
U.shot(game, out .. "/16-battle.png")
say("PASS crystal boot chain in " .. out)
if log then log:close() end
love.event.quit(0)
end
+227
View File
@@ -0,0 +1,227 @@
-- TryQuickSave and the Crystal script VARs, in a real Crystal game.
--
-- POKEPORT_IDENTITY=f2-crystal POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/f2 \
-- POKEPORT_DRIVER=tests/drivers/crystal_f2_save_and_vars.lua love .
--
-- A ../pokecrystal/maps/BattleTower1F.asm:80-86 with NO monkeypatch: the
-- desk's own `writetext / yesorno`, then `special TryQuickSave` -- the
-- overwrite prompt, the SAVING pages and a real file on disk -- and the
-- room menu it can only reach on a TRUE.
-- B ../pokecrystal/maps/RadioTower2F.asm:135-146: the correct password's
-- `readvar / addval 1 / writevar VAR_BLUECARDBALANCE`, through the real
-- World, over a save and a reload, and then BuenaPrize spending it.
local U = require("tests.drivers.util")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local BuenaPassword = require("src.ui.gen2.BuenaPassword")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
local Specials = require("src.script.gen2.Specials")
local VAR_BLUECARDBALANCE = 0x18
local VAR_BUENASPASSWORD = 0x19
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-f2"
local fails, shots = 0, 0
local function say(line)
print("[driver] " .. line)
io.stdout:flush()
end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
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 4)
end
local function top() return game.stack:top() end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
local save = game.save
ok(Specials.STUB_REASONS.TryQuickSave == nil,
"TryQuickSave is a handler, not a stub")
ok(Specials.STUB_REASONS.SampleKenjiBreakCountdown == nil,
"SampleKenjiBreakCountdown is a handler too")
--------------------------------------------------------------- section B
say("--- B: Buena's Blue Card, over a save and a reload")
ok(world:setMap("RADIO_TOWER_2F", 5, 5, "down") == true, "on RADIO_TOWER_2F")
U.wait(30)
local crystal = Save.crystalState(save)
crystal.buenaPassword.balance = 0
crystal.buenaPassword.word = 0x00
crystal.buenaPassword.day = nil
-- The three rows the correct-answer arm runs, straight off the extracted
-- opcode table, so the round trip is World:readVar -> Vm -> World:writeVar.
local function awardPoint()
local started = world.vm:start({
{ op = "readvar", var = VAR_BLUECARDBALANCE },
{ op = "addval", args = { 1 } },
{ op = "writevar", var = VAR_BLUECARDBALANCE },
})
ok(started == true, "the award rows started (vm idle)")
for _ = 1, 120 do
if not world.vm:running() then break end
U.wait(1)
end
end
for _ = 1, 3 do awardPoint() end
say("blue card balance: " .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
ok(world:readVar(VAR_BLUECARDBALANCE) == 3,
"three correct passwords are three points")
ok(crystal.buenaPassword.balance == 3, "and the points are in save.crystal")
-- The password itself is a RETVAR_ADDR_DE row too; BuenasPassword parks the
-- day's roll there through the same writevar path.
world:writeVar(VAR_BUENASPASSWORD, 0x24)
ok(world:readVar(VAR_BUENASPASSWORD) == 0x24, "wBuenasPassword reads back")
-- The map load that used to eat it: scriptVars is rebuilt empty at
-- World.lua:579.
ok(world:setMap("RADIO_TOWER_1F", 5, 5, "down") == true, "walked downstairs")
U.wait(20)
ok(world:readVar(VAR_BLUECARDBALANCE) == 3, "a map load does not eat it")
ok(game:writeSave() ~= false, "saved with the balance on it")
local reloaded = Save.load(save.version)
local reloadedBalance = reloaded and reloaded.crystal
and reloaded.crystal.buenaPassword
and reloaded.crystal.buenaPassword.balance
say("reloaded blue card balance: " .. tostring(reloadedBalance))
ok(reloadedBalance == 3, "and it survives a save and a reload")
ok(reloaded and reloaded.crystal.buenaPassword.word == 0x24,
"so does the password")
-- BuenaPrize, which could never dispense anything on a balance of 0.
-- data/items/buena_prizes.asm's first row is ULTRA_BALL at 2 points.
save.inventory = save.inventory or {}
save.inventory.ULTRA_BALL = nil
local prizeIndex
for index, name in ipairs(world.vm.specialOrder or {}) do
if name == "BuenaPrize" then prizeIndex = index - 1 break end
end
ok(prizeIndex ~= nil, "BuenaPrize is in constants.specialOrder")
world.vm:start({ { op = "special", id = prizeIndex } })
local sawPrizeMenu = false
for _ = 1, 300 do
if not world.vm:running() and top() == nil then break end
if getmetatable(top()) == BuenaPassword then
if not sawPrizeMenu then
sawPrizeMenu = true
say("prize menu balance shown: " .. tostring(top().balance))
shot("prize-menu")
tap("a", 6) -- ULTRA BALL
else
tap("b", 6) -- second pass: leave the shop
end
else
tap("a", 4)
end
end
ok(sawPrizeMenu, "BuenaPrize opened its list")
say("ULTRA_BALL x" .. tostring(save.inventory.ULTRA_BALL)
.. " balance=" .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
ok((save.inventory.ULTRA_BALL or 0) >= 1, "the prize was handed over")
ok(world:readVar(VAR_BLUECARDBALANCE) == 1, "and two points were spent")
shot("after-prize")
--------------------------------------------------------------- section A
say("--- A: the Battle Tower desk, with the real TryQuickSave")
-- A file has to already exist for AskOverwriteSaveFile to have anything to
-- ask about (`ld a, [wSaveFileExists] / and a / jr z, .erase`).
ok(game:writeSave() ~= false, "a first save is on disk")
ok(Save.exists(save.version), "Save.exists agrees")
save.party = {}
for _, row in ipairs({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20 },
{ "MEGANIUM", 20 } }) do
local mon = Mon.new(game.data, row[1], row[2])
save.party[#save.party + 1] = mon
end
for _ = 1, 40 do
if top() == nil then break end
tap("b", 2)
end
ok(world:setMap("BATTLE_TOWER_1F", 7, 7, "up") == true, "on BATTLE_TOWER_1F")
U.wait(40)
shot("tower-lobby")
local function isMenu() return getmetatable(top()) == ScriptMenu end
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
local reached = false
for _ = 1, 200 do
if isMenu() then reached = true break end
if world.choicebox then tap("b", 4) else tap("a", 4) end
end
ok(reached, "the receptionist reaches Menu_ChallengeExplanationCancel")
tap("a", 6) -- CHALLENGE
-- Two yes/no prompts now, not one: Text_SaveBeforeEnteringBattleRoom and
-- then AskOverwriteSaveFile's own, which the stub never asked.
local prompts, pages = 0, {}
for _ = 1, 240 do
if isRoomMenu() then break end
if world.choicebox then
prompts = prompts + 1
shot("prompt-" .. prompts)
say("prompt " .. prompts .. ": " .. tostring(world.lastText))
tap("a", 6)
else
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
end
tap("a", 4)
end
end
say("pages: " .. table.concat(pages, " | "))
ok(prompts >= 2,
"AskOverwriteSaveFile asked its own question (" .. prompts .. " prompts)")
local joined = table.concat(pages, " | ")
ok(joined:find("SAVING", 1, true) ~= nil, "the SAVING page was shown")
ok(joined:find("saved", 1, true) ~= nil, "and SavedTheGameText")
ok(isRoomMenu(), "TryQuickSave answered TRUE and the room menu opened")
shot("room-menu")
local onDisk = Save.load(save.version)
ok(onDisk ~= nil, "the challenge's own save reached disk")
ok(onDisk and onDisk.position and onDisk.position.map == "BATTLE_TOWER_1F",
"and it was written from the lobby: "
.. tostring(onDisk and onDisk.position and onDisk.position.map))
-- The room menu's own header is STATICMENU_DISABLE_B, so the way out of the
-- desk is forward: pick a room and let the walk to the elevator finish.
for _ = 1, 400 do
if not world:busy() and top() == nil then break end
tap("a", 4)
end
say("map after the desk: " .. tostring(world.map and world.map.id))
for _ = 1, 40 do
if top() == nil then break end
game.stack:pop()
end
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+260
View File
@@ -0,0 +1,260 @@
-- U2 verification: ENGINE_PLAYER_IS_FEMALE end to end, plus the four gendered
-- pictures and the Crystal surf refusal. Not a suite; a shot harness.
--
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 POKEPORT_GENDER=girl \
-- POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_flag_shots.lua love .
local U = require("tests.drivers.util")
local BattleState = require("src.ui.gen2.BattleState")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local MainMenu = require("src.ui.gen2.MainMenu")
local Mon = require("src.battle.gen2.Mon")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local Screens = require("src.ui.Screens")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/u2-gender"
local want = (os.getenv("POKEPORT_GENDER") or "girl"):lower()
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[u2] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function done(code)
if log then log:close() end
love.event.quit(code)
if code ~= 0 then error("driver failed", 0) end
coroutine.yield()
end
local function bail(reason)
say("FAIL " .. reason)
done(1)
end
local function top() return game.stack:top() end
local function isA(class)
local s = top()
return s ~= nil and getmetatable(s) == class
end
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 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return true end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
say("fixes.surfOntoNpc=" .. tostring(GameVersion.fixes().surfOntoNpc))
------------------------------------------------------------------ new game
U.wait(10)
for _ = 1, 1200 do
if isA(MainMenu) then break end
tap("start", 2)
end
waitFor("the main menu", function() return isA(MainMenu) end, 600)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
local gendered = false
for _ = 1, 400 do
if isA(GenderSelect) then gendered = true break end
if isA(OakSpeech) or isA(NamePick) then break end
U.wait(1)
end
if GameVersion.engine() == "crystal" then
if not gendered then bail("Crystal never offered InitGender") end
local pick = top()
pick.cursor = (want == "girl") and 2 or 1
U.shot(game, out .. "/00-gender.png")
say("OK gender screen, choosing " .. want)
tap("a")
else
if gendered then bail("Gold offered a gender prompt; it has none") end
say("OK no gender prompt on " .. GameVersion.get())
end
for _ = 1, 900 do
if game.phase == "play" and game.world and game.world.map then break end
if isA(NamingScreen) then
local naming = top()
naming.row = naming:bottomRow()
naming.col = 6
end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
local world = game.world
local save = game.save
say("OK overworld, name=" .. tostring(save.player.name)
.. " gender=" .. tostring(save.player.gender)
.. " sprite=" .. tostring(world:playerSpriteName()))
------------------------------------------------------ the engine flag itself
local id = FieldMoves.FEMALE_FLAG
say("FieldMoves.FEMALE_FLAG=" .. tostring(id))
if GameVersion.engine() == "crystal" then
if id ~= 99 then bail("female flag id is " .. tostring(id) .. ", want 99") end
local reads = world:engineFlag(id)
say("world:engineFlag(" .. id .. ")=" .. tostring(reads))
if reads ~= (want == "girl") then
bail("checkflag ENGINE_PLAYER_IS_FEMALE read " .. tostring(reads))
end
elseif id ~= nil then
bail("Gold bound a female flag id: " .. tostring(id))
end
------------------------------------------------------------- the three sites
local sites = {
{ "COPYCATS_HOUSE_2F", 3, 4, "up", "01-copycat" },
{ "ROUTE_37", 5, 9, "up", "02-route37" },
{ "POKECENTER_2F", 5, 5, "up", "03-pokecenter2f" },
}
for _, site in ipairs(sites) do
local mapId, x, y, facing, name = site[1], site[2], site[3], site[4], site[5]
if world.maps and world.maps[mapId] then
world:setMap(mapId, x, y, facing)
U.wait(40)
U.shot(game, out .. "/" .. name .. ".png")
local names = {}
for _, npc in ipairs(world.npcs or {}) do
names[#names + 1] = tostring(npc.def and npc.def.sprite)
end
say(name .. " map=" .. tostring(world.map.id)
.. " objects=" .. table.concat(names, ","))
else
say("SKIP " .. mapId .. ": not in this cache")
end
end
------------------------------------------------------------------- the pack
world:setMap("NEW_BARK_TOWN", 13, 6, "down")
U.wait(20)
save.inventory = save.inventory or {}
save.inventory.POTION = 3
Screens.push(game, "Gen2PackMenu", { save = save, world = {},
onClose = function() game.stack:pop() end })
U.wait(20)
U.shot(game, out .. "/04-pack.png")
local packGfx = top() and top().gfx
local pals = packGfx and packGfx.gfx and packGfx.gfx.palettes
local menuGfx = game.data.gen2MenuGfx or {}
local femalePals = menuGfx.pack and menuGfx.pack.palettesFemale
say("pack palettes are the female set: "
.. tostring(pals ~= nil and pals == femalePals))
tap("b")
U.wait(10)
while top() do game.stack:pop() end
------------------------------------------------------------ battle back pic
local hud = menuGfx.battleHud or {}
say("battleHud.playerBack=" .. tostring(hud.playerBack)
.. " playerBackFemale=" .. tostring(hud.playerBackFemale))
save.party = { Mon.new(game.data, "CYNDAQUIL", 12) }
world:startBattle({ wild = Mon.new(game.data, "WOOPER", 5) })
local battle
for _ = 1, 600 do
if getmetatable(top()) == BattleState then battle = top() break end
U.wait(1)
end
if not battle then bail("startBattle never reached the battle screen") end
-- showPlayerTrainer is only true until SendOutPlayerMon; hold it so the
-- back pic is what the shot catches.
for _ = 1, 90 do
battle.showPlayerTrainer = true
U.wait(1)
end
battle.showPlayerTrainer = true
U.shot(game, out .. "/05-battle-backpic.png")
say("battle backpic path=" .. tostring(battle.playerBackPath))
while top() do game.stack:pop() end
U.wait(10)
------------------------------------------------------------- trainer card
Screens.push(game, "Gen2TrainerCard", { onClose = function()
game.stack:pop() end })
U.wait(30)
U.shot(game, out .. "/06-trainercard.png")
say("trainer card female arm=" .. tostring(top() and top().female))
tap("b")
U.wait(10)
while top() do game.stack:pop() end
------------------------------------------------------------- hall of fame
save.hallOfFame = nil
Screens.push(game, "Gen2HallOfFame", {
save = save, mode = "induct", text = world.text,
entry = { winCount = 1, mons = { { species = "CYNDAQUIL", level = 12,
nickname = "CYNDAQUIL", otId = 1234, gender = "male" } } },
onDone = function() game.stack:pop() end })
U.wait(20)
local hof = top()
if getmetatable(hof) ~= HallOfFame then
bail("Gen2HallOfFame did not push (top is " .. tostring(hof) .. ")")
end
-- The ceremony walks its own phases; freeze it so the two player cards can
-- be posed and shot.
hof.update = function() end
hof.scx, hof.scy = 0, 0
hof.phase = "player"
U.shot(game, out .. "/07-hof-trainerpic.png")
hof.phase = "playerBack"
U.shot(game, out .. "/08-hof-backpic.png")
say("hof backpic=" .. tostring(hof.playerBackPath)
.. " trainerPic=" .. tostring(hof.trainerPicPath))
while top() do game.stack:pop() end
U.wait(10)
------------------------------------------------------------- surf onto NPC
--
-- SurfFunction.TrySurf with a facing object: Crystal refuses, Gold does not
-- (../pokecrystal/engine/events/overworld.asm:364).
local ctx = {
save = { player = { badges = { FOG = true } } },
mon = { species = "LAPRAS" },
facing = "down",
facingColl = 0x29,
playerColl = 0x00,
playerState = FieldMoves.PLAYER_NORMAL,
facingObject = { id = "an NPC standing on the water" },
}
local refused = FieldMoves.surfFromMenu(ctx)
say("surf onto an NPC: ok=" .. tostring(refused.ok)
.. " text=" .. tostring(refused.text))
local shouldRefuse = GameVersion.fixes().surfOntoNpc == true
if (refused.ok ~= true) ~= shouldRefuse then
bail("surf-onto-NPC answered ok=" .. tostring(refused.ok)
.. " but fixes().surfOntoNpc=" .. tostring(shouldRefuse))
end
ctx.facingObject = nil
if not FieldMoves.surfFromMenu(ctx).ok then
bail("open water refused SURF")
end
say("PASS " .. GameVersion.get() .. " / " .. want .. " in " .. out)
done(0)
end
+234
View File
@@ -0,0 +1,234 @@
-- Crystal's InitGender screen, end to end, and the six things that follow from
-- the answer. POKEPORT_GENDER picks the arm: "girl", "boy" or "none" (Gold,
-- which must never see the screen at all).
--
-- POKEPORT_IDENTITY=<sandbox> POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
-- POKEPORT_GENDER=girl POKEPORT_SHOT_DIR=<dir> \
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_shots.lua love .
local U = require("tests.drivers.util")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local NamePick = require("src.ui.gen2.NamePick")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local TrainerCard = require("src.ui.gen2.TrainerCard")
local FieldMoves = require("src.world.gen2.FieldMoves")
local GameVersion = require("src.core.GameVersion")
local Screens = require("src.ui.Screens")
return function(game)
local want = os.getenv("POKEPORT_GENDER") or "girl"
local out = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/crystal-gender-" .. want)
local log = io.open(out .. "/driver.log", "w")
local function say(line)
print("[driver] " .. line)
if log then log:write(line .. "\n"); log:flush() end
end
local function bail(reason)
say("FAIL " .. reason)
if log then log:close() end
love.event.quit(1)
error(reason, 0)
end
local function top() return game.stack:top() end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
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 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
U.wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
local function eq(got, wanted, label)
if got ~= wanted then
bail(("%s: got %s, want %s"):format(label, tostring(got),
tostring(wanted)))
end
say("OK " .. label .. " = " .. tostring(got))
end
say("version=" .. tostring(GameVersion.get())
.. " engine=" .. tostring(GameVersion.engine())
.. " gender=" .. want)
-- Straight to the intro menu; the boot chain itself is crystal_boot_smoke's.
for _ = 1, 900 do
if isA(MainMenu) then break end
tap("start", 2)
end
if not isA(MainMenu) then
for _ = 1, 400 do
if isA(MainMenu) then break end
tap("a", 2)
end
end
waitFor("the intro menu", function() return isA(MainMenu) end)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
tap("a")
if want == "none" then
-- Gold and Silver: PlayerProfileSetup has no InitGender to farcall.
waitFor("the clock screen", function() return isA(InitClock) end, 400)
if isA(GenderSelect) then bail("Gold put up the gender screen") end
say("OK no gender screen; NEW GAME went straight to the clock")
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
for _, step in ipairs(speech.steps or {}) do
if step.kind == "gender" then bail("Gold's speech grew a gender beat") end
end
eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock",
"the first beat")
U.wait(40)
U.shot(game, out .. "/01-oak.png")
eq(game.save.player.gender, "male", "save.player.gender")
say("PASS gold has no gender prompt")
if log then log:close() end
love.event.quit(0)
return
end
waitFor("the gender screen", function() return isA(GenderSelect) end, 600)
local gender = top()
U.wait(20)
say("OK gender screen, cursor = " .. tostring(gender.cursor))
U.shot(game, out .. "/01-genderselect.png")
if want == "girl" then
tap("down")
U.wait(10)
U.shot(game, out .. "/02-genderselect-girl.png")
else
U.shot(game, out .. "/02-genderselect-boy.png")
end
tap("a")
waitFor("the clock screen", function() return isA(InitClock) end, 400)
eq(game.save.player.gender, want == "girl" and "female" or "male",
"wPlayerGender after the answer")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"NamePlayer's InitName default")
for _ = 1, 200 do
if isA(OakSpeech) then break end
tap("a", 2)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
eq(speech.steps[1].id, "gender_select", "the beat the speech opened on")
local shotPic = false
for _ = 1, 500 do
if isA(NamePick) then break end
if not shotPic and isA(OakSpeech) then
local at = top().steps and top().steps[top().step]
if at and at.id == "ask_player_name" then
U.wait(45)
local wanted = (want == "girl") and top().playerPicFemale
or top().playerPic
if top().pic ~= wanted then
bail("DrawIntroPlayerPic put up the wrong pic")
end
U.shot(game, out .. "/03-intropic.png")
shotPic = true
end
end
tap("a", 2)
end
waitFor("the name picker", function() return isA(NamePick) end, 200)
waitFor("the name menu to slide in",
function() return top().slide == nil end, 240)
local picker = top()
eq(picker.items[2], want == "girl" and "KRIS" or "CHRIS", "preset row 1")
eq(picker.items[3], want == "girl" and "AMANDA" or "MAT", "preset row 2")
U.shot(game, out .. "/04-namepick.png")
picker.cursor = 1
tap("a")
waitFor("the naming screen", function() return isA(NamingScreen) end)
local naming = top()
eq(naming.gender, want == "girl" and "female" or "male",
"the keyboard's header icon gender")
U.wait(20)
U.shot(game, out .. "/05-naming.png")
naming.row = naming:bottomRow()
naming.col = 6
tap("a")
for _ = 1, 700 do
if game.phase == "play" and game.world and game.world.map then break end
tap("a", 2)
end
if not (game.phase == "play" and game.world and game.world.map) then
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
end
eq(game.world.map.id, "PLAYERS_HOUSE_2F", "the bedroom")
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
"the name that reached the save")
local sprite = game.world.player and game.world.player.spriteDef
eq(sprite and sprite.id, FieldMoves.playerSprite(game.save.player.gender),
"the overworld sheet")
U.wait(20)
U.shot(game, out .. "/06-bedroom.png")
-- Walk a step so the sheet is seen mid-stride rather than only standing.
U.hold(game, "down", 20)
U.wait(20)
U.shot(game, out .. "/07-bedroom-walk.png")
-- The card, opened directly: the START-menu route is Gen2StartMenu's and is
-- already covered by crystal_boot_smoke.
game.save.player.badges = { ZEPHYR = true, HIVE = true }
local card = Screens.push(game, "Gen2TrainerCard",
{ save = game.save, onClose = function() end })
if getmetatable(card) ~= TrainerCard then bail("the card did not open") end
eq(card.female, want == "girl", "GetCardPic picked KrisCardPic")
U.wait(30)
U.shot(game, out .. "/08-trainercard.png")
tap("right")
U.wait(20)
U.shot(game, out .. "/09-trainercard-badges.png")
game.stack:pop()
local gear = Screens.push(game, "Gen2Pokegear", { save = game.save })
U.wait(30)
local pals = gear.pals and gear:pals()
local femalePals = gear.gfx and gear.gfx.palettesFemale
if want == "girl" and femalePals and pals ~= femalePals then
bail("the gear kept MalePokegearPals for Kris")
end
if want == "boy" and femalePals and pals == femalePals then
bail("the gear took FemalePokegearPals for Chris")
end
say("OK pokegear palettes = " .. (pals == femalePals and "female" or "male"))
U.shot(game, out .. "/10-pokegear.png")
game.stack:pop()
say("PASS crystal gender run (" .. want .. ") in " .. out)
if log then log:close() end
love.event.quit(0)
end
+117
View File
@@ -0,0 +1,117 @@
local U = require("tests.drivers.util")
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
local TitleState = require("src.ui.gen2.TitleState")
local INTERVAL = 20
local LIMIT = 4000
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-intro"
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "")
or INTERVAL
U.wait(30)
local assets = game.data and game.data.gen2Intro
if not (assets and assets.acts) then
U.log("SKIP no crystal intro acts in this cache -- re-import crystal")
return
end
local finished = false
local intro = CrystalIntro.new(game, {
onDone = function() finished = true end,
})
game.stack:clear()
game.stack:push(intro)
local shots, scene = 0, 0
while not finished and intro.frames < LIMIT do
U.wait(interval)
if intro.scene ~= scene then
scene = intro.scene
U.log(("scene %d at frame %d (scx=%02x scy=%02x)")
:format(scene, intro.frames, intro.scx % 256, intro.scy % 256))
end
if not finished then
U.shot(game, ("%s/intro-%04d-scene%02d.png")
:format(out, intro.frames, intro.scene))
shots = shots + 1
end
end
assert(finished,
"the movie never reached IntroScene28 (frame " .. intro.frames .. ")")
U.log(("movie done: %d shots over %d frames"):format(shots, intro.frames))
local skipped = false
local intro2 = CrystalIntro.new(game, {
onDone = function() skipped = true end,
})
game.stack:clear()
game.stack:push(intro2)
U.wait(45)
U.tap(game, "b")
U.wait(3)
assert(skipped and intro2.skipped, "a button did not skip the intro")
U.log("skip via B verified at frame " .. intro2.frames)
game:showTitle()
U.wait(1)
local title = game.stack:top()
assert(getmetatable(title) == TitleState,
"showTitle left " .. tostring(title) .. " on the stack")
assert(#title.suicuneColor == 4,
"title.lua has no 4-frame Suicune set -- stale cache?")
assert(title.entrance, "title.lua has no entrance block -- stale cache?")
assert(title.suicuneX == 48 and title.suicuneY == 96,
("Suicune is at (%s, %s), expected (48, 96)")
:format(tostring(title.suicuneX), tostring(title.suicuneY)))
for i = 1, 4 do
U.shot(game, ("%s/title-entrance-%d.png"):format(out, i))
U.wait(4)
end
for _ = 1, 90 do
if title.entranceScx == 0 then break end
U.wait(1)
end
assert(title.entranceScx == 0, "the entrance never landed")
assert(title.gemY == title.gemRestY,
("gem y %s did not land at %s with the entrance")
:format(tostring(title.gemY), tostring(title.gemRestY)))
U.wait(2)
for i = 1, 5 do
U.shot(game, ("%s/title-suicune-%d.png"):format(out, i))
U.wait(6)
end
if love.window and love.window.setMode then
for _, shape in ipairs({ { 1280, 720 }, { 1920, 500 }, { 480, 800 } }) do
love.window.setMode(shape[1], shape[2], { resizable = true })
U.wait(3)
U.shot(game, ("%s/title-wide-%dx%d.png"):format(out, shape[1], shape[2]))
end
love.window.setMode(1280, 720, { resizable = true })
end
local intro3 = CrystalIntro.new(game, {})
game.stack:clear()
game.stack:push(intro3)
local function runTo(target, extra)
for _ = 1, LIMIT do
if intro3.scene >= target then break end
U.wait(5)
end
assert(intro3.scene >= target,
"widescreen pass never reached scene " .. target)
U.wait(extra)
end
runTo(4, 40)
U.shot(game, out .. "/intro-wide-scene04.png")
runTo(10, 60)
U.shot(game, out .. "/intro-wide-scene10.png")
runTo(18, 20)
U.shot(game, out .. "/intro-wide-scene18.png")
intro3:skip()
U.log(("PASS crystal intro + title shots in %s"):format(out))
end
+278
View File
@@ -0,0 +1,278 @@
-- The Crystal story specials, driven in a real game.
--
-- POKEPORT_IDENTITY=unitb-crystal POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unitb \
-- POKEPORT_DRIVER=tests/drivers/crystal_story_shots.lua love .
--
-- Four sections, each entered by putting the player on the real map and
-- letting the extracted script run:
-- A TIN_TOWER_1F the Suicune confrontation, and whether it flees
-- B ILEX_FOREST the GS Ball shrine, Celebi, and CheckCaughtCelebi
-- C DRAGON_SHRINE GiveDratini's Extremespeed moveset
-- D DAY_CARE GiveOddEgg
local U = require("tests.drivers.util")
local BattleState = require("src.ui.gen2.BattleState")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local NamingScreen = require("src.ui.gen2.NamingScreen")
local Roamers = require("src.core.gen2.Roamers")
local Save = require("src.core.gen2.Save")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-story"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
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 4)
end
local function top() return game.stack:top() end
local function battleState()
local st = top()
if st ~= nil and getmetatable(st) == BattleState then return st end
return nil
end
local function inBattle()
local st = battleState()
return st and st.battle or nil
end
local function waitFor(pred, frames)
for _ = 1, frames or 1200 do
if pred() then return true end
U.wait(1)
end
return false
end
local world
-- Mash A until the script is finished and the stack is bare again. The
-- naming screen gets B: `givepoke` ends in Specials.askNickname
-- (src/script/gen2/Specials.lua:382) and mashing A there types letters
-- forever instead of leaving.
-- The naming screen has no B exit: crystal_boot_smoke.lua leaves it by
-- putting the cursor on the bottom row's confirm cell. Confirming a blank
-- name is the cart's own "no nickname" (home/string.asm:6 _InitString).
local function mash()
local st = top()
if getmetatable(st) == NamingScreen then
st.row, st.col = st:bottomRow(), 6
end
tap("a", 2)
end
local function settle(frames)
for _ = 1, frames or 3000 do
if not world:busy() and top() == nil then return true end
mash()
end
local meta, name = getmetatable(top()), "?"
for id, mod in pairs(package.loaded) do
if mod == meta then name = id end
end
say((" stuck on %s (phase=%s) busy=%s"):format(name,
tostring(top() and top().phase), tostring(world:busy())))
return false
end
-- Stand one cell below the object carrying `scriptKey`, facing it.
local function standBelow(mapId, scriptKey)
world:setMap(mapId, 1, 1, "down")
U.wait(10)
local tx, ty
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.scriptKey == scriptKey then
tx, ty = npc.cellX, npc.cellY
end
end
if not tx then return false end
world:setMap(mapId, tx, ty + 1, "up")
U.wait(30)
return world.player.cellX == tx and world.player.cellY == ty + 1
end
local function moveIds(mon)
local list = {}
for _, move in ipairs((mon and mon.moves) or {}) do
list[#list + 1] = move.id
end
return table.concat(list, ",")
end
U.wait(60)
world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local save = game.save
save.player = save.player or {}
save.player.name = save.player.name or "CHRIS"
save.player.id = save.player.id or 30000
save.inventory = save.inventory or {}
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
-- ---- A TIN_TOWER_1F ----------------------------------------------------
-- ../pokecrystal/maps/TinTower1F.asm:84 TinTower1FSuicuneBattleScript, run
-- from the SCENE_TINTOWER1F_SUICUNE_BATTLE scene script at :22.
say("A Tin Tower")
say(" crystal AlwaysFleeMons SUICUNE = "
.. tostring(Roamers.alwaysFleeMons("crystal").SUICUNE)
.. ", gold = " .. tostring(Roamers.alwaysFleeMons("gold").SUICUNE))
world.mapScenes.TIN_TOWER_1F = 0
world:setMap("TIN_TOWER_1F", 9, 14, "up")
U.wait(20)
U.shot(game, out .. "/a1-tintower-enter.png")
ok(waitFor(function() return world:busy() end, 600),
"the scene script started on map entry")
U.wait(120)
U.shot(game, out .. "/a2-beasts.png")
ok(waitFor(function() return inBattle() ~= nil end, 3000),
"the Tin Tower scene reached a battle")
local battle = inBattle()
if battle then
U.wait(90)
U.shot(game, out .. "/a3-suicune-battle.png")
ok(battle.enemy and battle.enemy.species == "SUICUNE",
"the wild mon is SUICUNE (got " ..
tostring(battle.enemy and battle.enemy.species) .. ")")
ok((battle.enemy and battle.enemy.level) == 40, "at level 40")
-- ../pokecrystal/engine/battle/core.asm:759 TryEnemyFlee: with Suicune off
-- AlwaysFleeMons it has to survive the two random gates as well.
local fled = 0
for _ = 1, 500 do
if battle:tryEnemyFlee() then fled = fled + 1 end
end
ok(fled == 0, "Suicune never flees in 500 rolls (fled " .. fled .. ")")
ok(battle.outcome == nil, "and the battle is still live")
battle.outcome = "run"
local st = battleState()
if st then st:finishBattle() end
end
ok(settle(2000), "the Tin Tower script ran to its end")
-- ---- B ILEX_FOREST -----------------------------------------------------
-- ../pokecrystal/maps/IlexForest.asm:429 IlexForestShrineScript, a BGEVENT_UP
-- at (8,22). EVENT_FOREST_IS_RESTLESS and the GS Ball are the two gates; on
-- a retail cart neither can be reached, so both are set here the way the
-- Virtual Console wrapper sets sGSBallFlag
-- (../pokecrystal/engine/menus/save.asm:168).
say("B Ilex Forest shrine")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
Save.crystalState(save).gsBall = "have"
save.inventory.GS_BALL = 1
save.inventory.MASTER_BALL = 5
world:setMap("ILEX_FOREST", 8, 23, "up")
U.wait(40)
world.events:set(192, true)
U.shot(game, out .. "/b1-shrine.png")
tap("a", 30)
U.shot(game, out .. "/b2-shrine-prompt.png")
-- The prompt is the script's own yesorno; A takes YES.
ok(waitFor(function()
if inBattle() then return true end
tap("a", 3)
return false
end, 400), "the shrine script reached a battle")
local celebi = inBattle()
if celebi then
U.wait(90)
U.shot(game, out .. "/b3-celebi.png")
ok(celebi.enemy and celebi.enemy.species == "CELEBI",
"the wild mon is CELEBI (got " ..
tostring(celebi.enemy and celebi.enemy.species) .. ")")
ok(celebi.battleType == 11,
"the battle carries BATTLETYPE_CELEBI (got "
.. tostring(celebi.battleType) .. ")")
-- Catch it with a MASTER BALL so CheckCaughtCelebi has something to read.
local thrown = false
for _ = 1, 900 do
local st = battleState()
if not st then break end
if not thrown and st.phase == "menu" then
st:useItem("MASTER_BALL")
thrown = true
U.wait(60)
U.shot(game, out .. "/b4-masterball.png")
end
mash()
end
end
ok(settle(2000), "the shrine script ran to its end")
U.shot(game, out .. "/b5-after-celebi.png")
local caught = false
for _, mon in ipairs(save.party) do
if mon.species == "CELEBI" then caught = true end
end
ok(caught, "Celebi is in the party")
ok(Save.crystalState(save).celebiCaught == true,
"CheckCaughtCelebi recorded the catch")
ok((save.inventory.GS_BALL or 0) == 0, "the GS Ball was taken")
-- ---- C DRAGON_SHRINE ---------------------------------------------------
-- ../pokecrystal/maps/DragonShrine.asm:192 DragonShrineElder1Script, whose
-- .GiveDratini arm (:208) is `givepoke DRATINI, 15 / checkevent
-- EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG / special GiveDratini`.
say("C Dragon Shrine")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
-- ../pokecrystal/maps/DragonShrine.asm:10 SCENE_DRAGONSHRINE_NOOP; scene 0 is
-- the Dragon Master quiz cutscene, which is not what this section measures.
world.mapScenes.DRAGON_SHRINE = 1
ok(standBelow("DRAGON_SHRINE", "63:51a5"), "found the shrine elder")
U.shot(game, out .. "/c1-shrine.png")
tap("a", 30)
U.shot(game, out .. "/c2-elder.png")
ok(settle(2000), "the elder's script ran to its end")
U.shot(game, out .. "/c3-dratini.png")
local dratini
for _, mon in ipairs(save.party) do
if mon.species == "DRATINI" then dratini = mon end
end
ok(dratini ~= nil, "the elder handed over a DRATINI")
if dratini then
say(" moves = " .. moveIds(dratini))
ok(moveIds(dratini) == "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED",
"with .Moveset0, Extremespeed and all")
ok(dratini.moves[4].pp == 5, "Extremespeed arrives with 5 PP")
end
-- ---- D DAY_CARE --------------------------------------------------------
-- ../pokecrystal/maps/DayCare.asm:23 DayCareManScript_Inside, whose
-- `special GiveOddEgg` is at :33.
say("D Day Care")
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
save.inventory.EGG_TICKET = 1
ok(standBelow("DAY_CARE", "18:6f8f"), "found the Day-Care Man")
U.shot(game, out .. "/d1-daycare.png")
tap("a", 30)
U.shot(game, out .. "/d2-gramps.png")
ok(settle(2000), "the Day-Care Man's script ran to its end")
U.shot(game, out .. "/d3-oddegg.png")
local egg = save.party[2]
ok(egg ~= nil and egg.isEgg == true, "an EGG joined the party")
if egg then
say((" %s ot=%s otId=%s eggSteps=%s moves=%s"):format(
tostring(egg.species), tostring(egg.ot), tostring(egg.otId),
tostring(egg.eggSteps), moveIds(egg)))
ok(egg.ot == "ODD", "with OT ODD")
ok(egg.eggSteps == 20, "and 20 hatch cycles")
end
ok((save.inventory.EGG_TICKET or 0) == 0, "the EGG TICKET was tossed")
say(fails == 0 and ("PASS crystal story in " .. out)
or ("FAIL " .. fails .. " checks, shots in " .. out))
love.event.quit(fails == 0 and 0 or 1)
end
+277
View File
@@ -0,0 +1,277 @@
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
-- opponent draw, the walk-in, the battle, and the counter the room script
-- reads back.
--
-- POKEPORT_IDENTITY=f1-tower POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
-- POKEPORT_DRIVER=tests/drivers/crystal_tower_battle_f1.lua love .
--
-- src/world/gen2/World.lua has no startTowerBattle / setObjectSprite hook and
-- World:startBattle does not forward `battleTower` into Battle.new, so the
-- three seams are shimmed here for the length of the run. The bodies are
-- exactly what belongs in World:specialHooks and World:startBattle.
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local World = require("src.world.gen2.World")
-- The world exists before a POKEPORT_DRIVER chunk loads (main.lua:426-438),
-- so the seams go on the live instance.
local function shimWorldSeams(world)
local towerBattle = false
local baseNew = Battle.new
Battle.new = function(opts)
if towerBattle then opts.battleTower = true end
return baseNew(opts)
end
local baseStartBattle = World.startBattle
world.startBattle = function(self, opts, onDone)
towerBattle = (opts and opts.battleTower) and true or false
local started = baseStartBattle(self, opts, onDone)
towerBattle = false
return started
end
local hooks = world.vm.specials
hooks.startTowerBattle = function(trainer, onDone)
return world:startBattle({ trainer = trainer, battleTower = true }, onDone)
end
hooks.setObjectSprite = function(objectId, spriteName)
local index = (objectId or 0) - 1
local def = world.map and world.map.def
local obj = def and def.objects and def.objects[index]
local sheet = world.sprites and world.sprites[spriteName]
if not (obj and sheet) then return false end
obj.sprite = spriteName
local npc = world:objectEntity(objectId)
if npc and npc:setSpriteDef(sheet) then world:applySpritePalette(npc) end
world:rebuildPeople({ seamless = true })
return true
end
end
-- ---- the run --------------------------------------------------------------
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
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 4)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
shimWorldSeams(world)
ok(type(world.vm.specials.startTowerBattle) == "function",
"the shimmed startTowerBattle hook reached the VM")
ok(type(world.vm.specials.setObjectSprite) == "function",
"and so did setObjectSprite")
local roster = BattleTower.roster(game.data)
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
if roster then
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
roster.sampleTrainers))
end
-- Three legal mons over the L10 room, each with ONE strong move so a blind
-- A-press run cannot pick a status move for a hundred turns.
local party = {}
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
{ "FERALIGATR", 60, "SURF", "BERRY" },
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
local def = game.data.moves[row[3]]
local mon = Mon.new(game.data, row[1], row[2], {
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
})
mon.item = row[4]
party[#party + 1] = mon
end
game.save.party = party
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
-- the L10 one (battle_tower.asm:1129-1141).
local tower = BattleTower.state(game.save)
tower.levelGroup = 1
tower.streak = 0
tower.trainers = {}
tower.challenge = BattleTower.NO_CHALLENGE
while game.stack:top() do game.stack:pop() end
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
U.wait(30)
shot("room-entered")
-- The scene script walks the player in, draws the opponent and starts the
-- battle; nothing here presses anything until a text box wants it.
local sawOpponent, sawBattle = false, false
for _ = 1, 900 do
local vm = world.vm
if vm and vm.btOpponent and not sawOpponent then
sawOpponent = true
say(("opponent: %s %s (row %d), sprite %s, group %d")
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
vm.btOpponent.group))
for slot, mon in ipairs(vm.btOpponent.rows) do
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
tostring(mon.item)))
end
U.wait(20)
shot("opponent-walked-in")
end
if world.battleActive then
sawBattle = true
break
end
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
end
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
local screen, battle = nil, nil
if sawBattle then
U.wait(90)
shot("battle-open")
screen = game.stack:top()
battle = screen and screen.battle
ok(battle ~= nil, "the battle screen owns a Battle")
if battle then
ok(battle.inBattleTowerBattle == true,
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
.. " L" .. tostring(battle.enemy and battle.enemy.level))
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
"with a three-mon Tower party")
end
end
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
-- move with PP left.
local function hardestMove(mon)
local best, bestPower = 1, -1
for index, move in ipairs((mon and mon.moves) or {}) do
local def = game.data.moves and game.data.moves[move.id]
local power = (def and def.power) or 0
if (move.pp or 0) > 0 and power > bestPower then
best, bestPower = index, power
end
end
return best
end
-- PP topped up between turns: an unattended run otherwise Struggles itself
-- to death long before the third opponent mon is down.
local function refill(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
move.pp = move.maxPp or move.pp
end
end
local menus = 0
for _ = 1, 900 do
if not world.battleActive or (battle and battle.over) then break end
local phase = screen and screen.phase
if phase == "menu" then
menus = menus + 1
if menus == 1 then shot("battle-menu") end
refill(battle and battle.player)
screen.menuIndex = 1
tap("a", 4)
elseif phase == "moves" then
screen.menuIndex = hardestMove(battle and battle.player)
tap("a", 4)
elseif phase == "submenu" then
-- A forced switch cannot be cancelled, so walk off the fainted lead.
tap("down", 3)
tap("a", 4)
else
tap("a", 3)
end
end
if battle then
say("battle over=" .. tostring(battle.over)
.. " outcome=" .. tostring(battle.outcome)
.. " phase=" .. tostring(screen and screen.phase))
ok(battle.over, "the battle resolved")
ok(battle.outcome == "win", "and the L60 party won it")
shot("battle-end")
end
for _ = 1, 200 do
if not world.battleActive then break end
tap("a", 3)
end
ok(not world.battleActive, "the battle screen came down")
U.wait(30)
shot("after-battle")
local vm = world.vm
local state = BattleTower.state(game.save)
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
tostring(vm and vm.stringBuffer)))
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
"and armed sBattleTowerChallengeState")
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
ok(state.prevTeams and #state.prevTeams.prev == 3,
"and sBTMonPrevTrainer recorded the team")
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
-- second draw all follow on their own.
local pages, secondDraw = {}, nil
for _ = 1, 400 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
if #pages <= 6 then shot("page-" .. #pages) end
end
if world.battleActive then
secondDraw = world.vm and world.vm.btOpponent
break
end
if world.choicebox then tap("a", 4) else tap("a", 3) end
end
say("pages: " .. table.concat(pages, " | "))
local joined = table.concat(pages, " | ")
ok(joined:find("healed to full health", 1, true) ~= nil
or joined:find("Next up", 1, true) ~= nil,
"the room script carried on past the battle")
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
or joined:find("no.2", 1, true) ~= nil,
"and wStringBuffer3 named the SECOND opponent")
if world.battleActive then
U.wait(60)
shot("second-battle")
ok(true, "the loop came back round for opponent two")
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
end
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+182
View File
@@ -0,0 +1,182 @@
-- The four Ruins of Alph secret chambers, opened in a real game.
--
-- POKEPORT_IDENTITY=f3-crystal POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/f3 \
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_chambers.lua love .
local U = require("tests.drivers.util")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:25 closed, :42 open;
-- ../pokecrystal/engine/overworld/scripting.asm:2147 halves them to block 2,0.
local WALL_BLOCK = 3
local WALL_SHUT, WALL_OPEN = 0x2e, 0x30
local CHAMBERS = {
{
key = "OMANYTE", word = "WATER",
-- ../pokecrystal/engine/events/unown_walls.asm:25 CheckItem, :38 MON_ITEM
shut = function(game)
game.save.inventory = { FIRE_STONE = 1 }
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
{ item = "FIRE_STONE" }) }
end,
arm = function(game)
game.save.inventory = {}
game.save.party = {
Mon.new(game.data, "TYPHLOSION", 40),
Mon.new(game.data, "LANTURN", 30, { item = "WATER_STONE" }),
}
end,
armed = "a WATER STONE held by the last party mon",
},
{
key = "HO_OH", word = "HO-OH",
-- ../pokecrystal/engine/events/unown_walls.asm:2 wPartySpecies[0].
shut = function(game)
game.save.inventory = {}
game.save.party = {
Mon.new(game.data, "TYPHLOSION", 40),
Mon.new(game.data, "HO_OH", 70),
}
end,
arm = function(game)
game.save.party = {
Mon.new(game.data, "HO_OH", 70),
Mon.new(game.data, "TYPHLOSION", 40),
}
end,
armed = "HO-OH moved into the FIRST party slot",
},
{
key = "KABUTO", word = "ESCAPE",
shut = function(game)
game.save.inventory = { ESCAPE_ROPE = 1 }
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
end,
-- ../pokecrystal/engine/events/overworld.asm:809 farcall
-- SpecialKabutoChamber, off EscapeRopeOrDig's rope arm.
arm = function(_, world)
UnownWords.kabutoChamber(world.events, world.map and world.map.id)
end,
armed = "SpecialKabutoChamber, as the escape rope calls it",
},
{
key = "AERODACTYL", word = "LIGHT",
shut = function(game)
game.save.inventory = {}
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
end,
-- ../pokecrystal/engine/events/overworld.asm:285 farcall
-- SpecialAerodactylChamber, inside FlashFunction.CheckUseFlash.
arm = function(_, world)
UnownWords.aerodactylChamber(world.events, world.map and world.map.id)
end,
armed = "SpecialAerodactylChamber, as FLASH calls it",
},
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-chambers"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
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 4)
end
local function wordScreen()
local state = game.stack:top()
return (state and getmetatable(state) == UnownWords) and state or nil
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "CHRIS"
game.save.player.id = game.save.player.id or 30000
local function enter(mapId)
world:setMap("RUINS_OF_ALPH_OUTSIDE", 8, 15, "down")
U.wait(20)
world.mapScenes[mapId] = 0
world:setMap(mapId, 3, 1, "up")
for _ = 1, 400 do
U.wait(2)
if not world:busy() and not world.pendingSceneScript then break end
end
U.wait(30)
end
local function blockNow()
local blocks = world.map and world.map.def and world.map.def.blocks
return blocks and blocks[WALL_BLOCK]
end
for index, chamber in ipairs(CHAMBERS) do
local mapId = UnownWords.CHAMBER_MAPS[chamber.key]
local flag = UnownWords.WALL_OPENED[chamber.key]
say(("%d %s (flag %d)"):format(index, mapId, flag))
world.events:set(flag, false)
chamber.shut(game, world)
enter(mapId)
ok(world.map and world.map.id == mapId, " stood in the chamber")
ok(not world.events:get(flag), " the wall flag is still clear")
ok(blockNow() == WALL_SHUT,
(" and the hidden-doors callback drew the closed wall (%s)")
:format(tostring(blockNow())))
U.shot(game, ("%s/%d-%s-shut.png"):format(out, index,
chamber.key:lower()))
chamber.arm(game, world)
say(" armed: " .. chamber.armed)
world.mapScenes[mapId] = 0
enter(mapId)
ok(world.events:get(flag), " the wall flag is set now")
ok(blockNow() == WALL_OPEN,
(" and the wall-open script rewrote the block (%s)")
:format(tostring(blockNow())))
U.shot(game, ("%s/%d-%s-open.png"):format(out, index,
chamber.key:lower()))
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:82
-- RuinsOfAlphOmanyteChamberWallPatternLeft
local screen
for _ = 1, 200 do
screen = wordScreen()
if screen then break end
tap("a", 2)
end
ok(screen ~= nil, " the wall pattern reached DisplayUnownWords")
if screen then
U.wait(20)
ok(screen.wall and screen.wall.word == chamber.word,
(" showing %s (got %s)"):format(chamber.word,
tostring(screen.wall and screen.wall.word)))
U.shot(game, ("%s/%d-%s-word.png"):format(out, index,
chamber.key:lower()))
tap("a", 10)
U.wait(20)
end
end
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
U.wait(10)
love.event.quit(fails == 0 and 0 or 1)
end
+104
View File
@@ -0,0 +1,104 @@
-- The Ruins of Alph wall words and word rooms, driven in a real game.
--
-- POKEPORT_IDENTITY=unit-e-unown POKEPORT_GAME=crystal \
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unit-e \
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_words_shots.lua love .
local U = require("tests.drivers.util")
local GameVersion = require("src.core.GameVersion")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
-- maps/RuinsOfAlphKabutoChamber.asm:272 `bg_event 4, 0, BGEVENT_UP`
local CHAMBERS = {
{ map = "RUINS_OF_ALPH_KABUTO_CHAMBER", word = "ESCAPE" },
{ map = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", word = "LIGHT" },
{ map = "RUINS_OF_ALPH_OMANYTE_CHAMBER", word = "WATER" },
{ map = "RUINS_OF_ALPH_HO_OH_CHAMBER", word = "HO-OH" },
}
local WORD_ROOMS = {
{ map = "RUINS_OF_ALPH_KABUTO_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", x = 9, y = 6 },
{ map = "RUINS_OF_ALPH_HO_OH_WORD_ROOM", x = 9, y = 6 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-words"
local fails = 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
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 4)
end
local function top() return game.stack:top() end
local function wordScreen()
local state = top()
return (state and getmetatable(state) == UnownWords) and state or nil
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
local save = game.save
save.player = save.player or {}
save.player.name = save.player.name or "CHRIS"
save.player.id = save.player.id or 30000
save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
for index, chamber in ipairs(CHAMBERS) do
say("A" .. index .. " " .. chamber.map)
world:setMap(chamber.map, 4, 1, "up")
U.wait(30)
U.shot(game, ("%s/a%d-%s-chamber.png"):format(
out, index, chamber.word:lower()))
local screen
for _ = 1, 300 do
screen = wordScreen()
if screen then break end
if not world:busy() then tap("a", 2) else tap("a", 2) end
end
ok(screen ~= nil, chamber.map .. " reached DisplayUnownWords")
if screen then
U.wait(20)
ok(screen.wall and screen.wall.word == chamber.word,
(" showing %s (got %s)"):format(chamber.word,
tostring(screen.wall and screen.wall.word)))
ok(#screen.squares == #chamber.word,
(" %d letter squares"):format(#screen.squares))
U.shot(game, ("%s/a%d-%s-word.png"):format(
out, index, chamber.word:lower()))
tap("a", 10)
ok(wordScreen() == nil, " A closes the box")
U.wait(20)
end
end
for index, room in ipairs(WORD_ROOMS) do
say("B" .. index .. " " .. room.map)
world:setMap(room.map, room.x, room.y, "up")
U.wait(40)
ok(world.map and world.map.id == room.map, " stood in " .. room.map)
U.shot(game, ("%s/b%d-%s.png"):format(out, index,
room.map:lower():gsub("ruins_of_alph_", "")))
end
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
U.wait(10)
love.event.quit(fails == 0 and 0 or 1)
end
+119
View File
@@ -0,0 +1,119 @@
-- The two chamber walls the FIELD MOVES open, driven through the production
-- call sites rather than by calling the routines directly.
--
-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash
-- (badge FIRST, then SpecialAerodactylChamber) and :808-813 EscapeRopeOrDig's
-- `.escaperope` arm.
--
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_DRIVER=tests/drivers/gate_chamber_fieldmoves.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local UnownWords = require("src.world.gen2.UnownWords")
return function(game)
local fails = 0
local function say(line) print("[driver] " .. line); io.stdout:flush() end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
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 4)
end
local function clearText()
for _ = 1, 200 do
if not world:busy() then return true end
tap("a", 3)
end
return false
end
local function enter(mapId)
while game.stack:top() do game.stack:pop() end
assert(world:setMap(mapId, 3, 7, "up"), "no " .. mapId .. " in this cache")
for _ = 1, 240 do
if not world:busy() then break end
U.wait(2)
end
U.wait(10)
end
-- ---- the escape rope, in the Kabuto chamber ------------------------------
say("--- KABUTO, through World:useEscapeRope()")
enter(UnownWords.CHAMBER_MAPS.KABUTO)
ok(not UnownWords.wallOpened(world.events, "KABUTO"),
"the Kabuto wall flag starts clear")
game.save.inventory = { ESCAPE_ROPE = 1 }
-- wDigWarpNumber / wBackupMapGroup: the cave's own entrance, which
-- ../pokecrystal/engine/events/overworld.asm:795-798 copies into wNextWarp.
world.backupWarp = { map = "RUINS_OF_ALPH_OUTSIDE", warp = 1 }
clearText()
local rope = world:useEscapeRope("ESCAPE_ROPE")
say("escape rope result: " .. tostring(rope))
ok(rope == "escape_rope", "the rope was used")
ok(UnownWords.wallOpened(world.events, "KABUTO"),
"and SpecialKabutoChamber set the wall flag")
-- The rope must not open a wall anywhere else.
world.queuedFieldMove = nil
enter("RUINS_OF_ALPH_OUTSIDE")
clearText()
local before = UnownWords.wallOpened(world.events, "OMANYTE")
game.save.inventory = { ESCAPE_ROPE = 1 }
world:useEscapeRope("ESCAPE_ROPE")
ok(UnownWords.wallOpened(world.events, "OMANYTE") == before,
"a rope used outside a chamber opens nothing")
-- ---- FLASH, in the Aerodactyl chamber ------------------------------------
say("--- AERODACTYL, through World:useFieldMove(\"FLASH\")")
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
ok(world.map.id == UnownWords.CHAMBER_MAPS.AERODACTYL, "in the chamber")
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
"the wall flag starts clear")
local flash = game.data.moves.FLASH
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
{ moves = { { id = "FLASH", pp = flash.pp, maxPp = flash.pp } } }) }
-- :281-283, the ZEPHYRBADGE gate that runs BEFORE the special.
game.save.player = game.save.player or {}
game.save.player.badges = {}
local refused = world:useFieldMove("FLASH", game.save.party[1])
ok(refused and refused.ok ~= true, "no ZEPHYRBADGE: FLASH is refused")
ok(refused and refused.badge == "ZEPHYR", "on the badge, not on the map")
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
"and the badgeless press did NOT open the wall")
-- The refusal opened a text box; the world is busy until it is dismissed.
clearText()
game.save.player.badges = { ZEPHYR = true }
local used = world:useFieldMove("FLASH", game.save.party[1])
say("flash result: ok=" .. tostring(used and used.ok)
.. " action=" .. tostring(used and used.action))
ok(used and used.ok == true,
"with the badge FLASH is allowed in a chamber that is not a dark cave")
ok(UnownWords.wallOpened(world.events, "AERODACTYL"),
"and SpecialAerodactylChamber set the wall flag")
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
U.wait(40)
local _, block = world:blockIndexAt(3, 5)
say("aerodactyl block under the wall: " .. tostring(block))
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp")
.. "/01-aerodactyl-flash-open.png")
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+80
View File
@@ -0,0 +1,80 @@
-- Gate check: the Crystal wave left Gold alone. No Battle Tower, no Buena's
-- Blue Card, no Ruins of Alph secret chambers, and the three beasts roam.
--
-- POKEPORT_GAME=gold POKEPORT_VERSION=gold \
-- POKEPORT_DRIVER=tests/drivers/gate_gold_untouched.lua love .
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local Roamers = require("src.core.gen2.Roamers")
return function(game)
local fails = 0
local function say(line) print("[driver] " .. line); io.stdout:flush() end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
U.wait(60)
local world = game.world
assert(world and world.map, "gold world did not boot")
say("version=" .. tostring(game.version) .. " map=" .. tostring(world.map.id))
local order = {}
for _, name in ipairs((world.constants or {}).specialOrder or {}) do
order[name] = true
end
ok(next(order) ~= nil, "the Gold cache names its specials")
for _, name in ipairs({ "BattleTowerBattle", "BattleTowerAction",
"BattleTowerRoomMenu", "LoadOpponentTrainerAndPokemonWithOTSprite",
"BuenasPassword", "BuenaPrize", "AskRememberPassword",
"OmanyteChamber", "HoOhChamber", "CelebiShrineEvent",
"SampleKenjiBreakCountdown", "MoveTutor", "PokeSeer" }) do
ok(not order[name], "Gold has no " .. name .. " special")
end
ok(world.maps.BATTLE_TOWER_1F == nil, "no BATTLE_TOWER_1F map")
ok(world.maps.BATTLE_TOWER_BATTLE_ROOM == nil, "no tower battle room")
ok((world.trainers or {}).battleTower == nil,
"trainers.lua carries no battleTower roster")
ok((world.eventTables or {}).unownWalls == nil,
"events.lua carries no unownWalls table")
-- ../pokecrystal/constants/event_flags.asm:486-489 are Crystal-only.
for _, flag in ipairs({ 806, 807, 808, 809 }) do
ok(not world.events:get(flag), "wall-opened flag " .. flag .. " is clear")
end
-- ../pokecrystal/constants/script_constants.asm:69-74, Crystal-only VARs.
for _, id in ipairs({ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a }) do
ok(world:readVar(id) == 0,
("VAR $%02x reads 0 on Gold"):format(id))
end
-- data/wild/roammon_maps.asm: RAIKOU, ENTEI and SUICUNE, all three.
local roster = Roamers.roster(world.encounters)
say("roamer roster: " .. #roster .. " rows")
for _, row in ipairs(roster) do
say((" %s L%s start=%s"):format(tostring(row.species),
tostring(row.level), tostring(row.map)))
end
ok(#roster == 3, "three beasts in the roster")
local list = Roamers.init(game.save, { encounters = world.encounters, force = true })
local live = 0
for _, slot in ipairs(list or {}) do
if Roamers.active(slot) then live = live + 1 end
end
ok(live == 3, "all three are active after InitRoamMons")
-- pokegold/engine/battle/core.asm:3476-3479: TRAP and FORCESHINY only.
ok(Battle.noEscapeBattleType({ battleType = 9 }) == true, "TRAP: no escape")
ok(Battle.noEscapeBattleType({ battleType = 7 }) == true,
"FORCESHINY: no escape")
ok(Battle.noEscapeBattleType({ battleType = 10 }) == false,
"FORCEITEM: Lugia and Ho-Oh can still be run from on Gold")
ok(Battle.noEscapeBattleType({ battleType = 8 }) == false, "TREE: escapable")
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
+236
View File
@@ -0,0 +1,236 @@
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
-- opponent draw, the walk-in, the battle, and the counter the room script
-- reads back.
--
-- Nothing is shimmed: the startTowerBattle / setObjectSprite hooks and the
-- battleTower field all come from World itself.
--
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
-- POKEPORT_DRIVER=tests/drivers/gate_tower_noshim.lua love .
local U = require("tests.drivers.util")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
-- ---- the run --------------------------------------------------------------
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
local fails, shots = 0, 0
local function say(line) print("[driver] " .. line) end
local function ok(cond, line)
if not cond then fails = fails + 1 end
say((cond and "OK " or "FAIL ") .. line)
end
local function shot(name)
shots = shots + 1
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
end
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 4)
end
U.wait(60)
local world = game.world
assert(world and world.map, "crystal world did not boot")
ok(type(world.vm.specials.startTowerBattle) == "function",
"World:specialHooks supplies startTowerBattle, unshimmed")
ok(type(world.vm.specials.setObjectSprite) == "function",
"World:specialHooks supplies setObjectSprite, unshimmed")
local roster = BattleTower.roster(game.data)
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
if roster then
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
roster.sampleTrainers))
end
-- Three legal mons over the L10 room, each with ONE strong move so a blind
-- A-press run cannot pick a status move for a hundred turns.
local party = {}
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
{ "FERALIGATR", 60, "SURF", "BERRY" },
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
local def = game.data.moves[row[3]]
local mon = Mon.new(game.data, row[1], row[2], {
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
})
mon.item = row[4]
party[#party + 1] = mon
end
game.save.party = party
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
-- the L10 one (battle_tower.asm:1129-1141).
local tower = BattleTower.state(game.save)
tower.levelGroup = 1
tower.streak = 0
tower.trainers = {}
tower.challenge = BattleTower.NO_CHALLENGE
while game.stack:top() do game.stack:pop() end
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
U.wait(30)
shot("room-entered")
-- The scene script walks the player in, draws the opponent and starts the
-- battle; nothing here presses anything until a text box wants it.
local sawOpponent, sawBattle = false, false
for _ = 1, 900 do
local vm = world.vm
if vm and vm.btOpponent and not sawOpponent then
sawOpponent = true
say(("opponent: %s %s (row %d), sprite %s, group %d")
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
vm.btOpponent.group))
for slot, mon in ipairs(vm.btOpponent.rows) do
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
tostring(mon.item)))
end
U.wait(20)
shot("opponent-walked-in")
end
if world.battleActive then
sawBattle = true
break
end
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
end
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
local screen, battle = nil, nil
if sawBattle then
U.wait(90)
shot("battle-open")
screen = game.stack:top()
battle = screen and screen.battle
ok(battle ~= nil, "the battle screen owns a Battle")
if battle then
ok(battle.inBattleTowerBattle == true,
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
.. " L" .. tostring(battle.enemy and battle.enemy.level))
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
"with a three-mon Tower party")
end
end
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
-- move with PP left.
local function hardestMove(mon)
local best, bestPower = 1, -1
for index, move in ipairs((mon and mon.moves) or {}) do
local def = game.data.moves and game.data.moves[move.id]
local power = (def and def.power) or 0
if (move.pp or 0) > 0 and power > bestPower then
best, bestPower = index, power
end
end
return best
end
-- PP topped up between turns: an unattended run otherwise Struggles itself
-- to death long before the third opponent mon is down.
local function refill(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
move.pp = move.maxPp or move.pp
end
end
local menus = 0
for _ = 1, 900 do
if not world.battleActive or (battle and battle.over) then break end
local phase = screen and screen.phase
if phase == "menu" then
menus = menus + 1
if menus == 1 then shot("battle-menu") end
refill(battle and battle.player)
screen.menuIndex = 1
tap("a", 4)
elseif phase == "moves" then
screen.menuIndex = hardestMove(battle and battle.player)
tap("a", 4)
elseif phase == "submenu" then
-- A forced switch cannot be cancelled, so walk off the fainted lead.
tap("down", 3)
tap("a", 4)
else
tap("a", 3)
end
end
if battle then
say("battle over=" .. tostring(battle.over)
.. " outcome=" .. tostring(battle.outcome)
.. " phase=" .. tostring(screen and screen.phase))
ok(battle.over, "the battle resolved")
ok(battle.outcome == "win", "and the L60 party won it")
shot("battle-end")
end
for _ = 1, 200 do
if not world.battleActive then break end
tap("a", 3)
end
ok(not world.battleActive, "the battle screen came down")
U.wait(30)
shot("after-battle")
local vm = world.vm
local state = BattleTower.state(game.save)
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
tostring(vm and vm.stringBuffer)))
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
"and armed sBattleTowerChallengeState")
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
ok(state.prevTeams and #state.prevTeams.prev == 3,
"and sBTMonPrevTrainer recorded the team")
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
-- second draw all follow on their own.
local pages, secondDraw = {}, nil
for _ = 1, 400 do
local body = world.lastText
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
pages[#pages + 1] = body
if #pages <= 6 then shot("page-" .. #pages) end
end
if world.battleActive then
secondDraw = world.vm and world.vm.btOpponent
break
end
if world.choicebox then tap("a", 4) else tap("a", 3) end
end
say("pages: " .. table.concat(pages, " | "))
local joined = table.concat(pages, " | ")
ok(joined:find("healed to full health", 1, true) ~= nil
or joined:find("Next up", 1, true) ~= nil,
"the room script carried on past the battle")
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
or joined:find("no.2", 1, true) ~= nil,
"and wStringBuffer3 named the SECOND opponent")
if world.battleActive then
U.wait(60)
shot("second-battle")
ok(true, "the loop came back round for opponent two")
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
end
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
love.event.quit(fails == 0 and 0 or 1)
end
@@ -41,7 +41,7 @@ end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
or v == "silver" or v == "crystal"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
@@ -57,6 +57,14 @@ RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("crystal")
check(#capturedShortcuts == 4, "nor does a sixth")
check(capturedShortcuts[1] == "crystal", "activeVersion 'crystal' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"and the rest still follow GameVersion.ORDER until the cap")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
return v == "red" or v == "gold"
+218
View File
@@ -0,0 +1,218 @@
-- Crystal registration: VERSIONS row, engine lineage, ORDER slot, sha1
-- routing, the importer's required-file override and the script dialect.
-- luajit tests/engine/crystal_version_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("crystal version registration")
local check = S.check
local eq = S.eq
local GameVersion = require("src.core.GameVersion")
local Opcodes = require("src.script.gen2.Opcodes")
-- ------- 1. the VERSIONS row
local row = GameVersion.VERSIONS.crystal
check(row ~= nil, "GameVersion.VERSIONS carries a crystal row")
eq(row.id, "crystal", "row id")
eq(row.label, "Crystal", "row label")
eq(row.displayName, "Pokemon Crystal", "row display name")
eq(row.sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "retail Crystal sha1")
eq(row.manifest, "tools/rom_manifest_crystal.json", "row manifest path")
eq(row.cachePrefix, "crystal/", "cache prefix")
eq(row.saveSuffix, "_crystal", "save suffix")
eq(GameVersion.cachePrefix("crystal"), "crystal/", "cachePrefix() agrees")
eq(GameVersion.saveSuffix("crystal"), "_crystal", "saveSuffix() agrees")
local prefixes, suffixes = {}, {}
for _, id in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(id)
eq(prefixes[info.cachePrefix], nil, id .. " cache prefix is unique")
eq(suffixes[info.saveSuffix], nil, id .. " save suffix is unique")
prefixes[info.cachePrefix] = id
suffixes[info.saveSuffix] = id
end
-- ------- 2. generation and engine lineage
eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
eq(GameVersion.engine("crystal"), "crystal", "Crystal's engine lineage")
eq(GameVersion.engine("gold"), "gs", "Gold is the gs lineage")
eq(GameVersion.engine("silver"), "gs", "and so is Silver")
eq(GameVersion.engine("red"), "gen1", "Red is gen1")
eq(GameVersion.engine("blue"), "gen1", "Blue is gen1")
eq(GameVersion.engine("yellow"), "gen1", "Yellow is gen1")
local LINEAGES = { gen1 = true, gs = true, crystal = true }
for _, id in ipairs(GameVersion.ORDER) do
check(LINEAGES[GameVersion.engine(id)] == true,
id .. " reports a known engine lineage")
end
eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
eq(GameVersion.generation("red"), 1, "Red is Gen 1")
-- ------- 3. launcher ORDER
local index
for i, id in ipairs(GameVersion.ORDER) do
if id == "crystal" then index = i end
end
eq(index, 6, "crystal is ORDER slot 6")
eq(#GameVersion.ORDER, 6, "ORDER is six games")
eq(GameVersion.ORDER[4], "gold", "gold keeps slot 4")
eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5")
-- ------- 4. sha1 routing
eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal",
"the retail Crystal sha1 resolves to crystal")
eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold",
"Gold's sha1 still resolves to gold")
eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing")
-- ------- 5. set / get round trip
local savedCurrent = GameVersion.get()
eq(GameVersion.set("crystal"), "crystal", "set('crystal') is accepted")
eq(GameVersion.get(), "crystal", "and becomes current")
eq(GameVersion.engine(), "crystal", "engine() with no argument reads current")
check(not GameVersion.isGold(), "isGold() stays false on Crystal")
GameVersion.set(savedCurrent)
-- ------- 6. the importer's required-file list
local RomImporter = require("src.import.RomImporter")
love.filesystem = love.filesystem or {}
local savedGetInfo = love.filesystem.getInfo
local savedGetReal = love.filesystem.getRealDirectory
local savedGetSource = love.filesystem.getSource
local function probe(version)
local seen, order = {}, {}
love.filesystem.getInfo = function(name)
if not seen[name] then
seen[name] = true
order[#order + 1] = name
end
return { type = "file" }
end
love.filesystem.getRealDirectory = function() return "/nowhere" end
love.filesystem.getSource = function() return "/elsewhere" end
RomImporter.isReady(version)
return seen, order
end
local crystalSeen, crystalOrder = probe("crystal")
local redSeen = probe("red")
love.filesystem.getInfo = savedGetInfo
love.filesystem.getRealDirectory = savedGetReal
love.filesystem.getSource = savedGetSource
check(#crystalOrder > 20,
("the crystal list is substantial (%d entries probed)"):format(#crystalOrder))
for _, path in ipairs({
"crystal/data/generated/encounters.lua",
"crystal/data/generated/landmarks.lua",
"crystal/data/generated/title.lua",
"crystal/data/generated/intro.lua",
"crystal/assets/generated/title/crystal_logo.png",
"crystal/assets/generated/title/crystal_wordmark.png",
"crystal/assets/generated/title/crystal_suicune.png",
"crystal/assets/generated/splash/ditto.png",
"crystal/assets/generated/intro/chris.png",
"crystal/assets/generated/intro/kris.png",
"crystal/assets/generated/battle/front/wooper.png",
}) do
check(crystalSeen[path] == true, "crystal requires " .. path)
end
for _, path in ipairs({
"crystal/assets/generated/battle/anims/move_anim_0.png",
"crystal/assets/generated/battle/anims/move_anim_1.png",
"crystal/data/generated/battle_anims.lua",
"crystal/assets/generated/trade/game_boy.png",
}) do
eq(crystalSeen[path], nil, "crystal does not wait on " .. path)
end
check(redSeen["assets/generated/battle/anims/move_anim_0.png"] == true,
"red still requires the Gen 1 battle anim sheet")
eq(redSeen["assets/generated/title/crystal_logo.png"], nil,
"and none of Crystal's title art")
-- ------- 7. script dialect
local crystalTable = Opcodes.forEdition("crystal")
local goldTable = Opcodes.forEdition("gold")
check(crystalTable ~= goldTable,
"forEdition('crystal') is not the Gold table")
eq(Opcodes.forEdition("silver"), goldTable, "Silver shares Gold's table")
eq(goldTable, Opcodes, "and Gold's table is the module itself")
eq(Opcodes.forEdition(nil), Opcodes, "an absent edition falls back to Gold")
-- pokecrystal/macros/scripts/events.asm:541
eq(crystalTable[0x52] and crystalTable[0x52].name, "farjumptext",
"Crystal $52 is farjumptext")
eq(goldTable[0x52] and goldTable[0x52].name, "jumptext",
"Gold $52 is jumptext")
eq(crystalTable[0x53] and crystalTable[0x53].name, "jumptext",
"and Crystal's jumptext moved to $53")
local function count(tbl)
local n = 0
for key in pairs(tbl) do if type(key) == "number" then n = n + 1 end end
return n
end
eq(count(crystalTable), 170, "Crystal names 170 commands")
eq(count(goldTable), 162, "Gold names 162")
local diverged
for byte = 0x00, 0x51 do
local a = crystalTable[byte] and crystalTable[byte].name
local b = goldTable[byte] and goldTable[byte].name
if a ~= b then diverged = byte; break end
end
eq(diverged, nil, "$00-$51 are the same commands in both dialects")
check(Opcodes.TERMINATORS.farjumptext == true,
"farjumptext is a terminator")
-- ------- 8. the launcher reaches the crystal tab
local visited = {}
local fake = setmetatable({ tab = GameVersion.ORDER[1] }, RomImporter)
fake._switchTab = function(self, id)
self.tab = id
visited[#visited + 1] = id
end
for _ = 1, 12 do fake:_cycleTab(1) end
local sawCrystal, sawMods, sawBug = false, false, false
for _, id in ipairs(visited) do
if id == "crystal" then sawCrystal = true end
if id == "mods" then sawMods = true end
if id == "bug" then sawBug = true end
end
check(sawCrystal, "cycling the launcher tabs reaches crystal")
check(sawMods and sawBug, "and still reaches the mods and bug tabs")
local seen, cycle = {}, 0
fake.tab = "crystal"
repeat
seen[fake.tab] = true
fake:_cycleTab(1)
cycle = cycle + 1
until fake.tab == "crystal" or cycle > 40
eq(cycle, #GameVersion.ORDER + 4,
"the ring is the six games plus mods/find/skins/bug")
fake.tab = "crystal"
fake:_cycleTab(-1)
eq(fake.tab, "silver", "and stepping back off crystal lands on silver")
S.finish()
+1
View File
@@ -24,6 +24,7 @@ T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
T.eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
+292
View File
@@ -0,0 +1,292 @@
-- Gen 2 script bytecode dialects: Gold/Silver vs Crystal.
--
-- Crystal inserts farjumptext at $52 and pushes every later opcode up by one
-- (pokecrystal/macros/scripts/events.asm:541). Decoding a Crystal script with
-- the Gold table is silent: a Crystal $53 `jumptext` (2 operand bytes) reads as
-- Gold's `waitbutton` (0), the pointer walk desynchronises, and the extractor
-- emits plausible garbage rather than an error. So the expected tables below
-- are transcribed from the two macro files by hand and pinned here.
-- luajit tests/engine/gen2_script_opcodes_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local Opcodes = require("src.script.gen2.Opcodes")
-- pokegold/macros/scripts/events.asm:1-1015, in const order from $00.
-- Sizes are the macro body minus its own `db <name>_command`: db 1, dw 2,
-- dba 3, bigdt 3, map_id 2 (pokegold/macros/scripts/maps.asm:1-6).
-- `givepoke` is the one variable-length row: the macro emits 8 bytes when the
-- trainer argument is non-zero (events.asm:352-365) and the table declares the
-- 4-byte base, which src/import/RomExtractorGen2.lua re-measures per call site.
local GOLD_EXPECTED = {
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
"closewindow 0", "jumptextfaceplayer 2", "jumptext 2", "waitbutton 0",
"promptbutton 0", "pokepic 1", "closepokepic 0", "_2dmenu 0",
"verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", "swarm 2",
"halloffame 0", "credits 0", "warpfacing 5",
}
-- pokecrystal/macros/scripts/events.asm:1-1068, same transcription rules.
-- Cross-checked row for row against ScriptCommandTable
-- (pokecrystal/engine/overworld/scripting.asm:64-237), which is the table the
-- hardware actually indexes and therefore the authority over the macro file.
local CRYSTAL_EXPECTED = {
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
"closewindow 0", "jumptextfaceplayer 2", "farjumptext 3", "jumptext 2",
"waitbutton 0", "promptbutton 0", "pokepic 1", "closepokepic 0",
"_2dmenu 0", "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2",
"verbosegiveitemvar 2", "swarm 3", "halloffame 0", "credits 0",
"warpfacing 5", "battletowertext 1", "getlandmarkname 2",
"gettrainerclassname 2", "getname 3", "wait 1", "checksave 0",
}
local gold = Opcodes.forEdition("gold")
local crystal = Opcodes.forEdition("crystal")
-- CT-1: gold and silver share one dialect, and Opcodes[byte] keeps answering.
check(Opcodes.forEdition("silver") == gold, "silver resolves to the Gold table")
check(gold == Opcodes, "the Gold table is the module itself (Opcodes[byte])")
check(crystal ~= gold, "crystal resolves to a different table")
check(Opcodes.forEdition(nil) == gold, "an unknown edition falls back to Gold")
eq(Opcodes[0x52] and Opcodes[0x52].name, "jumptext",
"Opcodes[0x52] is unchanged for existing callers")
local function auditTable(label, tbl, expected)
local holes, wrong = {}, {}
for i, want in ipairs(expected) do
local byte = i - 1
local name, size = want:match("^(%S+) (%d+)$")
local row = tbl[byte]
if not row then
holes[#holes + 1] = ("$%02x"):format(byte)
elseif row.name ~= name or row.size ~= tonumber(size) then
wrong[#wrong + 1] = ("$%02x %s/%d wanted %s/%s")
:format(byte, tostring(row.name), row.size or -1, name, size)
end
end
eq(#holes, 0, label .. " has no missing opcode (" .. table.concat(holes, " ")
.. ")")
eq(#wrong, 0, label .. " matches events.asm (" .. table.concat(wrong, "; ")
.. ")")
local extra = {}
for byte = 0x00, 0xff do
if tbl[byte] and byte >= #expected then
extra[#extra + 1] = ("$%02x %s"):format(byte, tbl[byte].name)
end
end
eq(#extra, 0, label .. " declares nothing past the last command ("
.. table.concat(extra, " ") .. ")")
end
auditTable("gold", gold, GOLD_EXPECTED)
auditTable("crystal", crystal, CRYSTAL_EXPECTED)
-- (a) $00-$51 is byte-identical between the dialects.
local drift = {}
for byte = 0x00, 0x51 do
local g, c = gold[byte], crystal[byte]
if not (g and c and g.name == c.name and g.size == c.size) then
drift[#drift + 1] = ("$%02x"):format(byte)
end
end
eq(#drift, 0, "$00-$51 is identical in both dialects ("
.. table.concat(drift, " ") .. ")")
-- and the two dialects agree on NOTHING from $52 up, because the whole tail is
-- shifted by one. farjumptext is the wedge.
eq(crystal[0x52].name, "farjumptext", "$52 is farjumptext on Crystal")
eq(crystal[0x52].size, 3, "farjumptext carries a dba, so 3 operand bytes")
eq(crystal[0x53].name, "jumptext", "Gold's $52 jumptext moved to $53")
eq(crystal[0xa0].size, 3,
"Crystal swarm gained a leading flag byte (events.asm:1003-1008)")
eq(gold[0x9e].size, 2, "Gold swarm is still a bare map_id")
-- (c) NUM_EVENT_COMMANDS, both as the declared constant and as the row count.
local function rowCount(tbl)
local n = 0
for byte = 0x00, 0xff do if tbl[byte] then n = n + 1 end end
return n
end
eq(Opcodes.NUM_EVENT_COMMANDS, 162,
"pokegold events.asm:1015 NUM_EVENT_COMMANDS = $a2")
eq(crystal.NUM_EVENT_COMMANDS, 170,
"pokecrystal events.asm:1068 NUM_EVENT_COMMANDS = $aa")
eq(rowCount(gold), Opcodes.NUM_EVENT_COMMANDS,
"the Gold table is dense up to NUM_EVENT_COMMANDS")
eq(rowCount(crystal), crystal.NUM_EVENT_COMMANDS,
"the Crystal table is dense up to NUM_EVENT_COMMANDS")
-- (d) farjumptext ends the walk the same way jumptext does.
check(Opcodes.TERMINATORS.farjumptext,
"farjumptext is a TERMINATOR (jp ScriptJump, scripting.asm:318-327)")
check(Opcodes.TERMINATORS.jumptext, "jumptext still is")
check(crystal.TERMINATORS == Opcodes.TERMINATORS,
"the Crystal table answers TERMINATORS too")
eq(crystal.key, Opcodes.key, "and key(), so a resolved table is self-sufficient")
-- (e) MOD_COMMAND is reachable only by name: no byte in EITHER dialect decodes
-- to it, so ROM data can never be mistaken for a mod verb.
local collide = {}
for byte = 0x00, 0xff do
if gold[byte] and gold[byte].name == Opcodes.MOD_COMMAND then
collide[#collide + 1] = ("gold $%02x"):format(byte)
end
if crystal[byte] and crystal[byte].name == Opcodes.MOD_COMMAND then
collide[#collide + 1] = ("crystal $%02x"):format(byte)
end
end
eq(#collide, 0, "MOD_COMMAND has no byte in either dialect ("
.. table.concat(collide, " ") .. ")")
check(type(Opcodes.MOD_COMMAND) == "string" and Opcodes.MOD_COMMAND ~= "",
"MOD_COMMAND is a name, not a byte")
-- The Vm side of the dialect: every Crystal-only verb has a branch, and the
-- shifted `swarm` reads its flag rather than its map group.
love = require("tests.love_stub")
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
do
local seen, swarmArgs = {}, nil
local events = Events.new()
local vm = Vm.new({
generation = 2,
["s:crystal"] = {
-- pokecrystal/macros/scripts/events.asm:1003-1008: flag, then map_id.
{ op = "swarm", args = { 1, 24, 3 } },
{ op = "checksave" },
{ op = "wait", args = { 2 } },
{ op = "getlandmarkname", args = { 5, 3 } },
{ op = "gettrainerclassname", args = { 9, 3 } },
{ op = "getname", args = { 1, 152, 3 } },
{ op = "verbosegiveitemvar", args = { 20, 7 } },
{ op = "farjumptext", text = "t:far" },
{ op = "setevent", event = 1 },
},
}, { ["t:far"] = "Far text." }, events, {
setSwarm = function(group, mapNum, kind)
swarmArgs = { group, mapNum, kind }
end,
checkSave = function() return true end,
getLandmarkName = function(id) seen.landmark = id return "RUINS" end,
getTrainerClassName = function(id) seen.class = id return "SAGE" end,
getMonName = function(id) seen.mon = id return "CHIKORITA" end,
readVar = function(id) seen.var = id return 4 end,
giveItem = function(item, qty) seen.give = { item, qty } return true end,
getItemName = function() return "REPEL" end,
showText = function(body, onDone) seen.text = body onDone() end,
})
check(vm:start("s:crystal"), "a Crystal-shaped script starts")
for _ = 1, 40 do vm:update() end
check(not vm:running(), "and runs to completion")
eq(swarmArgs and swarmArgs[1], 24, "swarm reads the map group from args[2]")
eq(swarmArgs and swarmArgs[2], 3, "and the map number from args[3]")
eq(swarmArgs and swarmArgs[3], 1, "and passes SWARM_YANMA through as the kind")
eq(seen.landmark, 5, "getlandmarkname passes its landmark id to the hook")
eq(seen.class, 9, "gettrainerclassname passes the trainer group")
eq(seen.mon, 152, "getname with MON_NAME routes to the mon-name hook")
eq(seen.var, 7, "verbosegiveitemvar reads the quantity out of a var")
eq(seen.give and seen.give[1], 20, "and gives the item the first byte names")
eq(seen.give and seen.give[2], 4, "at the quantity the var held")
eq(seen.text, "Far text.", "farjumptext prints its text")
eq(next(vm.unknownOps or {}), nil,
"no Crystal verb in the script fell through to the unknown ledger")
-- Script_farjumptext ends on `jp ScriptJump`, so the setevent after it never
-- runs -- the same reading Opcodes.TERMINATORS encodes for the extractor.
check(not events:get(1),
"farjumptext ended the script before the setevent below it")
events:set(1, true)
check(events:get(1), "and the event really is observable when it is set")
end
do
-- Script_wait is SIX frames per unit (scripting.asm:2336-2347), not
-- Script_pause's two.
local vm = Vm.new({ generation = 2,
["s:wait"] = { { op = "wait", args = { 3 } }, { op = "end" } },
}, {}, nil, {})
check(vm:start("s:wait"), "a lone `wait` starts")
local frames = 0
while vm:running() and frames < 100 do
vm:update()
frames = frames + 1
end
eq(frames, 18, "`wait 3` holds the script for 3 * 6 frames")
end
T.finish()
+4 -1
View File
@@ -39,7 +39,7 @@ end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
for _, version in ipairs({ "gold", "silver" }) do
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local model = LauncherSettings.open(hooks, version)
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(model, "VIBRATION"), version .. " and VIBRATION")
@@ -82,6 +82,7 @@ for _, version in ipairs({ "gold", "silver" }) do
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
eq(model.opts.crystal, nil, version .. " nor a third")
local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value()
@@ -114,6 +115,8 @@ eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "crystal"), "TOUCH CONTROLS"), false,
"nor on Crystal")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
+15 -3
View File
@@ -90,11 +90,23 @@ check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
check(imp:find("_installMod", 1, true) ~= nil,
"and a zip elsewhere still installs a mod")
local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
local RomImporter = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local cycled, probe = {}, nil
probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter })
probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end
for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end
local reached = " " .. table.concat(cycled, " ") .. " "
check(reached:find(" skins ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
check(reached:find(" bug ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the bug tab")
for _, id in ipairs(GameVersion.ORDER) do
if id ~= GameVersion.ORDER[1] then
check(reached:find(" " .. id .. " ", 1, true) ~= nil,
"shoulder-button tab cycling reaches " .. id)
end
end
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list")
+20 -12
View File
@@ -27,18 +27,25 @@ end
-- ------- tokens expand off GameVersion, never a literal list
-- "nonesuch" is the deliberate never-a-game token for every unknown-version
-- case below. A real version id was used here twice ("gold", then "crystal")
-- and both had to be swapped the day that game shipped; this one never will.
local NO_SUCH_GAME = "nonesuch"
do
eq(table.concat(ModTargets.expand("red"), ","), "red",
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver,crystal",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("crystal"), ","), "crystal",
"Crystal included, the day its VERSIONS row landed")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
@@ -48,9 +55,9 @@ do
eq(table.concat(versions, ","), "red,gold",
"normalize dedupes and sorts into GameVersion.ORDER")
eq(#unknown, 0, "known tokens leave nothing unreported")
local _, bad = ModTargets.normalize({ "crystal", "gen1" })
local _, bad = ModTargets.normalize({ NO_SUCH_GAME, "gen1" })
eq(#bad, 1, "an unknown token comes back for the caller to report")
eq(bad[1], "crystal", "by name")
eq(bad[1], NO_SUCH_GAME, "by name")
end
-- ------- the legacy reading: gen2compat only ever ADDS Gen 2
@@ -58,7 +65,7 @@ end
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver,crystal",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -69,23 +76,23 @@ end
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(list(gen2), "gold,silver,crystal", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
eq(list(both), "red,blue,yellow,gold,silver,crystal", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver,crystal",
"an old gen2compat beside a new games list still adds its game")
end
do
-- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like
-- every other manifest vocabulary (Manifest.violation)
local lenient = mf({ games = { "crystal", "red" } })
local lenient = mf({ games = { NO_SUCH_GAME, "red" } })
eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest")
check(not pcall(mf, { api = 2, games = { "crystal" } }),
check(not pcall(mf, { api = 2, games = { NO_SUCH_GAME } }),
"api 2 refuses a game it does not have")
check(not pcall(mf, { games = "gen1" }),
"games must be an array, not a bare string")
@@ -226,12 +233,13 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, crystal = { a = true },
gold = { a = true }, silver = { a = true },
[NO_SUCH_GAME] = { a = true },
red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion[NO_SUCH_GAME], nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
@@ -57,9 +57,10 @@ end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
for _, ver in ipairs(GameVersion.ORDER) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
+2 -2
View File
@@ -82,7 +82,7 @@ local function importer(ready)
end
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
silver = true, crystal = true })
allReady:_queueBaseRomScan()
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
eq(listings, 0, "ready launcher does not enumerate baseroms")
@@ -125,7 +125,7 @@ missing:choose("red")
eq(picks, 1, "the next import attempt falls back to the native picker")
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
silver = true, crystal = true })
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
rescanned:reimport("red")
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")
+25
View File
@@ -37,6 +37,31 @@ eq(FieldMoves.BADGE_FLAG[26].store, "badges", "Johto badges go to player.badges"
eq(FieldMoves.BADGE_FLAG[34].store, "kantoBadges",
"ENGINE_BOULDERBADGE goes to player.kantoBadges")
-- pokecrystal/constants/engine_flags.asm:39 declares 162 flags to pokegold's
-- 93, moving the whole badge block up one.
do
local order = {}
for i = 1, 43 do order[i] = "ENGINE_UNRELATED" .. i end
order[10] = nil -- a const_skip hole must not truncate the map
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
order[27 + index] = "ENGINE_" .. name .. "BADGE"
end
for index, name in ipairs(FieldMoves.KANTO_BADGES) do
order[35 + index] = "ENGINE_" .. name .. "BADGE"
end
FieldMoves.bindEngineFlags(order)
eq(FieldMoves.BADGE_FLAG[27].name, "ZEPHYR", "crystal ZEPHYRBADGE is 27")
eq(FieldMoves.BADGE_FLAG[28].name, "HIVE", "crystal HIVEBADGE is 28")
eq(FieldMoves.BADGE_FLAG[34].name, "RISING", "crystal RISINGBADGE is 34")
eq(FieldMoves.BADGE_FLAG[35].name, "BOULDER", "crystal BOULDERBADGE is 35")
eq(FieldMoves.BADGE_FLAG[35].store, "kantoBadges",
"a renumbered Kanto badge still lands in kantoBadges")
eq(FieldMoves.BADGE_FLAG[26], nil, "Gold's ZEPHYR slot is vacated")
FieldMoves.bindEngineFlags(nil)
eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "no map falls back to Gold")
end
-- ---------------------------------------------------------------------------
-- The round trip: what a gym script writes is what a field move reads.
-- ---------------------------------------------------------------------------
+603
View File
@@ -0,0 +1,603 @@
-- The Battle Tower BATTLE: the roster tables, the opponent draw and the two
-- specials maps/BattleTowerBattleRoom.asm's loop is built out of.
--
-- luajit tests/gen2_battle_tower_battle_test.lua
--
-- ROM-free. The extractor half runs against a synthetic cartridge whose
-- tables are laid out byte for byte the way data/battle_tower/ assembles
-- them, and the rest against a fixture roster taken from
-- ../pokecrystal/data/battle_tower/parties.asm group 1.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle tower battle")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local RomExtractorGen2 = require("src.import.RomExtractorGen2")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
-- ---------------------------------------------------------------- fixtures
-- ../pokecrystal/data/pokemon/base_stats/*.asm, the five species group 1 of
-- BattleTowerMons opens with.
local POKEMON = {
JOLTEON = { name = "JOLTEON", index = 135, types = { "ELECTRIC" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 65, attack = 65, defense = 60, speed = 130,
specialAttack = 110, specialDefense = 95 } },
ESPEON = { name = "ESPEON", index = 196, types = { "PSYCHIC_TYPE" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 65, attack = 65, defense = 60, speed = 110,
specialAttack = 130, specialDefense = 95 } },
UMBREON = { name = "UMBREON", index = 197, types = { "DARK" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 95, attack = 65, defense = 110, speed = 65,
specialAttack = 60, specialDefense = 130 } },
WOBBUFFET = { name = "WOBBUFFET", index = 202, types = { "PSYCHIC_TYPE" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 190, attack = 33, defense = 58, speed = 33,
specialAttack = 33, specialDefense = 58 } },
KANGASKHAN = { name = "KANGASKHAN", index = 115, types = { "NORMAL" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 105, attack = 95, defense = 80, speed = 90,
specialAttack = 40, specialDefense = 80 } },
}
local MOVES = {
THUNDERBOLT = { pp = 15 }, HYPER_BEAM = { pp = 5 }, SHADOW_BALL = { pp = 15 },
ROAR = { pp = 20 }, MUD_SLAP = { pp = 10 }, PSYCHIC_M = { pp = 10 },
PSYCH_UP = { pp = 10 }, TOXIC = { pp = 10 }, IRON_TAIL = { pp = 15 },
COUNTER = { pp = 20 }, MIRROR_COAT = { pp = 20 }, SAFEGUARD = { pp = 25 },
DESTINY_BOND = { pp = 5 }, REVERSAL = { pp = 15 }, EARTHQUAKE = { pp = 10 },
ATTRACT = { pp = 15 },
}
-- ../pokecrystal/data/battle_tower/parties.asm:4-140, transcribed field for
-- field: the DVs, the stat exp words, the PP bytes and the stats the cart
-- copies straight into wOTPartyMon.
local function row(species, item, moves, pp, dvs, statExp, stats)
return {
species = species, item = item, moves = moves, pp = pp,
level = 10, happiness = 100, experience = 1000, otId = 0,
dvs = { attack = dvs[1], defense = dvs[2], speed = dvs[3],
special = dvs[4] },
statExp = { hp = statExp[1], attack = statExp[2], defense = statExp[3],
speed = statExp[4], special = statExp[5] },
hp = stats[1], maxHp = stats[1],
stats = { hp = stats[1], attack = stats[2], defense = stats[3],
speed = stats[4], specialAttack = stats[5], specialDefense = stats[6] },
}
end
local GROUP1 = {
row("JOLTEON", "MIRACLEBERRY",
{ "THUNDERBOLT", "HYPER_BEAM", "SHADOW_BALL", "ROAR" }, { 15, 5, 15, 20 },
{ 13, 13, 11, 13 }, { 50000, 40000, 40000, 35000, 40000 },
{ 41, 25, 24, 37, 34, 31 }),
row("ESPEON", "LEFTOVERS",
{ "MUD_SLAP", "PSYCHIC_M", "PSYCH_UP", "TOXIC" }, { 10, 10, 10, 10 },
{ 14, 13, 15, 11 }, { 40000, 50000, 35000, 40000, 40000 },
{ 39, 26, 24, 35, 38, 31 }),
row("UMBREON", "GOLD_BERRY",
{ "SHADOW_BALL", "IRON_TAIL", "PSYCH_UP", "TOXIC" }, { 15, 15, 10, 10 },
{ 13, 11, 14, 15 }, { 40000, 40000, 45000, 50000, 40000 },
{ 46, 25, 34, 26, 25, 39 }),
row("WOBBUFFET", "FOCUS_BAND",
{ "COUNTER", "MIRROR_COAT", "SAFEGUARD", "DESTINY_BOND" },
{ 20, 20, 25, 5 }, { 7, 15, 13, 7 },
{ 50000, 50000, 50000, 50000, 50000 }, { 66, 18, 25, 19, 18, 23 }),
-- MIRACLEBERRY again, which is the item collision the draw refuses.
row("KANGASKHAN", "MIRACLEBERRY",
{ "REVERSAL", "HYPER_BEAM", "EARTHQUAKE", "ATTRACT" }, { 15, 5, 10, 15 },
{ 14, 15, 12, 15 }, { 40000, 30000, 40000, 30000, 30000 },
{ 47, 31, 29, 29, 20, 28 }),
}
-- ../pokecrystal/data/battle_tower/classes.asm:11-17
local ROSTER = {
partyLength = 3,
levelGroups = 2,
uniqueMon = #GROUP1,
uniqueTrainers = 4,
sampleTrainers = 4,
trainers = {
{ index = 0, name = "HANSON", class = 37, classId = "FISHER" },
{ index = 1, name = "SAWYER", class = 30, classId = "POKEMANIAC" },
{ index = 2, name = "MASUDA", class = 43, classId = "GUITARIST" },
{ index = 3, name = "NICKEL", class = 20, classId = "SCIENTIST" },
},
groups = { GROUP1, GROUP1 },
classSprites = {
FISHER = "SPRITE_FISHER", POKEMANIAC = "SPRITE_SUPER_NERD",
GUITARIST = "SPRITE_ROCKER", SCIENTIST = "SPRITE_SCIENTIST",
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
trainers = {
battleTower = ROSTER,
classes = {
FISHER = { index = 37, name = "FISHER", baseMoney = 40,
attributes = { 0, 0, 40, 1, 0, 0, 0 }, items = {} },
POKEMANIAC = { index = 30, name = "POKEMANIAC", baseMoney = 60,
attributes = { 0, 0, 60, 1, 0, 0, 0 }, items = {} },
GUITARIST = { index = 43, name = "GUITARIST", baseMoney = 36,
attributes = { 0, 0, 36, 1, 0, 0, 0 }, items = {} },
SCIENTIST = { index = 20, name = "SCIENTIST", baseMoney = 44,
attributes = { 0, 0, 44, 1, 0, 0, 0 }, items = {} },
},
},
}
local function crystalSave(levelGroup)
local save = Save.normalize({ version = "crystal", generation = 2 })
save.battleTower.levelGroup = levelGroup or 1
save.party = {}
return save
end
-- A roll that walks a canned list, so every draw below is pinned. Same
-- 1..n convention Specials.random uses.
local function rolls(list)
local at = 0
return function(n)
at = at + 1
local value = list[at] or 1
if value > n then value = ((value - 1) % n) + 1 end
return value
end
end
local function fakeVm(save, hooks)
local vm = {
scriptVar = 0,
mem = {},
stringBuffer = "",
specials = hooks or {},
}
vm.specials.save = vm.specials.save or function() return save end
vm.specials.data = vm.specials.data or function() return DATA end
vm.specials.party = vm.specials.party or function() return save.party end
function vm:setStringBuffer(value) self.stringBuffer = value or "" end
function vm:showRaw() end
return vm
end
-- =========================================== the extractor's roster decode
--
-- A synthetic cartridge: BattleTowerTrainers, BattleTowerMons and
-- BTTrainerClassSprites laid out exactly as data/battle_tower/classes.asm,
-- parties.asm and data/trainers/sprites.asm assemble, plus the two
-- `maskbits N / cp N / jr nc` pairs load_trainer.asm samples with.
do
local NAME_LENGTH, MON_NAME_LENGTH = 11, 11
local NICKNAMED = 48 + MON_NAME_LENGTH
local TRAINERS_AT, MONS_AT = 0x4100, 0x4100 + 3 * NAME_LENGTH
local SPRITES_AT, TR_SAMPLE_AT, MON_SAMPLE_AT = 0x5000, 0x5100, 0x5200
local bytes = {}
local function put(offset, list)
for index, value in ipairs(list) do bytes[offset + index] = value end
end
local function name(text, width)
local out = {}
for index = 1, width do
out[index] = (index <= #text) and text:byte(index) or 0x50
end
return out
end
-- charmap rows for the letters the three names use.
local charmap, letters = {}, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for index = 1, #letters do
charmap[tostring(0x80 + index - 1)] = letters:sub(index, index)
end
local function encode(text, width)
local out = {}
for index = 1, width do
if index <= #text then
out[index] = 0x80 + (text:byte(index) - 65)
else
out[index] = 0x50
end
end
return out
end
-- Three rows, the third exactly NAME_LENGTH - 1 long so the unterminated
-- name the `dname` macro leaves (classes.asm:16 ZABOROWSKI) is covered.
local rows = { { "ABLE", 1 }, { "BAKER", 2 }, { "CDEFGHIJKL", 3 } }
for index, entry in ipairs(rows) do
local at = TRAINERS_AT + (index - 1) * NAME_LENGTH
put(at, encode(entry[1], NAME_LENGTH - 1))
bytes[at + NAME_LENGTH] = entry[2]
end
-- One level group of two mons. Only the fields the reader names are set;
-- everything else stays 0, which is what ByteFill leaves.
local function monBytes(species, item, moves, pp, dv1, dv2, level, stats)
local raw = {}
for index = 1, NICKNAMED do raw[index] = 0 end
raw[1] = species
raw[2] = item
for slot = 1, 4 do raw[2 + slot] = moves[slot] or 0 end
raw[9], raw[10], raw[11] = 0, 0x03, 0xe8
raw[12], raw[13] = 0xc3, 0x50
raw[22], raw[23] = dv1, dv2
for slot = 1, 4 do raw[23 + slot] = pp[slot] or 0 end
raw[28] = 100
raw[32] = level
raw[35], raw[36] = 0, stats[1]
raw[37], raw[38] = 0, stats[1]
raw[39], raw[40] = 0, stats[2]
raw[41], raw[42] = 0, stats[3]
raw[43], raw[44] = 0, stats[4]
raw[45], raw[46] = 0, stats[5]
raw[47], raw[48] = 0, stats[6]
return raw
end
put(MONS_AT, monBytes(135, 109, { 85, 63, 247, 46 }, { 15, 5, 15, 20 },
0xdd, 0xbd, 10, { 41, 25, 24, 37, 34, 31 }))
put(MONS_AT + NICKNAMED, monBytes(196, 78, { 189, 94, 0, 0 }, { 10, 10, 0, 0 },
0xed, 0xfb, 10, { 39, 26, 24, 35, 38, 31 }))
put(SPRITES_AT, { 7, 9, 11 })
-- `and $03 / cp 3 / jr nc, .resample` and `and $01 / cp 2 / jr nc`.
put(TR_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47,
0xe6, 0x03, 0xfe, 0x03, 0x30, 0xf3 })
put(MON_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47,
0xe6, 0x01, 0xfe, 0x02, 0x30, 0xf3 })
local rom = {}
for index = 1, 0x8000 do rom[index] = string.char(bytes[index] or 0) end
local manifest = {
romSha1 = "",
charmap = charmap,
constants = {
trainerClassOrder = { "TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY",
"MYSTICALMAN" },
spriteOrder = { "SPRITE_A", "SPRITE_B", "SPRITE_C", "SPRITE_D",
"SPRITE_E", "SPRITE_F", "SPRITE_G", "SPRITE_H", "SPRITE_I",
"SPRITE_J", "SPRITE_K" },
moveOrder = {}, itemOrder = {}, speciesOrder = {},
},
symbols = {
BattleTowerTrainers = { 1, TRAINERS_AT },
BattleTowerMons = { 1, MONS_AT },
BTTrainerClassSprites = { 1, SPRITES_AT },
["LoadOpponentTrainerAndPokemon.resample"] = { 1, TR_SAMPLE_AT },
["LoadRandomBattleTowerMon.resample"] = { 1, MON_SAMPLE_AT },
},
}
manifest.constants.speciesOrder[135] = "JOLTEON"
manifest.constants.speciesOrder[196] = "ESPEON"
manifest.constants.itemOrder[109] = "MIRACLEBERRY"
manifest.constants.itemOrder[78] = "LEFTOVERS"
manifest.constants.moveOrder[85] = "THUNDERBOLT"
manifest.constants.moveOrder[63] = "HYPER_BEAM"
manifest.constants.moveOrder[247] = "SHADOW_BALL"
manifest.constants.moveOrder[46] = "ROAR"
manifest.constants.moveOrder[189] = "MUD_SLAP"
manifest.constants.moveOrder[94] = "PSYCHIC_M"
local ex = RomExtractorGen2.new(table.concat(rom), manifest, nil)
local read = ex:readBattleTowerRoster(manifest.constants, charmap)
check(read ~= nil, "the roster reads out of the cartridge")
eq(read.uniqueTrainers, 3, "the trainer count is the gap to BattleTowerMons")
eq(read.uniqueMon, 2, "NUM_UNIQUE_MON comes off the mon resample loop")
eq(read.sampleTrainers, 3,
"and the trainer ceiling off its own, which Crystal 1.0 gets wrong")
eq(read.trainers[1].name, "ABLE", "a terminated name stops at '@'")
eq(read.trainers[3].name, "CDEFGHIJKL",
"and a full-width one runs to NAME_LENGTH - 1 with no terminator")
eq(read.trainers[2].classId, "WHITNEY", "the class byte resolves to a name")
eq(read.classSprites.FALKNER, "SPRITE_G", "class 1 takes the first sprite")
eq(read.classSprites.BUGSY, "SPRITE_K", "class 3 the third")
eq(read.classSprites.MYSTICALMAN, nil,
"and MYSTICALMAN is off the end of BTTrainerClassSprites")
eq(#read.groups, 10, "ten level groups are always read")
local first = read.groups[1][1]
eq(first.species, "JOLTEON", "group 1 mon 1 species")
eq(first.item, "MIRACLEBERRY", "its held item")
eq(first.level, 10, "its level")
eq(first.happiness, 100, "its happiness")
eq(first.experience, 1000, "its three exp bytes, big-endian")
eq(first.statExp.hp, 50000, "its HP stat exp word")
eq(first.dvs.attack, 13, "attack DV out of the high nibble")
eq(first.dvs.defense, 13, "defense DV out of the low nibble")
eq(first.dvs.speed, 11, "speed DV")
eq(first.dvs.special, 13, "special DV")
eq(#first.moves, 4, "four moves")
eq(first.moves[3], "SHADOW_BALL", "move three")
eq(first.pp[2], 5, "and its PP byte")
eq(first.maxHp, 41, "the stored max HP")
eq(first.stats.speed, 37, "and the stored Speed")
local second = read.groups[1][2]
eq(#second.moves, 2, "a row with two NO_MOVE slots keeps two moves")
eq(#second.pp, 2, "and two PP bytes")
end
-- =========================== Mon.stats agrees with the cart's stored stats
--
-- ../pokecrystal/engine/pokemon/move_mon.asm CalcMonStats is what produced the
-- bytes in parties.asm, so the port's own formula has to land on them.
do
for _, mon in ipairs(GROUP1) do
local dvs = { attack = mon.dvs.attack, defense = mon.dvs.defense,
speed = mon.dvs.speed, special = mon.dvs.special }
local stats = Mon.stats(POKEMON[mon.species].baseStats, dvs, mon.level,
mon.statExp)
eq(stats.hp, mon.stats.hp, mon.species .. " HP matches the ROM")
eq(stats.attack, mon.stats.attack, mon.species .. " Attack matches")
eq(stats.defense, mon.stats.defense, mon.species .. " Defense matches")
eq(stats.speed, mon.stats.speed, mon.species .. " Speed matches")
eq(stats.specialAttack, mon.stats.specialAttack,
mon.species .. " Sp.Atk matches")
eq(stats.specialDefense, mon.stats.specialDefense,
mon.species .. " Sp.Def matches")
end
end
-- ================================================ roster lookup and gating
do
eq(BattleTower.roster(DATA), ROSTER, "the roster comes off trainers.lua")
eq(BattleTower.roster({ trainers = {} }), nil,
"a Gold cache has no battleTower block")
eq(BattleTower.roster({ trainers = { battleTower = { trainers = {} } } }), nil,
"and half a block is not one either")
local save = crystalSave(0)
eq(BattleTower.opponentGroup(save, ROSTER), 1,
"a level group of 0 cannot index before the table")
save.battleTower.levelGroup = 9
eq(BattleTower.opponentGroup(save, ROSTER), 2,
"nor past the last group the roster carries")
save.battleTower.levelGroup = 2
eq(BattleTower.opponentGroup(save, ROSTER), 2, "a real group is kept")
end
-- ============================================== the trainer draw and sBTTrainers
do
local save = crystalSave()
local trainer = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 }))
eq(trainer.name, "SAWYER", "the second row is drawn")
eq(save.battleTower.trainers[1], 1,
"and recorded in sBTTrainers[sNrOfBeatenBattleTowerTrainers]")
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:44-51: the
-- streak list is what the reroll refuses.
save.battleTower.streak = 1
local again = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 }))
check(again.name ~= "SAWYER", "a trainer already in the streak is refused")
eq(save.battleTower.trainers[2], again.index,
"and the new one lands in the next slot")
-- :29-37, Crystal 1.0's ceiling: with sampleTrainers 2 only the first two
-- rows can ever come out, however the roll lands.
local capped = { partyLength = 3, levelGroups = 1, uniqueMon = #GROUP1,
uniqueTrainers = 4, sampleTrainers = 2, trainers = ROSTER.trainers,
groups = { GROUP1 }, classSprites = ROSTER.classSprites }
local seen = {}
for roll = 1, 8 do
local fresh = crystalSave()
seen[BattleTower.chooseTrainer(fresh, capped, rolls({ roll })).index] = true
end
eq(seen[2], nil, "row 2 is past the 1.0 ceiling and never drawn")
eq(seen[3], nil, "nor row 3")
check(seen[0] and seen[1], "the first two rows both are")
end
-- ================================================== the three-mon team draw
do
local save = crystalSave()
local team = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 }))
eq(#team, 3, "three mons come out")
eq(team[1].species, "JOLTEON", "the first roll takes the first row")
-- :131-136, the species and item collisions: JOLTEON is out, and so is
-- KANGASKHAN, which holds JOLTEON's MIRACLEBERRY.
eq(team[2].species, "ESPEON",
"the second roll skips the species already picked")
eq(team[3].species, "UMBREON", "and so does the third")
local held = {}
for _, mon in ipairs(team) do
eq(held[mon.item], nil, mon.species .. " brings an unheld item")
held[mon.item] = true
end
-- :149-166 and :195-206: the last two teams' species are refused too.
eq(save.battleTower.prevTeams.prev[1], "JOLTEON",
"the drawn team becomes sBTMonPrevTrainer")
eq(#save.battleTower.prevTeams.prevPrev, 0,
"with nothing displaced into sBTMonPrevPrevTrainer yet")
local second = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 }))
eq(second[1].species, "WOBBUFFET", "the next team skips the previous one")
eq(second[2].species, "KANGASKHAN", "and keeps skipping it")
eq(save.battleTower.prevTeams.prevPrev[1], "JOLTEON",
"and the old team shifts down to sBTMonPrevPrevTrainer")
-- Five rows cannot field three fresh species twice running, which is
-- exactly where the cart's `jr z, .FindARandomBattleTowerMon` would spin
-- forever: the port takes the unfiltered draw instead and still hands back
-- a full team. Twenty-one rows never reach it.
eq(#second, 3, "an exhausted pool still fields three rather than hanging")
eq(second[3].species, "JOLTEON", "the third falls back to a plain roll")
end
-- ============================================== the party the battle fights
do
local party = BattleTower.battleParty(DATA, { GROUP1[1], GROUP1[3] })
eq(#party, 2, "one battle mon per roster row")
local jolteon = party[1]
eq(jolteon.species, "JOLTEON", "species")
eq(jolteon.name, "JOLTEON", "and the species name, not the ROM nickname")
eq(jolteon.nickname, nil, "which is why no nickname is carried")
eq(jolteon.level, 10, "level")
eq(jolteon.item, "MIRACLEBERRY", "held item")
eq(jolteon.happiness, 100, "happiness")
eq(jolteon.maxHp, 41, "max HP off the stored bytes")
eq(jolteon.hp, 41, "at full")
eq(jolteon.stats.speed, 37, "stored Speed")
eq(jolteon.stats.specialDefense, 31, "stored Sp.Def")
eq(jolteon.dvs.attack, 13, "the row's own DVs, not a roll")
eq(#jolteon.moves, 4, "four moves")
eq(jolteon.moves[2].id, "HYPER_BEAM", "move two")
eq(jolteon.moves[2].pp, 5, "with the row's PP")
eq(jolteon.moves[2].maxPp, 5, "and the move's own maximum")
eq(party[2].species, "UMBREON", "and the second row follows")
-- Mon.new mutates the dvs table it is handed, so a second build has to see
-- the roster row untouched.
local again = BattleTower.battleParty(DATA, { GROUP1[1] })
eq(again[1].stats.attack, 25, "a second build reads the same row")
eq(GROUP1[1].dvs.hp, nil, "and leaves the roster row alone")
end
-- ============================== LoadOpponentTrainerAndPokemonWithOTSprite
do
local painted = {}
local save = crystalSave()
local vm = fakeVm(save, {
setObjectSprite = function(object, sprite)
painted[#painted + 1] = { object = object, sprite = sprite }
end,
})
vm.scriptVar = 2
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
eq(vm.scriptVar, 2, "wScriptVar still names the object, unwritten")
check(vm.btOpponent ~= nil, "the opponent is drawn onto the VM")
eq(vm.btOpponent.name, "HANSON", "the trainer's own name")
eq(vm.btOpponent.classId, "FISHER", "its class")
eq(#vm.btOpponent.rows, 3, "with three mons")
eq(#painted, 1, "the map object is repainted once")
eq(painted[1].object, 2, "the object wScriptVar named")
eq(painted[1].sprite, "SPRITE_FISHER",
"with BTTrainerClassSprites[class - 1]")
eq(save.battleTower.streak, 0, "the draw does not step the streak")
-- A save is all it needs; a world with no sprite hook still draws.
local bare = crystalSave()
local plain = fakeVm(bare, {})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(plain)
check(plain.btOpponent ~= nil, "and the draw survives a missing hook")
-- A cache with no roster leaves nothing behind rather than half an opponent.
local goldVm = fakeVm(crystalSave(), { data = function() return {} end })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(goldVm)
eq(goldVm.btOpponent, nil, "a cache with no roster draws nobody")
end
-- ============================================================ BattleTowerBattle
local function towerBattle(save, outcome, extra)
local log = { heals = 0, started = nil }
local hooks = {
healParty = function() log.heals = log.heals + 1 end,
startTowerBattle = function(spec, done)
log.started = spec
log.healsAtStart = log.heals
done(outcome)
return true
end,
}
for key, value in pairs(extra or {}) do hooks[key] = value end
local vm = fakeVm(save, hooks)
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
Specials.ALL.BattleTowerBattle(vm)
return vm, log
end
do
local save = crystalSave()
local vm, log = towerBattle(save, "win")
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:236-237
eq(vm.scriptVar, 0, "a win leaves wBattleResult 0 in wScriptVar")
eq(log.heals, 2, ":228 and :235, HealParty on both sides of the battle")
eq(log.healsAtStart, 1, "the first one before the send-out")
check(log.started ~= nil, "the battle was started")
eq(log.started.name, "FISHER HANSON", "named class-then-trainer")
eq(log.started.trainerName, "HANSON", "with the bare name kept")
eq(log.started.classId, "FISHER", "and the class key the pic reads")
eq(log.started.class, 37, "and its constant")
eq(log.started.baseMoney, 40, "the class's own payout multiplier")
eq(#log.started.party, 3, "three mons walk in")
eq(log.started.party[1].species, "JOLTEON", "the drawn team, built")
eq(log.started.party[1].maxHp, 41, "with the roster's stats")
-- :549-570 CopyBTTrainer, which is what steps the counter.
eq(save.battleTower.streak, 1, "the streak steps once")
eq(save.battleTower.challenge, BattleTower.CHALLENGE_IN_PROGRESS,
"and the challenge is now in progress")
-- :240-250, the byte maps/BattleTowerBattleRoom.asm:38 reads back.
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 1,
"wNrOfBeatenBattleTowerTrainers holds the new count")
eq(vm.stringBuffer, "2", "and wStringBuffer3 the NEXT opponent's number")
eq(vm.btBattleEnded, 1, "wBattleTowerBattleEnded ends the loop")
eq(vm.btOpponent, nil, "and the drawn opponent is spent")
end
do
local save = crystalSave()
save.battleTower.streak = 6
local vm = towerBattle(save, "win")
eq(save.battleTower.streak, 7, "the seventh win reaches BATTLETOWER_STREAK_LENGTH")
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 7,
"which is what the room script's `ifequal 7` reads")
end
do
local save = crystalSave()
local vm, log = towerBattle(save, "lose")
eq(vm.scriptVar, 1, "a loss leaves wBattleResult 1")
eq(log.heals, 2, "and still heals on both sides")
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], nil,
":238-239 skips the counter copy when the battle was lost")
eq(vm.stringBuffer, "", "and prints no next-opponent number")
eq(save.battleTower.streak, 1,
"the counter still stepped, because ReadBTTrainerParty ran")
end
do
-- No hook at all: the challenge has to END rather than loop on an opponent
-- that never appears.
local save = crystalSave()
local vm = fakeVm(save, {})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.BattleTowerBattle(vm)
eq(vm.scriptVar, 1, "an unwired world reports a loss")
eq(vm.btBattleEnded, 1, "and ends the tower loop")
local goldVm = fakeVm(crystalSave(), { data = function() return {} end })
Specials.ALL.BattleTowerBattle(goldVm)
eq(goldVm.scriptVar, 1, "so does a cache with no roster")
end
do
-- The special draws its own opponent when the room script never loaded one.
local save = crystalSave()
local log
local vm = fakeVm(save, {
healParty = function() end,
startTowerBattle = function(spec, done) log = spec; done("win"); return true end,
})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.BattleTowerBattle(vm)
check(log ~= nil and #log.party == 3,
"BattleTowerBattle alone still fields an opponent")
end
-- ============================================ InitBattleTowerChallengeRAM
do
local save = crystalSave()
local vm = fakeVm(save, { pushScreen = function() return false end })
vm.mem[BattleTower.WRAM_NR_BEATEN] = 4
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 0,
":193 zeroes wNrOfBeatenBattleTowerTrainers with the rest")
eq(vm.btBattleEnded, 0, "and wBattleTowerBattleEnded with it")
end
Specials.random = math.random
S.finish()
+629
View File
@@ -0,0 +1,629 @@
-- The Battle Tower: engine/events/battle_tower/battle_tower.asm,
-- engine/events/battle_tower/rules.asm and the room menu at
-- mobile/mobile_46.asm:137.
--
-- luajit tests/gen2_battle_tower_test.lua
--
-- ROM-free. The lobby rules, the challenge-state machine, the level/uber
-- room gates, the reward roll and the wInBattleTowerBattle badge guard, all
-- against fixtures.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle tower")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Battle = require("src.battle.gen2.Battle")
local BattleTower = require("src.core.gen2.BattleTower")
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
local Mon = require("src.battle.gen2.Mon")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
local Specials = require("src.script.gen2.Specials")
local A = BattleTower.ACTIONS
-- ---------------------------------------------------------------- fixtures
local ITEM_ORDER = {}
do
-- ../pokecrystal/constants/item_constants.asm: only the span the reward roll
-- walks has to be at its real index, so the filler carries the rest.
for i = 1, 250 do ITEM_ORDER[i] = "FILLER_" .. i end
ITEM_ORDER[18] = "POTION"
ITEM_ORDER[26] = "HP_UP"
ITEM_ORDER[27] = "PROTEIN"
ITEM_ORDER[28] = "IRON"
ITEM_ORDER[29] = "CARBOS"
ITEM_ORDER[30] = "LUCKY_PUNCH"
ITEM_ORDER[31] = "CALCIUM"
end
local DATA = {
gen2Constants = { itemOrder = ITEM_ORDER },
items = {
POTION = { index = 18, pocket = "ITEM" },
HP_UP = { index = 26, pocket = "ITEM" },
CALCIUM = { index = 31, pocket = "ITEM" },
},
}
local function mon(species, level, item, isEgg)
return { species = species, level = level, item = item, isEgg = isEgg }
end
local function crystalSave()
local save = { version = "crystal", player = { id = 7, badges = {} },
party = {}, inventory = {} }
return save
end
-- The hooks World:specialHooks hands a handler, stubbed.
local function fakeVm(opts)
opts = opts or {}
local vm = {
scriptVar = opts.scriptVar or 0,
specials = opts.hooks or {},
pages = {},
stringBuffer = "",
}
function vm:setStringBuffer(value) self.stringBuffer = value or "" end
function vm:showRaw(body)
body = tostring(body)
if self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer)
end
self.pages[#self.pages + 1] = body
end
return vm
end
local function towerVm(save, extra)
local hooks = {
save = function() return save end,
data = function() return DATA end,
party = function() return save.party end,
itemIndex = function(id)
for index, name in ipairs(ITEM_ORDER) do
if name == id then return index end
end
return 0
end,
}
for key, value in pairs(extra or {}) do hooks[key] = value end
return fakeVm({ hooks = hooks })
end
local function action(vm, id)
vm.scriptVar = id
Specials.ALL.BattleTowerAction(vm)
return vm.scriptVar
end
-- ============================================== the seam and the ownership
do
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:181-259 and
-- :1534-1576 joined the list once the roster reached trainers.lua; their own
-- behaviour is tests/gen2_battle_tower_battle_test.lua.
for _, name in ipairs({ "BattleTowerAction", "CheckForBattleTowerRules",
"Menu_ChallengeExplanationCancel", "BattleTowerRoomMenu",
"BattleTowerBattle", "LoadOpponentTrainerAndPokemonWithOTSprite" }) do
eq(Specials.HANDLER_SOURCE[name], "specials/battle_tower.lua",
name .. " is owned by specials/battle_tower.lua")
check(type(Specials.ALL[name]) == "function",
name .. " resolves to a function")
eq(Specials.STUBS[name], nil, name .. " is no longer a stub")
end
for _, name in ipairs({ "BattleTowerMobileError" }) do
check(type(Specials.STUB_REASONS[name]) == "string",
name .. " is still a deliberate stub, with its reason")
end
local found = false
for _, id in ipairs(Screens.GEN2_IDS) do
if id == "Gen2BattleTowerMenu" then found = true end
end
check(found, "Gen2BattleTowerMenu joined Screens.GEN2_IDS")
end
-- ================================================= _CheckForBattleTowerRules
do
local legal = { mon("PIKACHU", 40, "BERRY"), mon("GEODUDE", 40, "GOLD_BERRY"),
mon("HORSEA", 40) }
local lines, failed = BattleTower.checkRules(legal)
eq(#lines, 0, "a legal three-mon party prints nothing")
eq(failed, false, "and _CheckForBattleTowerRules returns no carry")
-- Two itemless mons do not collide: `ld a, [hl] / and a / jr z, .next`
-- drops a zero value before the inner scan ever runs.
local bare = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }
eq(#(BattleTower.checkRules(bare)), 0,
"three mons holding nothing is legal")
lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40) })
eq(table.concat(lines, "|"),
"_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|"
.. "_BattleTowerReturnWhenReadyText",
"two mons: the header, the count line and the tail")
lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("PIKACHU", 40),
mon("HORSEA", 40) })
eq(lines[2], "_TheMonMustAllBeDifferentKindsText",
"a duplicate species is the second check")
lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"),
mon("GEODUDE", 40, "BERRY"), mon("HORSEA", 40) })
eq(lines[2], "_TheMonMustNotHoldTheSameItemsText",
"a duplicate held item is the third")
lines = BattleTower.checkRules({ mon("PIKACHU", 40), mon("GEODUDE", 40),
mon("ODD_EGG", 5, nil, true) })
eq(lines[2], "_YouCantTakeAnEggText", "an egg is the fourth")
-- An egg is skipped by BOTH uniqueness walks (CheckPartyValueIsUnique's
-- `.isegg` guards each side), so it only trips its own check.
lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"),
mon("PIKACHU", 40, "BERRY", true), mon("HORSEA", 40) })
eq(table.concat(lines, "|"),
"_ExcuseMeYoureNotReadyText|_YouCantTakeAnEggText|"
.. "_BattleTowerReturnWhenReadyText",
"an egg duplicating a species and an item still only fails the egg rule")
-- Every check runs; BattleTower_ExecuteJumptable does not stop at the first.
lines = BattleTower.checkRules({ mon("PIKACHU", 40, "BERRY"),
mon("PIKACHU", 40, "BERRY"), mon("HORSEA", 40, "BERRY"),
mon("ODD_EGG", 5, nil, true) })
eq(table.concat(lines, "|"),
"_ExcuseMeYoureNotReadyText|_OnlyThreeMonMayBeEnteredText|"
.. "_TheMonMustAllBeDifferentKindsText|"
.. "_TheMonMustNotHoldTheSameItemsText|_YouCantTakeAnEggText|"
.. "_BattleTowerReturnWhenReadyText",
"four failures print the header, all four lines and the tail, in order")
end
-- ---- the special itself
do
local save = crystalSave()
save.party = { mon("PIKACHU", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }
local vm = towerVm(save)
Specials.ALL.CheckForBattleTowerRules(vm)
eq(vm.scriptVar, 0, "a legal party answers FALSE, the `ifnotequal FALSE` arm")
eq(#vm.pages, 0, "and prints nothing")
save.party = { mon("PIKACHU", 40) }
vm = towerVm(save)
Specials.ALL.CheckForBattleTowerRules(vm)
eq(vm.scriptVar, 1, "a broken party answers TRUE")
eq(#vm.pages, 3, "and prints header, refusal and tail")
eq(vm.stringBuffer, "3", "wStringBuffer2 holds the '3' the lines splice in")
end
-- ============================================== the rooms and their gates
do
local save = crystalSave()
eq(#BattleTower.levelGroupRows(save), 4,
"before the Hall of Fame only L10-L40 are offered")
save.hallOfFame = { count = 1, teams = {} }
eq(#BattleTower.levelGroupRows(save), 10,
"and all ten once STATUSFLAGS_HALL_OF_FAME_F is set")
-- maps/BattleTowerHallway.asm:40-47, the room the receptionist walks to.
eq(BattleTower.roomOf(1), 0, "L10 is the 10/20 room")
eq(BattleTower.roomOf(2), 0, "L20 shares it")
eq(BattleTower.roomOf(3), 1, "L30 is the 30/40 room")
eq(BattleTower.roomOf(4), 1, "L40 shares it")
eq(BattleTower.roomOf(9), 4, "L90 is the 90/100 room")
eq(BattleTower.roomOf(10), 4, "L100 shares it")
local party = { mon("PIKACHU", 30), mon("GEODUDE", 30), mon("HORSEA", 30) }
eq(BattleTower.levelCheck(party, 3), false, "L30 mons fit the L30 room")
eq(BattleTower.levelCheck(party, 2), true, "and top the L20 room")
party[1].level = 31
eq(BattleTower.levelCheck(party, 3), true, "one mon over is enough")
local ubers = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) }
eq(BattleTower.ubersCheck(ubers, 4), "LUGIA",
"an uber is refused below the L70 rooms")
eq(BattleTower.ubersCheck(ubers, 7), nil,
"and allowed from the L70 room up")
ubers[1] = mon("MEWTWO", 70)
eq(BattleTower.ubersCheck(ubers, 6), nil,
"an uber already at L70 is past the `cp 70 / jr c` gate")
eq(BattleTower.ubersCheck({ mon("DRAGONITE", 40) }, 4), nil,
"and DRAGONITE is not on the list")
end
-- ==================================================== the reward (:906-976)
do
-- The `maskbits` roll is over eight values folded into six, so HP_UP and
-- PROTEIN come up twice as often; LUCKY_PUNCH is rerolled.
local seen = {}
for roll = 1, 8 do
local n = 0
local item = BattleTower.rollReward(ITEM_ORDER, function(mask)
n = n + 1
-- Specials.random answers 1..mask, and the handler folds it with % mask.
return (n == 1) and roll or 1
end)
seen[roll % 8] = item
end
eq(seen[1], "PROTEIN", "roll 1 is PROTEIN")
eq(seen[2], "IRON", "roll 2 is IRON")
eq(seen[3], "CARBOS", "roll 3 is CARBOS")
eq(seen[5], "CALCIUM", "roll 5 is CALCIUM")
eq(seen[0], "HP_UP", "roll 0 is HP_UP")
eq(seen[6], "HP_UP", "roll 6 folds back onto HP_UP")
eq(seen[7], "PROTEIN", "roll 7 folds back onto PROTEIN")
check(seen[4] ~= "LUCKY_PUNCH", "roll 4 lands on LUCKY_PUNCH and rerolls")
eq(BattleTower.rewardFits(19, 20, nil), true,
"a pocket with a free slot takes the five")
eq(BattleTower.rewardFits(20, 20, nil), false,
"a full pocket holding none of the reward cannot")
eq(BattleTower.rewardFits(20, 20, 94), true,
"a full pocket already holding 94 can stack five more")
eq(BattleTower.rewardFits(20, 20, 95), false,
"95 is where MAX_ITEM_STACK - 5 + 1 stops it")
end
-- ================================== BattleTowerAction, every jumptable row
do
local save = crystalSave()
local vm = towerVm(save)
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:855-887
local unhandled = {}
for id = 0, BattleTower.NUM_ACTIONS - 1 do
vm.scriptVar = id
local before = vm.scriptVar
local ok = pcall(Specials.ALL.BattleTowerAction, vm)
if not ok then unhandled[#unhandled + 1] = id end
if ok and vm.scriptVar == before and before ~= 0 then
-- a row that answers nothing must be one of the documented no-writes
local writesNothing = (id == A.SET_EXPLANATION_READ)
or (id == A.SAVE_AND_QUIT) or (id == A.CHALLENGECANCELED)
or (id == A.ACTION_06) or (id == A.SAVELEVELGROUP)
or (id == A.LOADLEVELGROUP) or (id == A.ACTION_0A)
or (id == A.ACTION_0C) or (id == A.ACTION_11) or (id == A.ACTION_12)
or (id == A.ACTION_15) or (id == A.ACTION_16) or (id == A.RESETDATA)
or (id == A.ACTION_1C) or (id == A.ACTION_1D)
or (id == A.CHOOSEREWARD) or (id == A.SAVEOPTIONS)
if not writesNothing then unhandled[#unhandled + 1] = id end
end
end
eq(table.concat(unhandled, ","), "",
"all 32 BattleTowerAction rows run, and only the cart's silent ones "
.. "leave wScriptVar alone")
end
-- ---- the challenge state machine (:992-1022, :935-949)
do
local save = crystalSave()
local vm = towerVm(save)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE,
"a fresh save is BATTLETOWER_NO_CHALLENGE")
action(vm, A.SAVE_AND_QUIT)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.SAVED_AND_LEFT,
"the quicksave arm parks on BATTLETOWER_SAVED_AND_LEFT")
action(vm, A.ACTION_1C)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.WON_CHALLENGE,
"beating the seventh trainer is BATTLETOWER_WON_CHALLENGE")
action(vm, A.ACTION_1D)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.RECEIVED_REWARD,
"and taking the prize is BATTLETOWER_RECEIVED_REWARD")
action(vm, A.CHALLENGECANCELED)
eq(action(vm, A.GET_CHALLENGE_STATE), BattleTower.NO_CHALLENGE,
"cancelling clears it")
end
-- ---- sBattleTowerSaveFileFlags (:978-1008, :1471-1492)
do
local save = crystalSave()
local tower = BattleTower.state(save)
eq(tower.saveFileFlags, 0, "a fresh save has no tower flags")
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION)
eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION), 2,
"`and 2` answers the masked byte, not a boolean")
eq(BattleTower.saveFileFlag(save, BattleTower.SAVEFILE_REGISTERED), 0,
"and bit 0 is untouched")
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_EXPLANATION)
eq(tower.saveFileFlags, 2, "`or 2` twice is still 2")
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_REGISTERED)
eq(tower.saveFileFlags, 3, "both bits live in the one byte")
-- :979-982 -- with no save file on disk the routine returns FALSE without
-- ever reading the flags.
local vm = towerVm(save)
eq(action(vm, A.CHECK_EXPLANATION_READ), 0,
"no save file means the explanation check answers FALSE")
eq(action(vm, A.CHECKSAVEFILEISYOURS), 0,
"and so does the save-file check itself")
end
-- ---- the level group (:1129-1155) and the GS Ball (:1179-1185)
do
local save = crystalSave()
local vm = towerVm(save)
vm.btLevelGroup = 7
action(vm, A.SAVELEVELGROUP)
eq(BattleTower.state(save).levelGroup, 7,
"SAVELEVELGROUP banks wBTChoiceOfLvlGroup in SRAM")
vm.btLevelGroup = nil
action(vm, A.LOADLEVELGROUP)
eq(vm.btLevelGroup, 7, "and LOADLEVELGROUP brings it back for the hallway")
eq(action(vm, A.GSBALL), 0, "no GS Ball, no scene")
Save.crystalState(save).gsBall = "have"
eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE,
"sGSBallFlag reads back as GS_BALL_AVAILABLE ($b)")
Save.crystalState(save).gsBall = "given"
eq(action(vm, A.GSBALL), BattleTower.GS_BALL_AVAILABLE,
"and stays set once the ball has been handed over")
end
-- ---- RESETDATA and the prize (:890-933, :955-976)
do
local save = crystalSave()
local tower = BattleTower.state(save)
tower.streak = 5
tower.trainers = { 3, 9 }
local vm = towerVm(save)
action(vm, A.RESETDATA)
eq(tower.streak, 0, "RESETDATA clears sNrOfBeatenBattleTowerTrainers")
eq(next(tower.trainers), nil, "and the seven sBTTrainers slots")
local rewards = {}
for _ = 1, 200 do
action(vm, A.CHOOSEREWARD)
rewards[tower.reward] = true
end
eq(rewards.POTION, nil,
"CHOOSEREWARD never falls back on POTION: it reads gen2Constants.itemOrder")
eq(rewards[BattleTower.SKIPPED_REWARD], nil, "and never rolls LUCKY_PUNCH")
for name in pairs(rewards) do
check(name == "HP_UP" or name == "PROTEIN" or name == "IRON"
or name == "CARBOS" or name == "CALCIUM",
name .. " is inside BATTLETOWER_MIN_REWARD..MAX_REWARD")
end
eq(rewards.HP_UP, true, "and the whole span comes up (HP_UP)")
eq(rewards.CALCIUM, true, "through CALCIUM")
tower.reward = "CALCIUM"
eq(action(vm, A.GIVEREWARD), 31, "GIVEREWARD answers the item id")
-- A full ITEM pocket that does not already hold the reward: the desk hands
-- over a POTION, which is the script's `ifequal POTION` arm.
for i = 1, 20 do save.inventory["FILLER_" .. i] = 1 end
eq(action(vm, A.GIVEREWARD), 18,
"a stuffed pack turns the prize into POTION")
end
-- ---- Menu_ChallengeExplanationCancel (mobile/mobile_5f.asm:425-468)
do
local save = crystalSave()
for row = 1, 3 do
local vm = towerVm(save, {
scriptMenu = function(_header, done) done(row) end,
})
vm.scriptVar = 1
Specials.ALL.Menu_ChallengeExplanationCancel(vm)
eq(vm.scriptVar, row, "row " .. row .. " comes back as wScriptVar")
end
local vm = towerVm(save, {
scriptMenu = function(_header, done) done(0) end,
})
vm.scriptVar = 1
Specials.ALL.Menu_ChallengeExplanationCancel(vm)
eq(vm.scriptVar, 4, "a B press is the `.Exit` arm's 4")
vm = towerVm(save)
vm.scriptVar = 1
Specials.ALL.Menu_ChallengeExplanationCancel(vm)
eq(vm.scriptVar, 4, "and so is a run with no menu to open")
end
-- ---- BattleTowerRoomMenu (battle_tower.asm:1-5)
do
local save = crystalSave()
save.hallOfFame = { count = 1, teams = {} }
local pushed
local vm = towerVm(save, {
pushScreen = function(id, opts)
pushed = id
opts.onDone(6)
return true
end,
})
Specials.ALL.BattleTowerRoomMenu(vm)
eq(pushed, "Gen2BattleTowerMenu", "the desk opens the room menu screen")
eq(vm.scriptVar, 0, "a chosen room answers 0, the script's `ifnotequal $0`")
eq(vm.btLevelGroup, 6, "and wBTChoiceOfLvlGroup holds the L60 room")
vm = towerVm(save, {
pushScreen = function(_id, opts)
opts.onDone(nil)
return true
end,
})
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.scriptVar, 0x0a, "cancelling answers $a, the desk's loop-back arm")
vm = towerVm(save)
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.scriptVar, 0x0a, "and so does a run with no screen to push")
end
-- ============================================ the room menu screen itself
do
eq(BattleTowerMenu.levelLabel(1), " L:10 ", "Strings_L10ToL100 row 1")
eq(BattleTowerMenu.levelLabel(10), " L:100", "and row 10, six tiles wide")
local pressed = {}
local game = { input = { wasPressed = function(_, name)
return pressed[name] == true
end } }
local function press(name) pressed = { [name] = true } end
local save = crystalSave()
save.hallOfFame = { count = 1, teams = {} }
local result, calls = nil, 0
local screen = BattleTowerMenu.new(game, {
save = save,
party = { mon("PIKACHU", 20), mon("GEODUDE", 20), mon("HORSEA", 20) },
onDone = function(value) result = value; calls = calls + 1 end,
})
eq(screen.cursor, 1, "the spinner opens on L:10")
eq(screen:rowCount(), 11, "ten rooms plus CANCEL")
press("up"); screen:update()
eq(screen.cursor, 2, "UP walks the level up")
press("down"); screen:update()
press("down"); screen:update()
eq(screen.cursor, 11, "DOWN off the top rolls over to CANCEL")
press("up"); screen:update()
eq(screen.cursor, 1, "and UP off CANCEL rolls back to L:10")
-- A party at L20 tops the L10 room (mobile/mobile_46.asm:3915-3917).
press("a"); screen:update()
eq(screen.phase, "message", "picking L:10 with L20 mons prints a refusal")
eq(calls, 0, "and does not answer the script")
for _ = 1, 0x80 do pressed = {}; screen:update() end
eq(screen.phase, "pick", "the $80-frame hold puts the menu back")
eq(screen.cursor, 1, "at jumptable index 0, cursor reset")
press("up"); screen:update()
press("a"); screen:update()
eq(result, 2, "L:20 is accepted and hands back the level group")
eq(calls, 1, "exactly once")
end
do
-- The uber gate, and the CANCEL path's yes/no.
local pressed = {}
local game = { input = { wasPressed = function(_, name)
return pressed[name] == true
end } }
local function press(name) pressed = { [name] = true } end
local save = crystalSave()
local result, answered = nil, false
local screen = BattleTowerMenu.new(game, {
save = save,
party = { mon("LUGIA", 40), mon("GEODUDE", 40), mon("HORSEA", 40) },
monName = function(id) return id end,
onDone = function(value) result = value; answered = true end,
})
eq(screen:rowCount(), 5, "no Hall of Fame, so four rooms plus CANCEL")
for _ = 1, 3 do press("up"); screen:update() end
eq(screen.cursor, 4, "on L:40")
press("a"); screen:update()
eq(screen.phase, "message", "an uber under L70 is refused below the L70 room")
check(screen.message:find("LUGIA", 1, true) ~= nil,
"and the refusal names it, the way text_ram wcd49 does")
for _ = 1, 0x80 do pressed = {}; screen:update() end
eq(screen.cursor, 1, "the refusal restarts the menu at L:10")
for _ = 1, 4 do press("up"); screen:update() end
eq(screen.cursor, 5, "CANCEL is the last row")
press("a"); screen:update()
eq(screen.phase, "quit", "CANCEL opens the yes/no")
eq(screen.yes, true, "on YES")
press("down"); screen:update()
press("a"); screen:update()
eq(screen.phase, "pick", "NO puts the level spinner back")
eq(answered, false, "and answers nothing yet")
press("b"); screen:update()
eq(screen.phase, "quit", "B on the spinner is the same cancel prompt")
press("a"); screen:update()
eq(answered, true, "YES ends the menu")
eq(result, nil, "with no level group, which the handler turns into $a")
end
-- ================================ wInBattleTowerBattle and the badge boosts
do
local GROWTH = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1,
squared = 0, linear = 0, constant = 0 } }
local BATTLE_DATA = {
pokemon = {
growthRates = GROWTH,
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} },
PIDGEY = { id = "PIDGEY", index = 16, name = "PIDGEY",
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} },
},
moves = {}, type_chart = { types = {}, matchups = {} }, items = {},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local BADGES = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
MINERAL = true, STORM = true, GLACIER = true, RISING = true }
local function newBattle(tower)
local player = Mon.new(BATTLE_DATA, "MACHOP", 40, { dvs = perfect })
local wild = Mon.new(BATTLE_DATA, "PIDGEY", 40, { dvs = perfect })
return Battle.new({ data = BATTLE_DATA, party = { player }, wild = wild,
save = { player = { id = 7, badges = BADGES, kantoBadges = {} } },
battleTower = tower,
random = function(n) return (n or 1) > 1 and 1 or 0 end }), player
end
local outside, player = newBattle(false)
eq(outside.inBattleTowerBattle, false, "an ordinary battle is not a Tower one")
eq(outside:battleStat(player, "attack"),
Battle.boostStat(player.stats.attack),
"outside the Tower ZEPHYRBADGE still boosts Attack")
eq(outside:battleStat(player, "speed"),
Battle.boostStat(player.stats.speed), "and PLAINBADGE Speed")
eq(outside:badgeTypeBoost(player, "FLYING"), true,
"and DoBadgeTypeBoosts still fires")
local inside, towerPlayer = newBattle(true)
eq(inside.inBattleTowerBattle, true, "the Tower battle sets it")
eq(inside:battleStat(towerPlayer, "attack"), towerPlayer.stats.attack,
"BadgeStatBoosts' second early return drops the Attack boost")
eq(inside:battleStat(towerPlayer, "defense"), towerPlayer.stats.defense,
"and Defense")
eq(inside:battleStat(towerPlayer, "speed"), towerPlayer.stats.speed,
"and Speed")
eq(inside:battleStat(towerPlayer, "specialAttack"),
towerPlayer.stats.specialAttack, "and Special Attack")
eq(inside:battleStat(towerPlayer, "specialDefense"),
towerPlayer.stats.specialDefense,
"and GLACIERBADGE's buggy Special Defense re-check with it")
eq(inside:badgeTypeBoost(towerPlayer, "FLYING"), false,
"DoBadgeTypeBoosts takes the same guard (engine/battle/misc.asm:152)")
-- The obedience ladder shares Battle:hasBadge and the cart does NOT guard
-- it (engine/battle/effect_commands.asm:671-696 reads wJohtoBadges raw).
eq(inside:obedienceLevel(), outside:obedienceLevel(),
"obedience reads the badges either way")
eq(inside:hasBadge("badges", "ZEPHYR"), true,
"and Battle:hasBadge itself is untouched")
end
-- ================================================ Gold and Silver are clean
do
for _, version in ipairs({ "gold", "silver" }) do
local save = Save.normalize({ version = version, generation = 2 })
eq(save.version, version, version .. " keeps its own version")
eq(save.battleTower, nil, version .. " grows no battleTower block")
eq(save.crystal, nil, "nor a crystal one")
end
local crystal = Save.normalize({ version = "crystal", generation = 2 })
eq(crystal.version, "crystal", "and the Crystal file stays Crystal")
check(type(crystal.battleTower) == "table",
"which does carry sBattleTowerChallengeState")
eq(crystal.battleTower.challenge, BattleTower.NO_CHALLENGE,
"starting at BATTLETOWER_NO_CHALLENGE")
end
S.finish()
+55
View File
@@ -0,0 +1,55 @@
-- TryToRunAwayFromBattle's battle-type ladder, which nothing covered:
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
-- FORCESHINY and SUICUNE, pokegold/engine/battle/core.asm:3476-3479 only the
-- first and third, and the values themselves come from
-- ../pokecrystal/constants/battle_constants.asm:91-103.
-- luajit tests/gen2_battletype_escape_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battletype escape")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Battle = require("src.battle.gen2.Battle")
-- ../pokecrystal/constants/battle_constants.asm:91-103, `const_def` from 0.
local NORMAL, CANLOSE, DEBUG, TUTORIAL = 0, 1, 2, 3
local FISH, ROAMING, CONTEST, FORCESHINY = 4, 5, 6, 7
local TREE, TRAP, FORCEITEM, CELEBI, SUICUNE = 8, 9, 10, 11, 12
eq(Battle.BATTLETYPE_CANLOSE, CANLOSE, "BATTLETYPE_CANLOSE is 1")
eq(Battle.BATTLETYPE_FORCESHINY, FORCESHINY, "BATTLETYPE_FORCESHINY is 7")
eq(Battle.BATTLETYPE_TRAP, TRAP, "BATTLETYPE_TRAP is 9")
eq(Battle.BATTLETYPE_CELEBI, CELEBI, "BATTLETYPE_CELEBI is 11, after FORCEITEM")
eq(Battle.BATTLETYPE_SUICUNE, SUICUNE, "BATTLETYPE_SUICUNE is 12")
local function refuses(value)
return Battle.noEscapeBattleType({ battleType = value }) == true
end
for _, row in ipairs({
{ NORMAL, false, "NORMAL" },
{ CANLOSE, false, "CANLOSE" },
{ DEBUG, false, "DEBUG" },
{ TUTORIAL, false, "TUTORIAL" },
{ FISH, false, "FISH" },
{ ROAMING, false, "ROAMING" },
{ CONTEST, false, "CONTEST" },
{ FORCESHINY, true, "FORCESHINY" },
{ TREE, false, "TREE" },
{ TRAP, true, "TRAP" },
-- ../pokecrystal/maps/TinTower1F.asm:120 and pokegold's Lugia and Ho-Oh both
-- arm FORCEITEM, which the ladder does not name: it is escapable.
{ FORCEITEM, false, "FORCEITEM" },
{ CELEBI, true, "CELEBI" },
{ SUICUNE, true, "SUICUNE" },
}) do
local value, want, name = row[1], row[2], row[3]
eq(refuses(value), want,
("%s (%d) %s escape"):format(name, value, want and "refuses" or "allows"))
end
check(refuses(nil) == false, "an unarmed battle escapes")
S.finish()
+71
View File
@@ -0,0 +1,71 @@
-- The two Ruins of Alph walls the FIELD MOVES open, at their production seams:
-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash
-- (ZEPHYRBADGE first, SpecialAerodactylChamber second, the darkness test last)
-- and :808-813 EscapeRopeOrDig's `.escaperope` arm.
-- luajit tests/gen2_chamber_fieldmoves_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 chamber field moves")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Events = require("src.world.gen2.Events")
local FieldMoves = require("src.world.gen2.FieldMoves")
local UnownWords = require("src.world.gen2.UnownWords")
local function ctxFor(mapId, badges, dark)
local events = Events.new()
return {
events = events,
save = { player = { badges = badges } },
dark = dark or false,
openAerodactylWall = function()
return UnownWords.aerodactylChamber(events, mapId)
end,
}, events
end
local AERO = UnownWords.CHAMBER_MAPS.AERODACTYL
-- :281-283, the badge gate ahead of the special.
local ctx, events = ctxFor(AERO, {}, false)
local refused = FieldMoves.fromMenu("FLASH", ctx)
eq(refused.ok, false, "no ZEPHYRBADGE: FLASH is refused in the chamber")
eq(refused.badge, "ZEPHYR", "and it is the badge that refused it")
check(not UnownWords.wallOpened(events, "AERODACTYL"),
"a badgeless press must not reach SpecialAerodactylChamber")
-- :284-287, the special's carry is a second way into `.useflash`.
ctx, events = ctxFor(AERO, { ZEPHYR = true }, false)
local used = FieldMoves.fromMenu("FLASH", ctx)
eq(used.ok, true, "with the badge FLASH runs in a chamber that is not dark")
eq(used.action, "flash", "and it queues the flash")
check(UnownWords.wallOpened(events, "AERODACTYL"),
"and the wall-opened flag is set")
-- :288-290, any other lit map still refuses.
ctx, events = ctxFor("RUINS_OF_ALPH_OUTSIDE", { ZEPHYR = true }, false)
eq(FieldMoves.fromMenu("FLASH", ctx).ok, false, "a lit route still refuses")
check(not UnownWords.wallOpened(events, "AERODACTYL"),
"and opens nothing")
-- A dark cave is the ordinary arm, with no chamber anywhere near it.
ctx = ctxFor("SLOWPOKE_WELL_B1F", { ZEPHYR = true }, true)
eq(FieldMoves.fromMenu("FLASH", ctx).ok, true, "a dark cave is still lit")
-- A cache with no chamber hook at all (Gold) must behave exactly as before.
eq(FieldMoves.fromMenu("FLASH", {
save = { player = { badges = { ZEPHYR = true } } }, dark = false }).ok, false,
"with no openAerodactylWall on the ctx, a lit map refuses")
-- ../pokecrystal/engine/events/unown_walls.asm:81, the escape rope's own arm.
local ropeEvents = Events.new()
eq(UnownWords.kabutoChamber(ropeEvents, "RUINS_OF_ALPH_OUTSIDE"), false,
"the rope opens nothing outside a chamber")
check(not UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag still clear")
eq(UnownWords.kabutoChamber(ropeEvents, UnownWords.CHAMBER_MAPS.KABUTO), true,
"and opens the Kabuto wall inside it")
check(UnownWords.wallOpened(ropeEvents, "KABUTO"), "flag set")
S.finish()
+139
View File
@@ -0,0 +1,139 @@
-- Crystal's animated front pics: the bitmask decode and the frame sequencer.
-- luajit tests/gen2_crystal_anim_test.lua
--
-- ROM-free. The fixtures are the shapes the extractor writes into
-- data/generated/pokemon.lua, with the numbers taken from BULBASAUR's own
-- bitmask/frames data so a failure names the bytes it disagrees with.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal anim")
local check, eq = S.check, S.eq
local MonAnim = require("src.render.MonAnim")
-- ---- bitmask decode -------------------------------------------------------
-- ../pokecrystal/gfx/pokemon/bulbasaur/bitmask.asm bitmask 0 and frame 1:
-- %01100000 %10101101 %00000001 %00000000, then $19..$20. The tool emits the
-- byte low bit first, so the eight set bits are tile positions 5, 6, 8, 10,
-- 11, 13, 15 and 16 in the pic's own column-major order.
local BULBASAUR = {
tiles = 5,
bitmasks = {
{ 0x60, 0xad, 0x01, 0x00 },
{ 0x20, 0xad, 0x01, 0x00 },
},
frames = {
{ bitmask = 1, tiles = { 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20 } },
{ bitmask = 2, tiles = { 0x21, 0x1b, 0x22, 0x1d, 0x1e, 0x23, 0x24 } },
},
play = { { 1, 10 }, { 2, 10 }, { 0, 5 } },
idle = { { 2, 5 }, { 0, 5 } },
}
local base = MonAnim.tileMap(BULBASAUR, 0)
eq(base and #base, 25, "the base picture is 5x5 tiles")
eq(base and base[1], 0, "position 0 is tile 0")
eq(base and base[25], 24, "position 24 is tile 24")
local first = MonAnim.tileMap(BULBASAUR, 1)
eq(first and #first, 25, "frame 1 is still 5x5 tiles")
local REPLACED = { [6] = 0x19, [7] = 0x1a, [9] = 0x1b, [11] = 0x1c,
[12] = 0x1d, [14] = 0x1e, [16] = 0x1f, [17] = 0x20 }
local wrong = nil
for slot = 1, 25 do
local want = REPLACED[slot] or (slot - 1)
if first[slot] ~= want then wrong = wrong or slot end
end
eq(wrong, nil, "frame 1 replaces exactly the eight bitmask-0 positions")
-- Bitmask 1 drops bit 6 and keeps bit 5, so frame 2 leaves position 6 alone.
local second = MonAnim.tileMap(BULBASAUR, 2)
eq(second and second[6], 0x21, "frame 2 replaces position 5")
eq(second and second[7], 6, "and leaves position 6 at its base tile")
eq(second and #BULBASAUR.frames[2].tiles, 7,
"seven set bits, seven replacement tiles")
eq(MonAnim.tileMap({ frames = {}, bitmasks = {} }, 0), nil,
"no pic size means no tile map")
eq(MonAnim.tileMap(BULBASAUR, 9), nil, "and a frame that does not exist is nil")
-- ---- durations ------------------------------------------------------------
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16).
eq(MonAnim.duration(10, 0), 10, "speed 0 leaves a duration alone")
eq(MonAnim.duration(10, 4), 12, "ANIM_MON_SLOW's speed 4 stretches 10 to 12")
eq(MonAnim.duration(5, 4), 6, "and 5 to 6")
eq(MonAnim.duration(200, 4), 250, "the arithmetic stays inside a byte")
-- ---- the sequencer --------------------------------------------------------
-- setrepeat 2 / frame 1, 3 / frame 2, 2 / dorepeat 1 -- the shape most of
-- Crystal's entrance animations have (../pokecrystal/gfx/pokemon/abra/anim.asm).
local LOOPED = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { MonAnim.SETREPEAT, 2 }, { 1, 3 }, { 2, 2 }, { MonAnim.DOREPEAT, 1 } },
idle = { { 2, 2 } },
}
local function timeline(data, scene, ticks)
local anim = MonAnim.new(data, scene)
local out = {}
for _ = 1, ticks do
anim:update()
out[#out + 1] = anim:currentFrame()
end
return out, anim
end
local frames, anim = timeline(LOOPED, "battle", 14)
eq(table.concat(frames, ","), "0,1,1,1,2,2,1,1,1,2,2,2,0,0",
"the looped script plays twice and lands back on the base picture")
check(anim:finished(), "and the scene is over after PokeAnim_Finish")
-- Setup costs a frame of its own before the script starts, so the first
-- animation frame is on tick 2, not tick 1.
eq(frames[1], 0, "PokeAnim_Setup shows the base picture for its own frame")
-- ANIM_MON_SLOW's speed 4 stretches every duration: 8 becomes 10.
local ONESHOT = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { 1, 8 } }, idle = { { 2, 2 } },
}
eq(table.concat(timeline(ONESHOT, "battle", 11), ","),
"0,1,1,1,1,1,1,1,1,0,0", "speed 0 holds frame 1 for eight frames")
eq(table.concat(timeline(ONESHOT, "battleSlow", 13), ","),
"0,1,1,1,1,1,1,1,1,1,1,0,0", "speed 4 holds it for ten")
-- ANIM_MON_MENU: the entrance animation, an 18-frame pause, then the idle.
local menu = MonAnim.new(ONESHOT, "menu")
local seen = {}
for tick = 1, 31 do
menu:update()
seen[tick] = menu:currentFrame()
end
eq(seen[10], 0, "the entrance script ends on the base picture")
local resume
for tick = 11, 31 do
if seen[tick] ~= 0 then resume = tick break end
end
eq(resume, 11 + MonAnim.SCENE_WAIT + 1,
"eighteen wait frames, then PokeAnim_Idle's own frame, then the idle script")
eq(resume and seen[resume], 2, "and the idle script's first frame comes up")
check(not menu:finished(), "the menu scene is longer than the battle one")
local ran = 0
for tick = 1, 60 do
menu:update()
if menu:finished() then ran = tick break end
end
check(ran > 0, "but it does end")
-- ---- gating ---------------------------------------------------------------
eq(MonAnim.new(nil, "battle"), nil, "no data means no sequencer")
eq(MonAnim.new({ tiles = 5, play = {} }, "battle"), nil,
"and an empty script means no sequencer, which is every Gold species")
eq(MonAnim.new(LOOPED, "nosuchscene"), nil, "an unknown scene is nil too")
S.finish()

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