Compare commits

..

30 Commits

Author SHA1 Message Date
bryanthaboi 0ca1defb66 Merge pull request #341 from bryanthaboi/dev
fix internet, fix the mf grass in advanced
2026-07-28 15:16:09 -04:00
bryanthaboi 370ddea0b8 fix the mf grass 2026-07-28 15:14:48 -04:00
bryanthaboi a03f69926e bazinga 2026-07-28 15:01:59 -04:00
bryanthaboi 04ece522d7 anbernic in build and disable gbcfx on it 2026-07-28 14:04:49 -04:00
bryanthaboi 5a48a61f2e anbernic included in next release 2026-07-28 12:49:04 -04:00
bryanthaboi fd66a04686 Merge pull request #338 from bryanthaboi/dev
squashing all bugs known to man
2026-07-28 12:24:13 -04:00
bryanthaboi b69f8c27c9 Merge pull request #332 from johnjohto/fix-hof-dex-rating
Run the HoF dex rating through the standard text box (#314)
2026-07-28 12:19:43 -04:00
bryanthaboi 66f0326492 Merge pull request #333 from johnjohto/fix-midstep-buttons
Gate overworld buttons on the completed step (#286)
2026-07-28 12:19:31 -04:00
bryanthaboi dbab4a2116 Merge pull request #334 from johnjohto/fix-export-fidelity
Export fidelity: name padding, wOptions, as-caught catchRate (#206)
2026-07-28 12:19:17 -04:00
bryanthaboi 3e8da9b232 Merge pull request #335 from johnjohto/feat-save-slot-names
Nameable save slots in the launcher (#205)
2026-07-28 12:19:05 -04:00
bryanthaboi 223202f230 Merge pull request #336 from GFlorio/fix-linux-portable
Fix portable mode on the Linux AppImage release
2026-07-28 12:18:48 -04:00
bryanthaboi 3078c20e1d Merge pull request #337 from bryanthaboi/squashing-bugs-jul-28
bug squash an additional features
2026-07-28 12:18:17 -04:00
bryanthaboi 617a87a47b anbernic 2026-07-28 12:14:58 -04:00
bryanthaboi 8539a6b268 launcher editor and widescreen battle 2026-07-28 12:00:15 -04:00
bryanthaboi f9f38d161f CLOSES #223, CLOSES #233, CLOSES #236, CLOSES #240, CLOSES #241, CLOSES #249, CLOSES #252, CLOSES #255, CLOSES #257, CLOSES #258, CLOSES #263, CLOSES #265, CLOSES #274, CLOSES #275, CLOSES #276, CLOSES #279, CLOSES #280, CLOSES #282, CLOSES #283, CLOSES #287, CLOSES #291, CLOSES #292, CLOSES #293, CLOSES #301, CLOSES #304, CLOSES #315, CLOSES #316, CLOSES #317, CLOSES #321, CLOSES #322, CLOSES #330 2026-07-28 10:29:05 -04:00
Gabriel Florio 8b85cda84c get proper appDir in the AppImage linux release 2026-07-28 10:58:42 -03:00
johnjohto 1deba07106 Nameable save slots in the launcher (#205)
The reporter labels runs by abusing the in-game player name; give slots
a real label instead.  SaveData.renameSlot persists a trimmed label in
the options registry (options.saveSlots[version].names) -- never in the
save file, so renaming needs no save rewrite and an empty slot can be
labeled too -- listSlots rows carry it as `label`, and deleteSlot
drops it with the slot.

In the launcher, right-clicking a slot row opens an inline rename modal
(Enter commits, Esc cancels, empty clears; 24 whole-codepoint cap via
local UTF-8 helpers, since plain luajit has no utf8 library).  The row
title shows the label over the player name; badges/time/caught stay on
the meta line.  Desktop-only: touch has no secondary button.  main.lua
now forwards love.textinput to the importer while it is up.

Backend covered by a new renameSlot block in tests/engine/save_slots.lua
(78/78); docs/launcher.md's registry section documents the label.
2026-07-28 09:25:55 -04:00
johnjohto c24c7cbcb7 Export fidelity: name padding, wOptions, as-caught catchRate (#206)
Auditing the reporter's PKHeX screenshots against the emitted bytes
turned up three fidelity bugs in the .sav export:

- Name fields on a templateless export stayed zero-filled after the
  $50 terminator; every real save the naming screen wrote $50-pads
  the tail, and the zero tail is what PKHeX rendered as "JOHN{}".
  encodeName now pads zero tail bytes with $50 (nonzero template
  bytes still survive untouched).
- wOptions was never written, dropping text speed / battle style /
  battle effects on export.  Templateless exports now pack it from
  save.options (the recomp's textSpeed 1/3/5 are pokered's exact
  values); with a template the cartridge's own byte still wins.
- The party/box catch-rate byte was always re-derived from the
  current species, but Gen1 freezes it at catch time (evolution does
  not update it), which is why PKHeX demanded "a preevolution catch
  rate".  Pokemon.new now stamps the as-caught catchRate and
  Evolution.apply's in-place mutation preserves it.

The reported emulator crash itself is not addressed here: the encoder
is byte-identical to the reporter's version, and a byte-level audit
(offsets vs Bulbapedia's save map, all three checksums, a real
gameplay save round-trip, pokered's LoadMapData regenerating every
zeroed cache) shows the current export is structurally valid.
2026-07-28 09:00:07 -04:00
johnjohto c2334f2f91 Gate overworld buttons on the completed step (#286)
OverworldLoop (home/overworld.asm) jumps straight to .moveAhead while
wWalkCounter is nonzero: JoypadOverworld -- the START check, the A
check, and direction initiation -- only ever runs while the player
stands on a tile, and a button pressed mid-step is simply never seen.
The port ran handleInput() every frame regardless, so a mid-step
A/START pushed its TextBox/StartMenu right there and froze Red between
tiles, mid-animation (the Nurse Joy run-up in the report).

Gate handleInput on player.moving like the original.  The port-invented
wall-bonk SFX cooldown is hoisted above the gate so it keeps ticking on
held-direction frames mid-step exactly as before.

Adds tests/parity_midstep_buttons.lua: mid-step and last-frame A/START
presses are swallowed and the step completes, and both buttons work
again once the player stands on the tile.
2026-07-28 08:07:17 -04:00
johnjohto bfac5fe327 Run the HoF dex rating through the standard text box (#314)
HoFDisplayPlayerStats prints its three dex texts (seen/owned, the
POKéDEX Rating: header, the tier line) through HoFPrintTextAndDelay ->
PrintText, the standard two-row bottom box, each followed by 120
DelayFrames.  The port hand-drew a 6-row box and flattened the tier
text's cont (\v) rows into plain newlines, so a 3+ row rating painted
its third row over the box's bottom border and dropped the rest.

Push the three texts as a chain of auto-advancing TextBox states
instead: the cont rows scroll inside the two-row box with the original's
A/B wait (_ContText -> ManualTextScroll), and each box closes itself
after the 120-frame hold.

Adds tests/parity_hof_rating.lua: box order/content and the auto-close
hold are asserted over a full headless induction.
2026-07-28 07:32:35 -04:00
bryanthaboi 3b1032cc63 CLOSES #303, CLOSES #307, CLOSES #314, CLOSES #318 2026-07-28 06:16:21 -04:00
bryanthaboi 72f83760b7 Merge pull request #328 from johnjohto/fix-fieldmove-whiteflash
Run the field-move white blink under its text, not after (#320)
2026-07-28 05:10:06 -04:00
bryanthaboi 63a179b77a Merge pull request #326 from johnjohto/fix-windows-picker-unicode
Fix launcher crash on non-ASCII picked filenames (#325)
2026-07-28 05:09:51 -04:00
bryanthaboi 37ba5c3879 Merge pull request #323 from johnjohto/fix-shake-name-dupe
Stop HUD names ghosting during the battle window shake (#295)
2026-07-28 05:09:21 -04:00
bryanthaboi 00f86ca192 Merge pull request #324 from johnjohto/fix-oakspeech-strings-gate
Route the Oak speech fallback texts through Strings.source
2026-07-28 05:08:59 -04:00
johnjohto b0868e7571 Run the field-move white blink under its text, not after (#320)
The vanilla surf/strength flow closed the menu, showed the overworld,
then fired a solid-white blink on the empty map, and set the surfing
sprite while the player was still on land.  pokered's
GBPalWhiteOutWithDelay3 runs while the text is still up, so the blink
reads as a text flash.

The blink now sits under the textbox on the stack (only the top state
updates, so it holds its frames until the text closes), and surfing
applies only when the step onto the water happens.  Same reorder for
the party-menu STRENGTH texts.  The parity surf tests now dismiss the
got-on text before asserting the mount, matching the new order.
2026-07-27 22:42:47 -04:00
johnjohto 9ec3ff26d8 Fix launcher crash on non-ASCII picked filenames (#325)
The Windows pickers shell out to PowerShell, which writes the chosen
path in the console's OEM codepage (CP437 on en-US): a file named
"Pokémon ...zip" came back as Pok\x82mon.  The import then failed and
the error notice carrying those bytes hard-crashed the mods panel's
UTF-8-validating text draw.

- All three Windows picker scripts (ROM, mod, save) now force
  [Console]::OutputEncoding to UTF-8, so returned paths and any notice
  built from them are valid.
- The mod picker also copies the pick to a plain-ASCII temp name and
  returns that, so a non-ASCII filename actually imports instead of
  failing the io.open (Windows io.open needs ANSI bytes).
2026-07-27 22:08:57 -04:00
johnjohto 726e1d4ec5 Route the Oak speech fallback texts through Strings.source
The intro API upgrade left six player-visible literals in OakSpeech's
FALLBACKS table outside the Strings routing, so the strings-coverage
gate failed on dev. Mark them with Strings.source, the catalog idiom
for declaration-site literals, and the gate is green again.
2026-07-27 20:46:06 -04:00
johnjohto 5b3c5f5da8 Stop HUD names ghosting during the battle window shake (#295)
The zone pass composited two copies of the baked canvas on shake
frames: a base copy and the offset copy. The canvas holds window-layer
content (HUD names, the text box), so the vacated strip showed a full
second copy of the enemy name. Draw only the shifted copy and fill the
strip blank, like the hardware revealing empty BG.
2026-07-27 20:44:09 -04:00
bryanthaboi c45f6be8f2 Update README.md 2026-07-27 20:36:30 -04:00
188 changed files with 19697 additions and 2666 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ body:
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
+1 -1
View File
@@ -10,7 +10,7 @@ body:
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
+17 -2
View File
@@ -1,7 +1,8 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -169,6 +170,13 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
# runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
run: |
set -euo pipefail
@@ -218,6 +226,12 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
@@ -283,6 +297,7 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
+68 -23
View File
@@ -1,13 +1,13 @@
# Pokemon Gen 1 Recompilation Project
# Gen1Recomp
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input.
The ROM is verified, used during import, and then released from memory. It is
@@ -33,21 +33,24 @@ audio channel programs copied out of the verified ROM.
## Controls
| Action | Keyboard | Controller |
|--------|----------|------------|
| ------ | ----------------- | ------------------ |
| Move | Arrow keys / WASD | D-pad / left stick |
| A | Z / Enter / Space | A |
| B | X / Backspace | B |
| Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
supported out of the box.
### Hotkeys
| Key | What it does |
|-----|----------------|
| --------- | ---------------------------------------------------- |
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
@@ -57,22 +60,55 @@ supported out of the box.
| `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
Both rulesets share the same damage formulas; they differ only in whether
the original's quirks are kept. The setting persists in `options.lua`, and
mods can register their own.
`gen1_faithful` is the default and reproduces the original cartridge,
famous bugs included:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
| `critIgnoresStages` | Crit rate ignores stat stages |
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
| `randMin` / `randMax` | Damage random factor 217-255 |
`modern_clean` keeps the formulas but removes the notorious quirks:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/run.sh
```
then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
## Portable Mode
@@ -80,7 +116,7 @@ By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the app
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package.
@@ -88,14 +124,21 @@ runs from a read-only package.
With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory.
that same folder instead of the OS save directory.
- A ROM import writes the generated `data/generated` and `assets/generated`
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along.
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## Modding
@@ -103,33 +146,35 @@ The game ships a native mod platform: content registries, events and hooks,
per-mod saves and options, and an in-game manager. The full modding book —
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
reference — lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
## Bugs and Ideas
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
that looks wrong, text in the wrong box, anything that does not match the
original game.
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
Attach a screenshot if you can. It saves a lot of back and forth, and if you
can't get one, the form asks you to describe what you saw instead.
Thought of a feature that could be good, or a way to improve one that already
exists?
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
Say what you want, why it is worth doing, and how you picture it working. A
request with real detail is one that can actually get built.
## More
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- `docs/architecture.md` — runtime details;
`docs/behavior-porting-notes.md` — formula provenance.
`docs/behavior-porting-notes.md` — formula provenance.
## Special Thanks
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
# + PortMaster).
#
# The stock Anbernic firmware resolves ports relative to the launch script
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
# device does not need a separate love_11.5 runtime download on first launch.
#
# Usage:
# ./build-rg34xxsp.sh [--version X.Y.Z]
#
# Output:
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
#
# Install on device:
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
# roms/PORTS/Gen1recomp.sh
# roms/PORTS/gen1recomp/...
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
# folder when zenity is missing).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/rg34xxsp"
WORK="$HERE/work/rg34xxsp"
DIST="$ROOT/dist/rg34xxsp"
APP_NAME="gen1recomp"
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
# 64-bit MOD for RG34XXSP). Release uploads stage it as
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
PORT_DIR_NAME="gen1recomp"
LAUNCHER_NAME="Gen1recomp.sh"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
command -v curl >/dev/null || fail "curl is required"
command -v zip >/dev/null || fail "zip is required"
command -v unzip >/dev/null || fail "unzip is required"
mkdir -p "$CACHE" "$WORK" "$DIST"
download() {
local url="$1" dest="$2"
if [ -f "$dest" ] && [ -s "$dest" ]; then
return 0
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|| fail "download failed: $url"
mv "$dest.tmp" "$dest"
}
# --------------------------------------------------------------- game tree
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
# main.lua and RomImporter can scan it without zenity/kdialog.
say "staging lovegame/"
GAME_SRC="$WORK/lovegame"
rm -rf "$GAME_SRC"
mkdir -p "$GAME_SRC"
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
# Stamp release version into the staged tree only (never the working tree).
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
"$GAME_SRC/src/core/Version.lua" \
|| fail "version stamp failed"
else
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
fi
# Portable marker: saves + ROM cache live next to the game on the SD card.
: > "$GAME_SRC/portable.txt"
# --------------------------------------------------------------- love runtime
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
LOVE_BIN="$CACHE/love.aarch64"
LOVE_LIB="$CACHE/liblove-11.5.so"
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
MODPLUG_LIB="$CACHE/libmodplug.so.1"
OGG_LIB="$CACHE/libogg.so.0"
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
# Sanity: love stub must be an aarch64 ELF.
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
# --------------------------------------------------------------- port tree
say "assembling port package"
PORT_ROOT="$WORK/port"
rm -rf "$PORT_ROOT"
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
"$PORT_ROOT/$PORT_DIR_NAME/conf"
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
# Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
see https://love2d.org/ for full terms.
EOF
# --------------------------------------------------------------- launcher
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
# casing and mount points (mmc / sdcard / TF1 / TF2).
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
# Uses SHDIR-relative paths so stock firmware finds the game folder.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
if [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/roms/PORTS/PortMaster"
fi
SHDIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# Mali / H700: prefer GLES where available
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
# persisted in options.lua.
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
fi
if type pm_platform_helper >/dev/null 2>&1; then
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi
./bin/love.aarch64 "$GAMEDIR/lovegame"
if type pm_finish >/dev/null 2>&1; then
pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi
EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
# --------------------------------------------------------------- metadata
cat > "$PORT_ROOT/port.json" <<EOF
{
"version": 2,
"name": "gen1recomp.zip",
"items": [
"$LAUNCHER_NAME",
"$PORT_DIR_NAME"
],
"items_opt": null,
"attr": {
"title": "gen1recomp",
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
"genres": ["adventure", "rpg"],
"porter": ["gen1recomp"],
"image": {},
"rtr": true,
"runtime": null,
"reqs": [],
"arch": ["aarch64"]
}
}
EOF
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./$LAUNCHER_NAME</path>
<name>gen1recomp</name>
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
<releasedate>20250101T000000</releasedate>
<developer>the bois club</developer>
<publisher>the bois club</publisher>
<genre>RPG</genre>
</game>
</gameList>
EOF
cat > "$PORT_ROOT/README.md" <<'EOF'
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
**Stock OS 64-bit MOD** (H700, PortMaster).
### Install
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
### Controls (launcher)
| Input | Action |
|--|--|
| D-pad / left stick | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Right stick | Scroll lists |
| Start / Select | Play or Choose ROM |
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
### Display options
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
`POKEPORT_GBCFX=1`.
### First run
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
**Choose ROM** — the game scans that folder. After import, the ROM-derived
cache and saves stay beside the game (`portable.txt`).
Canonical SHA-1 (1 MiB US carts only):
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
### Thanks
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
EOF
# --------------------------------------------------------------- zip
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
say "packing $ZIP_OUT"
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
say "done."
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
+6 -1
View File
@@ -13,7 +13,12 @@ function love.conf(t)
_G.POKEPORT_DEV_MODE = developer
if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
-- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an
-- empty directory in a packaged build, so `--editor` could not find
-- data/generated at all (SaveIO.defaultPath already assumed this name).
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
t.window.title = "Pokemon Save Editor"
t.window.width = 1280
t.window.height = 800
+22
View File
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
}
end
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
-- switch events clear (so those barriers are closed again on the next floor
-- load) and the boulder that fell through 3F's hole goes back upstairs:
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
-- is what makes the puzzle "completely reset" on a return trip (#258).
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
-- every map entry, connection crossings included, like EnterMap.
M.ROUTE_23 = {
onEnter = function(game, ow)
local f = game.save.flags
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
end,
talk = {
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
-- -> wWhichBadge = EARTHBADGE
+57 -15
View File
@@ -94,6 +94,27 @@ M.VIRIDIAN_CITY = {
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
},
},
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
-- but was never standing here when it fired: a vanilla .sav imported
-- through src/save_convert, whose codec does not model pokered's
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
-- both objects fall back to their compiled-in defaults -- the sleeper ON
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
-- still lying across the north path (#234).
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
-- re-applies the same script's other two HideObjects on entry (#106).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
end,
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
@@ -719,11 +740,20 @@ local function boulderAt(ow, x, y)
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
end
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
-- map record in Game.data, so the port has to put the closed block back
-- itself or a solved barrier stays open for the rest of the session (#258).
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
-- OverworldState:setMap.
M.VICTORY_ROAD_1F = {
onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
ow:replaceBlock(4, 6, 0x1D)
end
ow:replaceBlock(4, 6,
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
end,
onBoulderMoved = function(game, ow, npc)
if npc.cellX == 17 and npc.cellY == 13
@@ -736,12 +766,14 @@ M.VICTORY_ROAD_1F = {
M.VICTORY_ROAD_2F = {
onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
ow:replaceBlock(3, 4, 0x15)
end
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
ow:replaceBlock(11, 7, 0x1D)
end
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
-- again the next time you climb down (#258)
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
ow:replaceBlock(3, 4,
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
ow:replaceBlock(11, 7,
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
end,
onBoulderMoved = function(game, ow, npc)
if npc.cellX == 1 and npc.cellY == 16
@@ -759,9 +791,8 @@ M.VICTORY_ROAD_2F = {
M.VICTORY_ROAD_3F = {
onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
ow:replaceBlock(3, 5, 0x1D)
end
ow:replaceBlock(3, 5,
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
end,
onBoulderMoved = function(game, ow, npc)
if npc.cellX == 3 and npc.cellY == 5
@@ -769,12 +800,23 @@ M.VICTORY_ROAD_3F = {
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
ow:replaceBlock(3, 5, 0x1D)
end
-- the hole at (23,15): the boulder drops to 2F next to switch 2
if npc.cellX == 23 and npc.cellY == 15 then
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
-- VictoryRoad3FDefaultScript .handle_hole gates the swap on
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
-- matched no object_event name, so the toggle landed under a key
-- objectVisible never reads: harmless only while nothing hid that
-- boulder, and fatal once Route 23's reset does (#258).
if npc.cellX == 23 and npc.cellY == 15
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
+1 -1
View File
@@ -761,7 +761,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
+1 -1
View File
@@ -300,7 +300,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) #90 / #105.
-- instead of the car's ROM default (B1F) -- #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
+147 -19
View File
@@ -293,7 +293,11 @@ pewterEscort.guySteps = {
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
-- home any more, because its first step is LEFT through the player
-- parked on (11,18) (#241). See walkHome below.
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
@@ -371,20 +375,44 @@ local function pewterGymEscort(game, ow)
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
-- minus the +4 border offset object_event coords carry per
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
-- That lands him on (17,18), the last walkable cell before the fence at
-- (18,18) and one column past the screen edge with the player parked on
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
-- him back on his object_event spawn (35,16) facing DOWN, which is the
-- snap below (the vanish/reappear is off screen, same as the original).
--
-- The old code retraced guyReturnSteps instead, whose first step is
-- LEFT into (11,18) -- the cell the player is standing on -- and
-- scriptMove is a pure tween with no entity test, so he walked straight
-- through Red (#241). There is no honest route home: (17,18) is a
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
-- the original teleports. guyReturnSteps stays as the documented
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
-- Brock victory still HideObject's him.
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
if i > 5 then
-- SetSpritePosition2: same field writes as Commands.place_npc,
-- including the target clear -- a stale targetX/targetY would
-- leave OverworldState:npcAtCell reserving (17,18) forever and
-- silently wall the player out of that pocket.
guy.cellX, guy.cellY = 35, 16
guy.px, guy.py = 35 * 16, 16 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
ow:scriptMove(guy, "right", 1, tick)
end
tick()
end
@@ -468,23 +496,48 @@ end
-- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
-- tile the rival stands BELOW the player on (29,5) and leaves east along
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
-- step UP to row 4 to get around him. The port had the two branches
-- swapped, so the top tile started the walk with UP out of (28,4) into
-- the cliff cell (28,3), which is not walkable (#236).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
-- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
if py == 4 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
if py == 4 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
-- own row from the object_event spawn (25,5): the full four-RIGHT
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
-- then faces him UP on index 1 and RIGHT otherwise (#236).
local rx = (py == 4) and 29 or 28
local rivalFacing = (py == 4) and "up" or "right"
return {
{ "show_object", "ROUTE_22", objName }, -- 1
{ "move_npc_to", objIndex, 28, py }, -- 2
{ "face_object", objIndex, "right" }, -- 3
{ "move_npc_to", objIndex, rx, 5 }, -- 2
{ "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6
@@ -496,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
}
end
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
-- (the (29,5) tile, rival to his left) (#236).
M.ROUTE_22 = {
onStep = function(game, ow, x, y)
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
local f = game.save.flags
local playerFacing = (y == 4) and "down" or "left"
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
return runAmbush(game, ow,
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
end
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
end
return false
end,
@@ -545,7 +603,7 @@ end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack not a bare hide_object.
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
@@ -564,7 +622,7 @@ local rocketRows = {
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour the swap reconnects the city (Bill's ticket does
-- door neighbour -- the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
@@ -628,13 +686,83 @@ M.MUSEUM_1F = {
}
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
-- PewterPokecenterJigglypuffText). The text_asm sets
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
-- prints with no A prompt and the script keeps running underneath it:
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
-- last step closes the box, so the whole dance plays out unskippably
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
-- the song ends rather than 48 frames later.
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
-- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing.
local function jigglypuffDance(game, npc)
local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it
-- toward the player), and the first write is that same facing, so the
-- first visible quarter turn lands 24 frames in
local step = 1
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
if npc and npc.facing == dir then step = i end
end
local frames, phase = 0, "silence"
return {
sound = function()
Music.stop() -- SFX_STOP_ALL_MUSIC
return { isPlaying = function() return phase ~= "done" end }
end,
tick = function()
frames = frames + 1
if phase == "silence" then
if frames < JIGGLYPUFF_SILENCE then return end
frames = 0
if Music.playOnce(game.data, "Music_JigglypuffSong") then
phase = "spin"
else
-- no song def (or headless): Music.stop above already took the
-- map theme down and nothing armed pendingRestore, so put it
-- back by hand and fall through to the tail rather than hold
-- the box on a poll that would never clear
Music.restoreMap(game.data)
phase = "tail"
end
return
end
if phase == "spin" then
if frames < JIGGLYPUFF_STEP then return end
frames = 0
-- the loop tests the channels after the delay and before the next
-- write, so a song that just ended costs no extra quarter turn
if not Music.oneShotPlaying() then
phase = "tail"
return
end
step = step % #JIGGLYPUFF_SPIN + 1
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return
end
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
end,
}
end
M.PEWTER_POKECENTER = {
talk = {
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
push(game, text(game)._PewterPokecenterJigglypuffText
or "JIGGLYPUFF: Puu\npupuu!", done)
-- not the local push() helper: this box needs opts.auto, which is
-- also what suppresses the blinking arrow and the A dismissal
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) }))
end,
},
}
+1 -1
View File
@@ -16,7 +16,7 @@
-- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won checkVictoryRewards shows this chain instead of a
-- no header.won -- checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
+91
View File
@@ -0,0 +1,91 @@
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
(TF1).
## Install
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
`gen1recomp/` folder.
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
```
Roms/PORTS/Gen1recomp.sh
Roms/PORTS/gen1recomp/
```
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
folder.
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
```
Roms/PORTS/gen1recomp/lovegame/
```
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
The port ships with `portable.txt` already in place, so after import the
saves and ROM-derived cache stay next to the game on the SD card. See
[Portable Mode](../README.md#portable-mode) for what that means.
Only the canonical 1 MiB US carts import:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
## Launcher controls
| Input | Action |
| ------------------ | ------------------ |
| D-pad / left stick | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Right stick | Scroll lists |
| Start / Select | Play or Choose ROM |
In-game controls use the normal PortMaster / SDL pad map, rebindable under
**OPTIONS → CONTROLS**.
## Notes
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
level carried over in an `options.lua` from another machine. The H700's Mali
GPU is in the same class as the phone GPUs that compile that present pass and
then show a black frame (issue #136), and `love.system.getOS()` reports
`"Linux"` here, so the Android gate would not have caught it. Every other
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
paths relative to its own directory rather than PortMaster's `$directory`,
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
casing and mount points.
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
last run.
## Building the port
Release runs build this automatically (see `.github/workflows/release.yml`).
To build it by hand:
```sh
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
```
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
local testing.
Stock OS 64-bit MOD comes from
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
+1 -1
View File
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
## Map scripts
+9
View File
@@ -69,6 +69,15 @@ keeps working unchanged.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
Custom slot labels (#205) live alongside them in the same registry:
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
each `listSlots` row as `label` (the launcher row shows `label`, falling
back to the player name). `deleteSlot` drops the label with the slot.
Renaming never touches the save file, so an empty slot can be labeled.
On desktop, right-clicking a slot row opens the inline rename modal
(Enter commits, Esc cancels); touch has no secondary button, so the
affordance is desktop-only.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
+6 -6
View File
@@ -1,21 +1,21 @@
# Native modding
The modding book lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
— install a mod, write a first one, enable and disable it.
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
— twelve dependency-ordered rungs, each a runnable mod.
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
— task-sized recipes.
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
— every registry, generated from `src/mods/Schemas.lua`.
Regenerate the reference straight into a wiki checkout:
```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
```
## Rendering pipelines
+116 -6
View File
@@ -86,7 +86,7 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
@@ -97,8 +97,9 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`).
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
"RED++"; it is the richest colorization rather than anything Red-specific.)
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **SGB INV**: each SGB zone palette with shade order reversed.
@@ -109,7 +110,8 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
labels.
## GBC FX
@@ -127,6 +129,14 @@ It runs as a final present pass after world + UI composite in
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
Default OFF; persisted as `save.options.gbcfx`.
Mobile GPUs often compile the pass but present a black frame, so Android and
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -141,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
duration:
- **Game speed is pinned to normal.** The GAME SPEED option and
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
mods enabled offers to switch them all off and relaunch (mods merge at
boot, so a restart is the only way). The restart is confirmed, not
silent. They stay listed as disabled, ready to switch back on.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
divergence there ends the match as a draw. `volatile` carries per-turn
flags both sides recompute anyway - a divergence there is logged and
reported to mods, and play continues.
The relay logs which component diverged on which turn, so a desync report
names something specific.
## Custom boot text
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
@@ -161,8 +195,9 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
@@ -181,6 +216,36 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
## Widescreen battle layout
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
which gives battles a 304×144 native-pixel surface and a Gen 3-style
arrangement on it:
- the foe's status box upper left, the foe's picture upper right;
- the player's picture lower left, the player's status box lower right,
with a longer HP bar and the numeric HP under it;
- a full-width message window;
- a split "What will X do?" prompt / 2×2 command window;
- a 2×2 move menu, navigated with all four directions, with a PP and type
panel attached to its right.
Only the composition changes. Pictures, palettes, HP-bar colors, font
pages, window borders, sounds, animations, timing and every battle rule
stay the engine's, so a COLORS mode or an asset mod still owns the look.
Each side's picture keeps its original pixels and placement math and is
composited into its own region of the wider battlefield -- nothing is
scaled or squeezed -- and animations, which are authored in the original
160-pixel space, shift as one rigid group onto whichever side they play
on. The whole screen is drawn at the window's integer fit scale for the
wider surface, so a 304-pixel screen is drawn a step smaller than a
160-pixel one in the same window.
The wide surface is live only while the battle itself is the screen on
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
brings the classic surface back with it.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
@@ -228,3 +293,48 @@ be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
The save editor ships inside every build instead of being a developer-only
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
Delete on every slot that actually holds a save. Edit suspends the
launcher, opens that slot's file in the editor, and **Close** hands the
process back to the launcher with the slot list re-read (a rename, a badge
or a dex change shows up on the row immediately). Unsaved edits arm a
confirm first, so leaving cannot lose work. `love . --editor` still opens
it standalone, where Close quits instead; `--save <path>` points it at any
file, and a save can be dragged onto the window.
The editor now wears the launcher's visual language - the same navy radial
field, 16px translucent cards, tri-colour version rail and green/yellow/red
semantics - so the two windows read as one app. Six tabs:
- **Party**: the roster with sprites, HP bars and level chips on the left,
and the mon inspector permanently docked on the right instead of floating
over the list. Species, level, DVs and moves all round-trip through the
Gen 1 formulas, so the inspector can never show illegal stats.
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
and the player / lastHeal / lastOutdoor spawn points settable by clicking
a cell. Setting lastOutdoor on a map the game would not accept as an
outdoor source is refused with the reason.
- **Dex**: seen / owned completion meters and a four-column grid; owning
implies seen and un-seeing clears owned, exactly as the game requires.
Two rules run through all of it. Every mutation goes through one funnel
that sets the dirty flag and writes the status line together, so nothing
changes silently and no branch can quietly no-op - "Party is full", "Bag is
full", "click a cell first" all say so. And every destructive verb (Remove,
Release, Clear all, Wipe dex) arms on the first click and commits on the
second, relabelling itself to `Confirm?` in between.
A validation pill in the tab rail mirrors what the running game would
quarantine on load; clicking it jumps to the tab holding the first problem.
+1 -1
View File
@@ -65,7 +65,7 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
## Save-directory layout
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
find_roms_root() {
local v candidate
for v in /Volumes/*; do
[ -d "$v" ] || continue
# Prefer a volume that already has Roms/ or PORTS/
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
echo "$v"
return 0
fi
done
# Fallback: large FAT volume named NO NAME on disk8
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
if [ -d "$v" ]; then
echo "$v"
return 0
fi
done
return 1
}
say "looking for Anbernic ROMs volume"
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
say "using: $ROMS_ROOT"
# Resolve PORTS dir (stock uses Roms/PORTS)
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
PORTS="$ROMS_ROOT/Roms/PORTS"
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
PORTS="$ROMS_ROOT/roms/PORTS"
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
PORTS="$ROMS_ROOT/Roms/ports"
elif [ -d "$ROMS_ROOT/PORTS" ]; then
PORTS="$ROMS_ROOT/PORTS"
else
mkdir -p "$ROMS_ROOT/Roms/PORTS"
PORTS="$ROMS_ROOT/Roms/PORTS"
fi
say "PORTS: $PORTS"
# Refresh staged payload
mkdir -p "$STAGE/PORTS"
if [ -f "$ZIP" ]; then
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
else
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
fi
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
say "copying gen1recomp port"
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
# Also drop carts in the stock GB folder for the emulator library
GB_DIR=""
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
done
if [ -n "$GB_DIR" ]; then
say "copying .gb into $GB_DIR"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
fi
sync
say "installed:"
ls -lh "$PORTS/Gen1recomp.sh"
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
+120 -11
View File
@@ -1,8 +1,12 @@
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
-- game-data cache from a user-provided ROM on first boot.
--
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
-- editor tool (tools/save-editor/) instead of the game.
-- The save editor (tools/save-editor/) ships inside every build and is
-- reachable two ways:
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
-- * from the launcher: Edit on a save row, which suspends the launcher,
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
@@ -31,6 +35,105 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
-- ------------------------------------------------------------ save editor
-- The launcher instance parked while the editor is up, plus the version whose
-- cache the editor mounted (so closing can put the read path back).
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
-- live inside the .love archive, which the stock Lua searcher cannot open.
local function addEditorRequirePath()
local fs = love.filesystem
if not (fs.setRequirePath and fs.getRequirePath) then
-- very old LOVE: a source checkout still resolves through package.path
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
return
end
local current = fs.getRequirePath()
if current:find("tools/save%-editor") then return end
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
.. current)
end
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
-- design size, so grow it while editing and put it back on Close. Never
-- shrinks, never touches a fullscreen or mobile window.
local function resizeForEditor()
if not (love.window and love.window.getMode and love.window.setMode) then return end
local osName = love.system.getOS()
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
local w, h, flags = love.window.getMode()
if flags.fullscreen then return end
local dw, dh = love.window.getDesktopDimensions()
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
if wantW <= w and wantH <= h then return end
editorWindow = { w = w, h = h }
love.window.setMode(wantW, wantH, flags)
end
local function restoreWindow()
if not editorWindow then return end
local _, _, flags = love.window.getMode()
love.window.setMode(editorWindow.w, editorWindow.h, flags)
editorWindow = nil
end
-- Open the editor on a launcher save row. The version's cache has to be
-- mounted before the editor's Data:load runs, or a Blue save would be edited
-- against Red's species/item tables.
local function openEditor(version, slotId)
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] =
{ ok = false, text = "Could not resolve that save slot on disk." }
end
return
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version)
require("src.import.CacheFs").mountVersion(version)
editorVersion = version
editorHost = Importer
Importer = nil
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
end
-- Back to the launcher. Everything the editor mounted or cached has to come
-- back out: the version overlay (CacheFs) and the generated modules require
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
-- with this one's data.
function closeEditor()
local version = editorVersion
editorMode = false
if EditorApp and EditorApp.unload then EditorApp.unload() end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
editorVersion = nil
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
@@ -81,12 +184,17 @@ function love.load(args)
end
love.graphics.setDefaultFilter("nearest", "nearest")
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
-- cache has to be mounted before the editor's Data:load.
if editorMode then
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
local version = os.getenv("POKEPORT_VERSION") or "red"
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(savePath)
EditorApp.load(savePath, { version = version })
return
end
@@ -127,10 +235,11 @@ function love.load(args)
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
-- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end
function love.update(dt)
@@ -206,19 +315,19 @@ end
function love.gamepadpressed(joystick, button)
if editorMode then return end
if Importer then return end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode then return end
if Importer then return end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode then return end
if Importer then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
@@ -302,7 +411,7 @@ function love.mousemoved(x, y)
end
function love.textinput(text)
if Importer then return end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
end
@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
backend (luasocket TCP) are ordinary sockets, and Android denies those
to a UID without INTERNET. Bind and connect then come back EPERM, which
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support -->
+1 -1
View File
@@ -8,7 +8,7 @@
return {
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "beginner", "cosmetic", "data-only", "legacy" },
differences = {
changed = {
@@ -3,7 +3,7 @@
return {
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "balance", "data-only", "beginner" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "ui", "tool", "quality-of-life" },
differences = {
changed = { "the START menu gains a DEXNAV row above SAVE" },
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "audio", "chiptune", "ui" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "quest", "story", "scripting" },
differences = {
changed = {
@@ -3,7 +3,7 @@
return {
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "total-conversion", "capstone" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "A teal player recolor derived from your own cache, plus two palette records.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "cosmetic", "graphics", "beginner" },
screenshots = {
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
+1 -1
View File
@@ -2,7 +2,7 @@
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "battle", "mechanic", "ruleset", "hardcore" },
differences = {
changed = {
+13 -1
View File
@@ -56,16 +56,28 @@ done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love
# tools/save-editor is part of the shipped app, not a dev-only script: the
# launcher's Edit button on a save row opens it in-process (main.lua), and
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive.
say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# The editor is only reachable if its entry point and both module directories
# made it in; a silent miss would ship a launcher whose Edit button crashes.
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua; do
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|| fail "game.love is missing $required (save editor would not load)"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
+10 -4
View File
@@ -113,11 +113,12 @@ import pathlib, re, sys
path = pathlib.Path(sys.argv[1])
text = path.read_text()
# Drop network / mic / legacy storage, not needed for offline play.
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
# Drop mic / legacy storage, not needed by this game.
# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
# INTERNET: link play is not offline-only any more, and stripping INTERNET
# made every LAN host and every relay connect fail with EPERM (issue #287).
# Orientation / label come from gradle.properties placeholders.
for perm in (
"android.permission.INTERNET",
"android.permission.RECORD_AUDIO",
"android.permission.WRITE_EXTERNAL_STORAGE",
):
@@ -136,14 +137,19 @@ pack_game_love() {
say "packing game.love for love-android embed flavor"
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
+6 -2
View File
@@ -164,15 +164,19 @@ pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only.
# Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
+6
View File
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
echo "-- T3 content: skipped (--quick)"
else
run_tier "T3 content behavior (Red)" run_content_behavior
# The save editor ships inside every build (the launcher's Edit button on
# a save row opens it), so its panel suites run in CI rather than by hand.
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
+475 -77
View File
@@ -27,6 +27,7 @@ local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle")
local BattleState = {}
BattleState.__index = BattleState
@@ -35,9 +36,33 @@ BattleState.isOpaque = true
-- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. The wide
-- layout is live only while this battle is the state being drawn on top --
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
-- goes back with it and the battle underneath is not drawn at all.
function BattleState:wideLayout()
local options = self.game and self.game.save and self.game.save.options
if not options or options.battleLayout ~= "wide" then return false end
local stack = self.game.stack
return (stack and stack.top and stack:top()) == self
end
-- Renderer:setUISize asks the top state for its surface before anything draws
function BattleState:uiSize()
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
return 160, 144
end
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it.
function BattleState.sgbPalettes() return nil end
-- SGB whole-screen remap must not run over it. The wide layout still
-- needs a zone list of its own: the invented 160x144 one would leave its
-- extra columns unremapped in the forced-mono modes (WideBattle.zones).
function BattleState:sgbPalettes()
if self:wideLayout() then return WideBattle.zones() end
return nil
end
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
@@ -53,16 +78,22 @@ local BALL_ANIMS = {
}
local imageCache = {}
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
-- running battle holds the pics it built at enter() (battler.sprite,
-- playerBackPic, trainerPic). Weak keys let a dropped pic's row go with it,
-- so invalidate() can drop the path cache without orphaning what is on
-- screen right now (#316).
local WEAK_KEYS = { __mode = "k" }
-- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {}
local imagePadBottom = setmetatable({}, WEAK_KEYS)
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
local imagePadLeft = setmetatable({}, WEAK_KEYS)
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {}
local imageMeta = setmetatable({}, WEAK_KEYS)
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
-- entirely, so its palette variant collapses back onto the plain path.
@@ -118,10 +149,19 @@ local function getImage(path, pal, trueColor)
return imageCache[key]
end
-- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding
-- Hot reload / COLORS change (PaletteFX.setMode calls this): the next
-- getImage re-resolves every pic through the asset search path and
-- re-measures its ground padding. ONLY the path->image cache is dropped.
-- Wiping the three per-image tables as well orphaned the pics a running
-- battle already holds: imagePadBottom went nil, so backPlacement lost the
-- four transparent rows it grounds the back pic on and the pic jumped
-- pad * 2x = 8px UP the frame COLORS changed (#316), and imageMeta went nil,
-- so picImage's forced-mono grayImage (#207), fadeImage's BGP variants and
-- imagePathOf's battle_sprite_scales lookup all silently stopped resolving.
-- Those rows are weak-keyed, so entries for pics nothing references any more
-- are collected on their own rather than leaking.
function BattleState.invalidate()
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
imageCache = {}
end
Assets.register(BattleState.invalidate)
@@ -174,6 +214,25 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
-- already baked in, so darkening them means re-baking through PAL_BLACK the
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
-- the pics can never disagree. trueColor art has no DMG shades to remap.
local function blackImage(data, img)
local meta = imageMeta[img]
if not meta or meta.trueColor then return img end
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(data)
local colors = pack and pack.palettes and pack.palettes.BLACK
if not colors then return img end
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
return getImage(meta.path, { name = name, colors = colors }) or img
end
-- the asset path a loaded battle image came from (nil for the headless
-- stub images), so the battle_sprite_scales registry can be looked up by
-- the same path data references
@@ -200,6 +259,11 @@ function BattleState:picImage(img)
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
-- dark with the HP bars while the blackout text is up (#292). Below the
-- mono check on purpose: the forced-mono modes re-threshold the whole
-- frame downstream, and the DMG had no SGB darkening to begin with.
if self.blackedOut then return blackImage(self.data, img) end
return fadeImage(img, self:activeBgp())
end
@@ -725,6 +789,7 @@ function BattleState:startMessage(item)
-- it against self.total); the current line's revealed count is #shown[last]
self.charIndex = 0
self.msgWaiting = nil
self.msgPrompt = nil
self.scrollPx = nil
self:beginMsgLine()
end
@@ -930,11 +995,20 @@ function BattleState:updateQueue()
end))
return true
end
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
if not (item and item.choice) then
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
-- that branch above scrolls the NEXT line in, which this page has not
-- got, so reusing it would call beginMsgLine on a drained message.
self.msgPrompt = true
if input:wasPressed("a") or input:wasPressed("b") then
self.msgPrompt = nil
self.current = nil
end
end
end
return true
end
@@ -1021,6 +1095,17 @@ function BattleState:enter()
-- sides slide in; the trainer pics stay up until the send-outs
self.introSlide = 40
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
-- the intro text; _InitBattleCommon (core.asm:6755-6762) ClearScreenArea's
-- both HUD blocks and ClearSprites's the balls the moment that text is
-- dismissed. This flag is exactly that window: drawHUDs draws the intro
-- chrome while it is up and holds the real enemy HUD back, since a wild
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
-- draw is still gated on the slide having landed, so nothing shows while
-- the silhouettes are still coming in.
self.introBalls = true
-- SGB: the player-side battle palette while the back pic is up is
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
@@ -1052,12 +1137,27 @@ function BattleState:enter()
queueEnemyCry()
end
self:say(self.introText)
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
-- dismissed both HUD blocks are cleared and ClearSprites drops the
-- pokeball OAM, so the intro chrome never returns for the rest of the
-- battle -- not on a switch, and not when the beaten trainer's pic
-- scrolls back in (#317, #282)
self:act(function() self.introBalls = nil end)
if self.kind == "trainer" then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
self:act(function()
self.showEnemyTrainer = false
self:slidePic("foe")
end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
self.showEnemyTrainer = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
@@ -1075,13 +1175,20 @@ function BattleState:enter()
queueEnemyCry()
end
if not self.safari and not self.demo then
self:say(self:sendOutText(self.player.name))
-- Red's pic clears, the POOF plays, then the mon appears with its
-- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
-- a = 9 tiles, one tile every 2 frames) BEFORE SendOutMon prints
-- "Go! X!" -- Red does not simply vanish under the message (#317)
self:act(function() self:slidePic("back", 0, -72, 4) end)
table.insert(self.queue, { wait = 18 })
self:act(function()
self.showPlayerBack = false
self.sendingOut = true
self:slidePic("back")
end)
self:say(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
self:act(function()
self.sendingOut = false
@@ -1133,6 +1240,31 @@ local function clearTrapping(battler)
battler.trapDamage = nil
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
-- selection opens, but the clear is skipped for a mon that must recharge or
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
-- glitch).
--
-- A method rather than three lines inside the menu branch, because two
-- other places need the identical rule at the identical point in the turn
-- and both got it wrong by not having it:
--
-- * guarded PER BATTLER, not off self.player alone. This runs on
-- whichever machine is looking at its own menu, and in a lockstep link
-- battle "self.player" is the host's mon on one peer and the guest's on
-- the other, so one shared guard let one peer clear both flags while
-- the other cleared neither -- a bogus desync draw in a winnable match.
-- * a tournament spectator never enters the menu phase at all (it has no
-- decision to make), so nothing cleared a flinch in its replay: the
-- flag survived into the next turn, ate a move the real players saw
-- land, and from there the replay was watching a different battle.
-- LinkBattle.newSpectator calls this at the head of every turn.
function BattleState:clearTurnFlinches()
for _, b in ipairs({ self.player, self.enemy }) do
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
end
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
@@ -1153,11 +1285,17 @@ function BattleState:fightLockedAction(battler)
end
if battler.bideTurns then return { special = "bide" } end
-- held while the OPPONENT's trapping bit is set (live mirror so a
-- trap ended early by paralysis/faint frees the victim immediately)
-- trap ended early by paralysis/faint frees the victim immediately).
-- Read, not written: executeAction refreshes battler.boundTurns from the
-- same expression when the action actually runs, and that site runs on
-- both peers of a link battle. Storing it here instead wrote a hashed
-- field on whichever machine happened to open its own FIGHT menu, which
-- left the two peers holding boundTurns=0 against nil for the same
-- battler and ended the match as a desync over a mirror of a mirror.
local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns
local bound = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil
if battler.boundTurns then
if bound then
return { special = "bound" }
end
return nil
@@ -1194,9 +1332,23 @@ function BattleState:swapMoves(i, j)
require("src.core.Sound").play(self.data, "Swap")
end
function BattleState:update(dt)
-- One frame of the presentational clock: the BGP flash sequences, the
-- per-battler pic slide/hide programs, the send-out grow-in, the intro
-- slide and the screen-shake programs all advance in updateFx and nowhere
-- else. It lives behind its own entry point because a caller that has to
-- skip the rest of update() for a frame must still tick this, and a link
-- battle does exactly that on two hot paths -- waiting on the peer's action,
-- and draining a resolved lockstep turn. Skipping it there froze whatever
-- was mid-flight: a flash stuck on its inverted BGP step repainted the whole
-- UI in inverted shades, and a pic part-way through a slide-off or a grow-in
-- simply stayed gone -- for as long as the opponent took to choose.
function BattleState:tickFx()
self.frame = self.frame + 1
self:updateFx()
end
function BattleState:update(dt)
self:tickFx()
local input = self.game.input
-- safety net: HP/status changed outside a queued drain (level-up heals,
@@ -1211,6 +1363,15 @@ function BattleState:update(dt)
end
if self.phase == "messages" then
-- Nothing queued starts until the silhouettes have finished sliding in:
-- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar
-- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy
-- cry and the "Wild X appeared!" box belong at the end of the slide, not
-- on its first frame (#303). The hold sits here rather than in
-- updateQueue because only this loop has a frame clock: updateFx above
-- counts introSlide down, and a headless caller driving updateQueue on
-- its own has no slide to wait for.
if (self.introSlide or 0) > 0 then return end
if not self:updateQueue() then
if self.afterQueue == "menu" then
self.phase = "menu"
@@ -1270,13 +1431,7 @@ function BattleState:update(dt)
end
return
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared during
-- move selection, but the clear is skipped while the player must
-- recharge or is locked into Rage (core.asm:293-295 -- the Hyper
-- Beam flinch-recharge glitch)
if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false
end
self:clearTurnFlinches()
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
@@ -1332,7 +1487,14 @@ function BattleState:update(dt)
if self.phase == "moveSelect" then
local moves = self.player.curMoves
if input:wasPressed("up") then
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
-- four directions navigate it; nil means no direction was pressed and
-- A / B / SELECT below behave the same in either layout.
local grid = self:wideLayout()
and WideBattle.navigate(self.moveIndex, #moves, input)
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
@@ -1374,7 +1536,13 @@ function BattleState:update(dt)
-- (core.asm:2553-2557), so there is no backing out with B.
if self.phase == "mimicSelect" then
local moves = self.mimicMoves
if input:wasPressed("up") then
-- the copy menu shares the widescreen move grid, so it navigates the
-- same way there (the classic layout keeps the vertical list)
local grid = self:wideLayout()
and WideBattle.navigate(self.mimicIndex, #moves, input)
if grid then
self.mimicIndex = grid
elseif input:wasPressed("up") then
self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
@@ -2156,19 +2324,34 @@ end
-- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by
-- playVictoryMusic) and every other outcome tears it down in
-- end_of_battle.asm -- self.result covers those. The damage drain
-- gates the start (the HUD redraw runs after UpdateHPBar finishes),
-- but healing out of the red stops it at once (item_effects.asm clears
-- the alarm before the bar animates). No alarm before the player HUD
-- first draws (send-out), nor in the safari/old-man battles, which
-- have no player mon HUD.
-- gates the START (the HUD redraw runs after UpdateHPBar finishes) but
-- never the stop, and healing out of the red silences it at once
-- (item_effects.asm:991-994 clears the alarm before the bar animates).
-- No alarm before the player HUD first draws (send-out), nor in the
-- safari/old-man battles, which have no player mon HUD.
function BattleState:lowHealthAlarmActive()
local p = self.player
if not p or self.safari or self.demo or self.result
or self.lowHealthAlarmDisabled then return false end
if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end
if p.fainted then return false end
-- A siren that is ALREADY sounding follows the drawn bar, not the
-- model: wLowHealthAlarm is a latch DrawPlayerHUDAndHPBar only revisits
-- once UpdateHPBar2 has finished animating (core.asm:4727-4729 /
-- core.asm:4845-4847 both drain first, then jp DrawHUDsAndHPBars), and
-- a KO clears it in RemoveFaintedPlayerMon (core.asm:1011-1016), i.e.
-- after the bar has drained empty. applyDamage takes the HP off the
-- model while the turn is still being queued, so keying a running alarm
-- off mon.hp cut it dead for the whole "used X!" line + move animation
-- + drain window (#293). max() keeps a heal out of the red silencing
-- it on the spot, the way item_effects.asm does.
local hp = p.mon.hp
if hp <= 0 or p.fainted then return false end
if p.shownHP and p.shownHP > hp then return false end -- drain running
if self.lowHealthAlarmOn then
hp = math.max(hp, shownHP(p))
elseif p.shownHP and p.shownHP > hp then
return false -- drain running: the HUD redraw has not happened yet
end
if hp <= 0 then return false end
local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp)))
return px < 10
end
@@ -2184,10 +2367,47 @@ local function stepProgram(prog)
return head
end
-- Trainer-pic slides. SlideTrainerPicOffScreen (core.asm:1235) walks a
-- trainer pic off its own screen edge one tile every 2 frames (9 tiles left
-- for the player back pic, 8 tiles right for the foe), and
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
-- brings the beaten foe back in from the right one column every 4 frames.
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
-- queue rows that start them park a { wait } of the matching length. Call
-- with no target to clear a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step)
self.picOff = self.picOff or {}
if to == nil then
self.picOff[slot] = nil
return
end
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
end
-- the live x offset for a pic slot, 0 when nothing is sliding
function BattleState:picOffset(slot)
local p = self.picOff and self.picOff[slot]
return p and p.x or 0
end
function BattleState:updateFx()
if self.introSlide and self.introSlide > 0 then
self.introSlide = self.introSlide - 1
end
-- step each live trainer-pic slide toward its target; a landed program
-- holds its offset (the after-battle scroll-in rests two tiles right of
-- the battle slot) until its owner clears the slot
if self.picOff then
for _, p in pairs(self.picOff) do
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
end
end
end
local fx = self.fx
if fx then
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
@@ -2276,7 +2496,12 @@ function BattleState:updateFx()
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
-- loops while the player's bar is red; see lowHealthAlarmActive
local Sound = require("src.core.Sound")
if self:lowHealthAlarmActive() then
-- self.lowHealthAlarmOn mirrors wLowHealthAlarm's bit 7: a latch read
-- back inside lowHealthAlarmActive (the RHS sees last frame's value)
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
@@ -2548,7 +2773,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end
-- PP: not for continuations, struggle, called moves, or (under
-- gen1_faithful) wild/trainer enemies pokered DecrementPP only ever
-- gen1_faithful) wild/trainer enemies -- pokered DecrementPP only ever
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
-- That rule doesn't apply in a link battle: "the enemy" there is a real
-- human peer independently tracking their own PP the normal way, not an
@@ -2913,6 +3138,16 @@ function BattleState:enemyMonFainted()
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
-- the block FaintEnemyPokemon just cleared, after the exp text and
-- BEFORE the next send-out. It survives EnemySendOutFirstMon's
-- SlideTrainerPicOffScreen (core.asm:1308-1310, 8 steps x DelayFrames 2)
-- so SET style gets the brief flash, and stays up through the whole
-- SHIFT prompt below (#283).
self:act(function() self.showEnemyBalls = true end)
table.insert(self.queue, { wait = 16 })
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
@@ -2948,6 +3183,10 @@ function BattleState:enemyMonFainted()
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- EnemySendOutFirstMon .next4 (core.asm:1413-1417): ClearSprites and
-- the 4x11 ClearScreenArea take the ball row away with the rest of
-- the enemy HUD block, right before TrainerSentOutText (#283)
self.showEnemyBalls = nil
self:markParticipant()
-- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area
-- clears, TrainerSentOutText prints, THEN the pic appears
@@ -2975,6 +3214,20 @@ function BattleState:enemyMonFainted()
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
-- the reset the mon that was out when the enemy fainted -- marked by
-- the send-out act above, which mirrors EnemySendOut's own re-flag
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
-- enemyMonFainted counted two mons and the switch-in earned half the
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
-- replacements (openReplacementMenu) must NOT do this: pokered's
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
-- the deliberate exp-share, and a fainted mon is already dropped by
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
self.participants = {}
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
@@ -2990,16 +3243,39 @@ function BattleState:enemyMonFainted()
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
self.game.save.money = self.game.save.money + prize
-- the beaten trainer's pic returns for the defeat text (pokered
-- DisplayBattleMenu's defeat flow)
self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end)
-- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then
-- the victory theme starts BEFORE TrainerDefeatedText and the
-- prize money
-- TrainerBattleVictory (core.asm:915-949) in order: EndLowHealthAlarm
-- and the victory theme, TrainerDefeatedText, ScrollTrainerPicAfterBattle
-- (the beaten trainer scrolls back in from the right, one column every 4
-- frames, resting two tiles right of the battle slot), DelayFrames 40,
-- PrintEndBattleText -- the trainer's OWN loss line, on the battle
-- screen -- and only then MoneyForWinningText. Every row rides the
-- *Next inserters so it keeps that order behind the running queue item;
-- the plain act() the pic used to ride appended to the END of the queue,
-- which is why the trainer only flashed up for a frame or two as the
-- battle popped and the loss line had to be printed by the overworld
-- afterwards, stranding any evolution between two cuts (#282).
-- endBattleText is filled in by whoever started the battle
-- (OverworldState:engageTrainer in src/world/OverworldController.lua);
-- scripted battles that print their own follow-up leave it nil.
self:actNext(function() self:playVictoryMusic() end)
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
self.trainer.name))
self:actNext(function()
self.showEnemyTrainer = self.trainerPic ~= nil
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
end)
-- the 24-frame scroll-in plus the DelayFrames 40 that follows it
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 64 })
if self.endBattleText then
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
-- starts a fresh page, which is a message row of its own here (five
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then self:sayNext(page) end
end
end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
end
self.result = "win"
@@ -3067,6 +3343,15 @@ function BattleState:playerMonFainted()
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
if not BattleState.isOaksLabStarterRival(self) then
-- HandlePlayerBlackOut (core.asm:1150-1159): SET_PAL_BATTLE_BLACK runs
-- BEFORE PlayerBlackedOutText2, so the enemy pic and both HP bars are
-- already dark under the blackout lines (#292). The Oak's Lab starter
-- rival returns one line above that call and never darkens. Set here
-- rather than queued: this whole function already runs from a queued
-- act after "<mon> fainted!" was dismissed, which is where the palette
-- command sits. (The Route 22 RIVAL1 wipe darkens one box early, over
-- Rival1WinText, which pokered prints just before the same command.)
self.blackedOut = true
self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name))
end
@@ -3475,13 +3760,41 @@ end
-- called by BagMenu when a ball is thrown
function BattleState:throwBall(ball)
-- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1
-- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 /
-- call PrintText` (:146-147), so a trainer battle never shows the
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then
self:say(Strings("%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
end
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
if self.kind ~= "wild" then
self:sayNext(Strings("The TRAINER\nblocked the BALL!"))
self:sayNext(Strings("Don't be a thief!"))
-- ThrowBallAtTrainerMon (item_effects.asm:2292-2303) still animates the
-- toss: MoveAnimation routes TOSS_ANIM to TossBallAnimation, which takes
-- its .BlockBall branch in a trainer battle (animations.asm:2582-2585,
-- 2629-2637) -- the plain TOSS arc whatever the ball tier, then
-- SFX_FAINT_THUD and BLOCKBALL_ANIM, and only then the two texts. The
-- ball still counts as used: UseItem_ sets
-- wActionResultOrTookBattleTurn = 1 (item_effects.asm:1-3) and this path
-- never clears it, so UseBagItem does not fall back to the bag
-- (core.asm:2257-2259) and the turn is spent -- the foe moves (#291).
local t = self.data.text
self:animNext("TOSS_ANIM", true, nil, ball)
self:actNext(function()
require("src.core.Sound").play(self.data, "Faint_Thud")
end)
self:animNext("BLOCKBALL_ANIM", true)
self:sayNext(t._ThrowBallAtTrainerMonText1
or Strings("The trainer\nblocked the BALL!"))
self:sayNext(t._ThrowBallAtTrainerMonText2
or Strings("Don't be a thief!"))
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
return
end
if self.ghost then
@@ -3802,7 +4115,12 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
-- while an SE effect displaces the pic, confine it to its side's
-- tile window like the GB tilemap does (the pic can never overwrite
-- the HUD columns or the text box rows)
-- ...except under the widescreen layout, where the side's own region
-- scissor is already that window on a battlefield the classic tile
-- columns do not describe (an 88..160 clip would fall entirely outside
-- the enemy's region and erase the pic).
local clip = love.graphics.setScissor and love.graphics.intersectScissor
and not self.wideRegion
local scx, scy, scw, sch
if clip then
scx, scy, scw, sch = love.graphics.getScissor()
@@ -3932,6 +4250,16 @@ function BattleState:sgbBattlePals()
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
-- battle screen -- both HP bars and both mon regions -- goes dark behind
-- the blackout text. picImage re-bakes the pics through the same palette,
-- since those draw over the zone pass rather than through it (#292).
if self.blackedOut and pals.BLACK then
local b = pals.BLACK
return { [0] = b, [1] = b, [2] = b, [3] = b }
end
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
@@ -4006,9 +4334,10 @@ end
-- recolor the grayscale BG canvas per zone; an active BGP fade permutes
-- the zone palette (the SGB colors the remapped DMG shade). A window
-- shake draws a second, offset copy over the base one: the color
-- regions themselves never move on the SGB, and the vacated strip
-- shows the unshifted BG map like the hardware.
-- shake draws only the offset copy: the baked canvas holds the HUDs and
-- text box (window-layer content), so compositing an unshifted copy
-- underneath ghosted every name in the vacated strip (#295). The strip
-- shows blank color 0 instead, like the hardware revealing empty BG.
function BattleState:drawZonePass(src, sx, sy)
local PaletteFX = require("src.render.PaletteFX")
local shader = PaletteFX.shader()
@@ -4016,13 +4345,17 @@ function BattleState:drawZonePass(src, sx, sy)
local bgp = self:activeBgp()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0
for _, z in ipairs(BATTLE_ZONES) do
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
love.graphics.setScissor(z[1] * 8, z[2] * 8,
(z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8)
love.graphics.draw(src, 0, 0)
if sx ~= 0 or sy ~= 0 then
local zx, zy = z[1] * 8, z[2] * 8
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
love.graphics.setScissor(zx, zy, zw, zh)
if shaking then
love.graphics.rectangle("fill", zx, zy, zw, zh)
love.graphics.draw(src, sx, sy)
else
love.graphics.draw(src, 0, 0)
end
end
love.graphics.setScissor()
@@ -4145,15 +4478,21 @@ end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
-- onlySide ("player" / "enemy") draws one side's pic alone, and
-- skipMenuClip drops the move-menu row clip below: the widescreen layout
-- composites each side into its own region of a taller battlefield, where
-- neither the other side's pixels nor the classic menu rows apply.
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
-- The move-select boxes are BG tiles on the GB, so they REPLACE the
-- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes
-- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
-- layer in the colorized pipeline, so clip them to the visible rows.
local g = love.graphics
local clipY = self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64 or nil
local clipY = not skipMenuClip
and (self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64)
or nil
local clipped, cs1, cs2, cs3, cs4
if clipY and g.getScissor and g.intersectScissor then
cs1, cs2, cs3, cs4 = g.getScissor()
@@ -4161,13 +4500,15 @@ function BattleState:drawPicsLayer(slide, sx, sy)
clipped = true
end
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then
if onlySide ~= "player" and self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
love.graphics.draw(img, ex, ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
elseif onlySide ~= "player"
and self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
@@ -4196,7 +4537,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then
if onlySide ~= "enemy" and self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
@@ -4209,8 +4550,11 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1)
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
pad, padL, s)
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
dy + sy, 0, s, s)
elseif onlySide ~= "enemy"
and self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
love.graphics.setColor(1, 1, 1, 1)
@@ -4262,9 +4606,13 @@ function BattleState:drawHUDs(slide)
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
-- ...and it is not up yet during the intro text either: a wild battle's
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
-- player's ball row with no enemy HUD beside it (#317)
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0
and not self.enemy.fainted then
and not self.introBalls and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -4292,29 +4640,61 @@ function BattleState:drawHUDs(slide)
end
end
-- ReplaceFaintedEnemyMon -> DrawEnemyPokeballs (core.asm:896,
-- draw_hud_pokeball_gfx.asm:9-11 -> SetupEnemyPartyPokeballs :33-45):
-- between a KO and the next send-out the foe's ball row sits in the enemy
-- HUD block FaintEnemyPokemon cleared, over the chrome PlaceEnemyHUDTiles
-- writes with it -- the same $73 (1,2) / $74 (1,3) / $76 run / $78 tiles
-- the live HUD draws, minus the HP bar (#283). Its own block rather than
-- a third arm of showIntroBalls below: that window is DrawAllPokeballs's
-- (#317) and clears for the rest of the battle, this one reopens on every
-- enemy faint. wBaseCoordX $48 / wBaseCoordY $20 stepping -8 is screen
-- (64,16) leftward, the same row the intro draws.
if self.showEnemyBalls and self.enemyParty and slide == 0 then
hudTile(0x73, 8, 16)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
-- Safari shows only the ball count; the old man demo shows neither mon
if self.safari then
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end
-- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
local showIntroBalls = slide == 0 and (
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
-- Party pokeball rows and the HUD chrome under them, for exactly the
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
-- on the wild intro too -- keying it off the enemy trainer pic meant a
-- wild battle never drew one (#317) -- and SetupEnemyPartyPokeballs is
-- skipped when wIsInBattle == 1, so only a trainer/link battle gets the
-- foe's row. Gating on introBalls rather than on the pics also stops the
-- rows coming back when the beaten trainer scrolls in (#282):
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
-- OAM, which ClearSprites emptied when the intro text was dismissed.
local showIntroBalls = self.introBalls and slide == 0
if showIntroBalls then
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
-- rightward along row 3 (draw_hud_pokeball_gfx.asm:133-165)
hudTile(0x73, 8, 16)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
love.graphics.setColor(1, 1, 1, 1)
if self.enemyParty and (
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
if self.showPlayerBack then
-- PlacePlayerHUDTiles (hlcoord 18,10): $73, then $77 + 8x $76 + $6F
-- LEFTWARD along row 11 (draw_hud_pokeball_gfx.asm:119-131)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end
end
local hidePlayer = self.safari or self.demo
if self.player and not hidePlayer and not self.showPlayerBack
and slide == 0 then
@@ -4363,9 +4743,10 @@ function BattleState:drawTextArea()
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
-- box, bottom-right of the box like TextBox / home/text.asm
if self.msgWaiting and self.frame % 60 < 30 then
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
end
elseif self.phase == "menu" and self.demo then
@@ -4405,6 +4786,18 @@ function BattleState:drawTextArea()
-- the move box's top border ('─' at (4,12), '┘' at (10,12)).
Font.drawBox(0, 8, 11, 5)
Font.drawBox(4, 12, 16, 6)
-- Those two cells are REPLACED on hardware: MoveSelectionMenu writes them
-- straight into the tilemap over the border it just laid down
-- (core.asm:2492-2501), and PrintMenuItem's own TextBoxBorder then redraws
-- the whole row on top (core.asm:2838-2844). Font.drawCode blits a
-- black-on-transparent glyph instead, so the tile underneath survives: the
-- move box's '┌' keeps its Poké Ball corner showing through the '─', and
-- the '─' the move box drew at (10,12) pokes two dots out from under the
-- '┘' (#240). Wipe each cell back to box white first, the way a tilemap
-- write does.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 32, 96, 8, 8)
love.graphics.rectangle("fill", 80, 96, 8, 8)
Font.drawCode(Font.BORDER.h, 32, 96)
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
@@ -4444,7 +4837,12 @@ function BattleState:drawTextArea()
end
function BattleState:draw()
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
if self:wideLayout() then return WideBattle.draw(self) end
return self:drawClassic()
end
function BattleState:drawClassic()
-- AskName: ClearSprites + wild ClearScreenArea -- white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
love.graphics.setColor(1, 1, 1, 1)
+1 -1
View File
@@ -16,7 +16,7 @@
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP Gen 1 AI never reads wEnemyMonPP).
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
+379
View File
@@ -0,0 +1,379 @@
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
--
-- The battle simulation, timing, animations and rules stay BattleState's;
-- this module only replaces the composition and asks the renderer for a
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
-- border glyphs, species palettes and HP tiles all resolve through the
-- engine, so a COLORS mode or an asset mod still owns the look.
--
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
-- upper left with its picture upper right, the player's picture lower left
-- with their status lower right, a full-width message window, a split
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local TypeChart = require("src.battle.TypeChart")
local WideBattle = {
WIDTH = 304,
HEIGHT = 144,
-- everything above this line is battlefield; the 40 rows below it are
-- the message / command / move windows
FIELD_BOTTOM = 104,
}
-- The forced-mono display modes re-threshold the whole finished frame
-- through the shade shader, and picImage hands them raw DMG grays for that
-- (#207). The wide layout has to know: it exposes a matching whole-surface
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
local function monoMode()
local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic"
end
local function shownHP(battler)
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
end
-- a name truncated to `pixels` with a trailing '.', measured through the
-- font's own advances so a variable-width page still fits
local function fitName(text, pixels)
local spans = Font.split(text or "")
local n = Font.spansFitting(spans, pixels)
if n >= #spans then return text or "" end
local out = {}
for i = 1, math.max(0, n - 1) do
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
end
return table.concat(out) .. "."
end
local function saveScissor()
if not love.graphics.getScissor then return nil end
local x, y, w, h = love.graphics.getScissor()
if x == nil then return false end
return { x, y, w, h }
end
local function restoreScissor(saved)
if not love.graphics.setScissor then return end
if saved and saved ~= false then
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
else
love.graphics.setScissor()
end
end
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
-- The scissor is in canvas space, so it bounds the region itself while the
-- translate moves the classic 160x144 coordinates into it.
local function inRegion(x, y, w, h, dx, dy, fn)
local g = love.graphics
local saved = saveScissor()
g.setScissor(x, y, w, h)
g.push()
g.translate(dx, dy)
fn()
g.pop()
restoreScissor(saved)
end
local function levelAt(battle, battler, x, y)
if battler.shownStatus then
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
else
HudTiles.tile(0x6E, x, y) -- '<LV>'
Font.draw(tostring(battler.mon.level), x + 8, y)
end
end
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
local function drawStatusPanel(battle, battler, x, y, player)
local tx, ty = math.floor(x / 8), math.floor(y / 8)
local tw, th = player and 15 or 16, player and 5 or 4
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
local nameWidth = player and 64 or 80
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
-- out to the wide screen's own corners
local function drawIntroBalls(battle)
if not battle.introBalls then return end
if battle.enemyParty and
(battle.kind == "trainer" or battle.kind == "link") then
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
end
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
end
local function drawHUDs(battle, slide)
if battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
drawStatusPanel(battle, battle.enemy, 0, 0, false)
end
if battle.safari then
Font.drawBox(23, 7, 15, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
elseif battle.player and not battle.demo and not battle.showPlayerBack
and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
if battle.scrollPx and battle.scrollPx > 0 then
battle.scrollPx = battle.scrollPx - 2
if battle.scrollPx <= 0 then battle.scrollPx = nil end
end
local off = battle.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(battle.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
Font.drawCode(0xEE, 288, 132)
end
end
local function drawCommandMenu(battle)
local col = (battle.menuIndex - 1) % 2
local row = math.floor((battle.menuIndex - 1) / 2)
if battle.safari then
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112)
Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128)
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
return
end
-- the prompt on the left, the 2x2 commands on the right
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112) -- 'PK'
Font.drawCode(0xE2, 248, 112) -- 'MN'
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
end
local function drawMoveDetails(battle, move)
Font.drawBox(28, 13, 10, 5)
if not move then return end
local def = battle.data.moves[move.id]
if not def then return end
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
end
local function drawMoveGrid(battle, moves, selected)
-- The 8px font needs 28 tiles for two complete twelve-character move
-- names plus their cursors; the details panel gets the other ten.
Font.drawBox(0, 13, 28, 5)
love.graphics.setColor(0, 0, 0, 1)
for i, move in ipairs(moves or {}) do
local col = (i - 1) % 2
local row = math.floor((i - 1) / 2)
local x, y = col == 0 and 16 or 120, 112 + row * 16
local def = battle.data.moves[move.id]
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
end
local col = (selected - 1) % 2
local row = math.floor((selected - 1) / 2)
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
drawMoveDetails(battle, moves and moves[selected])
end
local function drawMoveMenu(battle)
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
if battle.moveSwapIndex then
local col = (battle.moveSwapIndex - 1) % 2
local row = math.floor((battle.moveSwapIndex - 1) / 2)
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
end
end
local function drawTextArea(battle)
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
drawMessageBox(battle)
elseif battle.phase == "menu" then
drawCommandMenu(battle)
elseif battle.phase == "moveSelect" then
drawMoveMenu(battle)
elseif battle.phase == "mimicSelect" then
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
else
Font.drawBox(0, 13, 38, 5)
end
end
-- Battle animations are authored in the original 160px coordinate space.
-- Shift each complete OAM frame as one rigid group between the new player
-- and enemy anchors: drawing the whole animation through both side regions
-- would duplicate any tiles overlapping the other side's source range (most
-- visibly the send-out POOF reappearing on the far right).
function WideBattle.animationOffset(sprites)
if not sprites or #sprites == 0 then return 0, 0 end
local minX, maxX = math.huge, -math.huge
for _, sprite in ipairs(sprites) do
minX = math.min(minX, sprite.x - 8)
maxX = math.max(maxX, sprite.x)
end
local center = (minX + maxX) / 2
local t = math.max(0, math.min(1, (center - 40) / 80))
return math.floor(20 + 116 * t + 0.5),
math.floor(8 * (1 - t) + 0.5)
end
local function currentAnimationSprites(battle)
if battle.animPlaying and battle.animPlayer then
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
return step and step.sprites
end
if battle.lockedBall and battle.animPlayer then
return battle.lockedBall
end
end
local function drawAnimationLayer(battle)
local sprites = currentAnimationSprites(battle)
if not sprites or #sprites == 0 then return end
local dx, dy = WideBattle.animationOffset(sprites)
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
function() battle:drawAnimLayer(false) end)
end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
local sy = (fx and fx.shakeY) or 0
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
local slide = (battle.introSlide or 0) * 4
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
drawAnimationLayer(battle)
drawTextArea(battle)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
g.setColor(1, 1, 1, 1)
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, battle)
end
end
-- The palette zones for the wide surface. The composition already resolves
-- species colors, paper shade and HP-bar colors itself, so the colorized
-- modes take the trueColor opt-out (`colors = false`) over the whole
-- surface; the forced-mono modes still want their whole-screen remap, and
-- get one sized to the wide surface instead of the 160x144 rectangle
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
function WideBattle.zones()
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
if monoMode() then
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
-- the inverted permutation), exactly as it does for ensureZones' zone
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
end
return { { colors = false, x = 0, y = 0, w = w, h = h } }
end
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
-- and a direction pointing at an empty slot holds the current one.
function WideBattle.moveGridIndex(index, count, direction)
if count < 1 then return nil end
local row = math.floor((index - 1) / 2)
local col = (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local otherRow = 1 - row
local other = otherRow * 2 + col + 1
return other <= count and other or index
end
local DIRECTIONS = { "left", "right", "up", "down" }
-- the slot a directional press selects, or nil when none was pressed (the
-- caller then runs its normal list navigation / A / B / SELECT handling)
function WideBattle.navigate(index, count, input)
for _, key in ipairs(DIRECTIONS) do
if input:wasPressed(key) then
return WideBattle.moveGridIndex(index, count, key)
end
end
return nil
end
return WideBattle
+30 -4
View File
@@ -206,10 +206,15 @@ function Data:load()
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
-- Drop every namespace the mod merge created and evict the generated modules
-- from package.loaded, so the next load() re-reads them off disk instead of
-- handing back the cached tables. Two callers:
-- * reloadGenerated below (dev hot reload)
-- * main.lua, when the launcher closes the save editor -- the editor may
-- have loaded the OTHER game's cache, and require would otherwise serve
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
-- clears the matching read-path overlay).
function Data:unloadGenerated()
local pristine = self._pristineKeys
if pristine then
for key in pairs(self) do
@@ -222,6 +227,13 @@ function Data:reloadGenerated()
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
end
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
self:unloadGenerated()
self:load()
end
@@ -246,6 +258,20 @@ function Data:resolveText(mapLabel, textConst)
local s = self.text[entry.text]
if s then return s, entry.asm end
end
-- A text_asm entry names its wrapper label but carries no string, because
-- the extractor cannot follow asm. A handful of those wrappers are plain
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
-- for those the extracted _Label string IS the whole behavior, so the
-- boulders and signs printed nothing at all (#318). Wrappers that really
-- do run logic have no _Label string to find, so they still fall through
-- to their hand-ported script in data/scripts/.
-- needsAsm comes back false here on purpose: showMapText's warning tells
-- the reader to go port a script, and for these there is nothing to port.
if entry.label then
local s = self.text["_" .. entry.label]
if s then return s, false end
end
return nil, entry.asm
end
+19
View File
@@ -191,6 +191,15 @@ end
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
@@ -238,6 +247,16 @@ function Game:draw()
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- The UI surface is resolved once, before any state draws: the top state
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
-- asks for 304x144). Anything else keeps the classic surface, so a menu
-- pushed over a wide battle brings the screen straight back to 160x144.
local top = self.stack:top()
if top and top.uiSize then
Renderer:setUISize(top:uiSize())
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
Renderer:beginFrame(worldBelow)
self.stack:draw()
-- SGB colorization: the topmost state that knows its palette owns the
+1 -1
View File
@@ -144,7 +144,7 @@ function Input:step()
elseif next(sources) ~= nil then
self.state[btn] = true
end
-- sources == {}: real press fully released before this step keep up
-- sources == {}: real press fully released before this step -- keep up
end
for btn, sources in pairs(self.sources) do
if next(sources) == nil then
+88 -11
View File
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Stats = require("src.pokemon.Stats")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
@@ -132,17 +133,26 @@ local function detectPortable()
-- Recover the folder containing the .app so a packaged app finds its
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
-- and the plain source-base directory (next to the .exe/AppImage) is used.
local function appContainer(path)
local appPath = path and path:match("^(.*%.app)/Contents/")
return appPath and appPath:match("^(.*)/[^/]+$") or nil
local function parentDir(path)
return path and path:match("^(.*)/[^/]+$") or nil
end
-- Order: the .app's containing folder (packaged macOS), then the
-- source-base directory (next to a packaged .exe/AppImage), then the
-- source itself (a `love <gamedir>` run drops portable.txt in the game
-- folder). First one holding the marker wins. Built by appending so a
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
local function appContainer(path)
return parentDir(path and path:match("^(.*%.app)/Contents/"))
end
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
-- the squashfs at a temp path, and that becomes the base directory.
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
-- base dir from there.
local function appImageContainer()
return parentDir(os.getenv("APPIMAGE"))
end
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
-- then the source-base directory (next to a packaged .exe), then the source
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
-- First one holding the marker wins. Built by appending so a nil (e.g. no
-- .app in the path) never truncates the ipairs scan.
local candidates = {}
local appDir = appContainer(src) or appContainer(sbd)
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
if appDir then candidates[#candidates + 1] = appDir end
if sbd then candidates[#candidates + 1] = sbd end
if src then candidates[#candidates + 1] = src end
@@ -193,6 +203,9 @@ function SaveData.defaultOptions()
textSpeed = 3,
animations = true,
battleStyle = "shift",
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -442,6 +455,25 @@ function SaveData.slotSummary(save)
}
end
-- The absolute on-disk path of a slot's save file, for the one caller that
-- cannot go through love.filesystem: the save editor reads and writes with
-- raw io.* so it can also open a file the player dragged in from anywhere.
-- Resolves against the same root persistFs would write to -- the portable
-- game folder when portable mode is on, otherwise LOVE's save directory --
-- so Edit on a launcher save row lands on the file the game actually plays.
-- nil when neither root is available (headless tests with an injected fs).
function SaveData.slotDiskPath(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) or not slotId then return nil end
local base = SaveData.portableBaseDir()
or (love and love.filesystem and love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory())
if not base then return nil end
local sep = package.config:sub(1, 1)
local rel = select(1, slotNames(version, slotId))
return base .. sep .. rel:gsub("/", sep)
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
@@ -458,11 +490,44 @@ function SaveData.listSlots(version)
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
label = reg.names and reg.names[id] or nil }
end
return out
end
-- Give a registered slot a custom label (#205: "a way to name save slots so
-- you can see that in the launcher"). The label lives in the options
-- registry next to list/active, never in the save file itself, so renaming
-- needs no save rewrite and an empty slot can be labeled too. The label is
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
-- false + an error string when the id is not registered.
function SaveData.renameSlot(version, slotId, name)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then return false, "slot not registered" end
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
if label == "" then label = nil end
reg.names = reg.names or {}
reg.names[slotId] = label
if next(reg.names) == nil then reg.names = nil end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return true
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
@@ -580,6 +645,7 @@ function SaveData.deleteSlot(version, slotId)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.names then reg.names[slotId] = nil end
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
@@ -972,6 +1038,17 @@ local function scrubKnownMon(mon, data)
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end
mon.level = clamp(mon.level, 1, 100, 1)
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
-- mon.stats: the status screen opened in the box (#233) and the party list
-- after withdrawing one (#304). The original derives them on demand
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
-- every later reader (menus, battle, items, SGB bar zones, the link
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
-- so the derived stats use a sane level. A save that already has stats is
-- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
local moves = mon.moves
if type(moves) ~= "table" then return end
local hadMoves = #moves > 0
@@ -1216,7 +1293,7 @@ function SaveData.newGame(boot)
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have this only applies to New Game.
-- already have -- this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
+105
View File
@@ -75,6 +75,32 @@ local function resolveMkdir()
return mkdirFn
end
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
-- refuse a non-empty directory, so a caller has to delete the files first.
local rmdirFn = nil
local function resolveRmdir()
if rmdirFn ~= nil then return rmdirFn end
rmdirFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return rmdirFn end
if ffi.os == "Windows" then
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
end
else
pcall(ffi.cdef, "int rmdir(const char *pathname);")
local resolved = pcall(function() return ffi.C.rmdir end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
end
end
return rmdirFn
end
-- Mount an external directory onto the physfs read path (appended, so the
-- game's own source always wins a name clash). Returns true on success.
--
@@ -122,6 +148,37 @@ local function mountReadable(dir, append)
return fn(dir, append)
end
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
-- one game's cache and then Play the other, and an overlay left mounted
-- would win the read path for the rest of the process.
local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_unmount end)
if oks and fn then
physfsUnmountFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= 0
end
break
end
end
end
return physfsUnmountFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -229,6 +286,23 @@ function CacheFs.remove(rel)
love.filesystem.remove(rel)
end
-- Remove a single cache-relative directory once its files are gone. Needed
-- because os.remove cannot delete a directory on Windows and
-- love.filesystem.remove never reaches outside the save directory, so the
-- portable game folder gets the same FFI-syscall treatment as its mkdir
-- (issue #74: os.execute would flash a console window per call). Used by the
-- mod installer so an uninstall leaves nothing behind (#330).
function CacheFs.removeDir(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local rmdir = resolveRmdir()
if rmdir then rmdir(realPath(root, rel)) end
return
end
love.filesystem.remove(rel)
end
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
-- cache-format bump does not leave orphaned files behind. No-op when the
-- portable cache is inactive (the save-directory copy is cleared by
@@ -280,4 +354,35 @@ function CacheFs.mountVersion(version)
return false
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue save, close
-- it, and press Play on Red: with blue/ still prepended, Red's
-- require("data.generated.*") and its generated art would silently resolve to
-- Blue's files. Callers must also drop the generated modules from
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
-- fixes the read path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
local sub = prefix:gsub("/+$", "")
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
local done = false
local fn = resolveUnmount()
if fn and base then
done = fn(base .. SEP .. sub) or done
end
-- also drop the love.filesystem.mount fallback, which registers the folder
-- under its bare name rather than its absolute path
if love.filesystem.unmount then
done = love.filesystem.unmount(sub) or done
end
return done
end
return CacheFs
+422 -19
View File
@@ -255,7 +255,36 @@ local function fileUrl(path)
return "file://" .. encoded
end
-- The native pickers below block the whole loop inside io.popen, and they are
-- opened straight out of mousepressed -- with the button still physically
-- down. SDL auto-captures the pointer for the length of a press (on X11 an
-- XGrabPointer with owner_events) and only drops that capture when it
-- processes the matching button-up, which it cannot do while we sit in popen
-- and never pump. The grab then outlives the click and every pointer event
-- over the file chooser is still routed to our window: the dialog draws and
-- keyboard-navigates (keyboard focus is a separate grab) but ignores the
-- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how
-- long the click was held, which is why the same build picks one ROM fine and
-- then hangs the mouse on the next. So pump until no button is held, letting
-- SDL see the release and let go first; bounded, so a stuck button costs a
-- moment and never the launcher. pump() only drains OS events into LOVE's
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
-- and the release is still delivered normally on the next frame.
local function releasePointerGrab()
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
and love.timer) then
return
end
local deadline = love.timer.getTime() + 1
while love.mouse.isDown(1, 2, 3) do
love.event.pump()
if love.timer.getTime() > deadline then break end
love.timer.sleep(0.005)
end
end
local function commandOutput(command)
releasePointerGrab()
local pipe = io.popen(command, "r")
if not pipe then return nil end
local result = pipe:read("*a")
@@ -338,7 +367,10 @@ local function chooseRom(promptName)
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
-- write the pick as UTF-8: the console's OEM codepage would mangle
-- non-ASCII names (Pokémon -> Pok\x82mon) and crash any text draw
-- that shows them (#325)
"if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
@@ -369,7 +401,16 @@ local function chooseZip()
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Mod archive (*.zip)|*.zip|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
-- copy the pick to a plain-ASCII temp name and answer with that:
-- the console's OEM codepage would mangle a non-ASCII path
-- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes,
-- so returning the original name both crashed the notice draw and
-- could never have opened the file (#325)
"if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_mod_pick.zip';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
@@ -400,7 +441,8 @@ local function chooseSav()
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
-- UTF-8, like the ROM and mod pickers (#325)
"if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
@@ -432,7 +474,10 @@ end
-- own cache (Red at the root, Blue under blue/), so both can be imported and
-- played side by side. onComplete(version) hands the chosen game off to boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced).
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
-- row -- main.lua opens the bundled save editor on that slot; when it is not
-- supplied the Edit label is not drawn at all).
function RomImporter.new(onComplete, opts)
opts = opts or {}
local android = love.system.getOS() == "Android"
@@ -441,6 +486,7 @@ function RomImporter.new(onComplete, opts)
onComplete = onComplete,
launcher = opts.launcher or false,
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
android = android,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
logo = love.graphics.newImage("assets/logo/logo.png"),
@@ -473,6 +519,14 @@ function RomImporter.new(onComplete, opts)
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
-- right stick scrolls the save-slot / mods lists.
_padCursor = { x = 0, y = 0 },
_padCursorActive = false,
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
_padDir = {},
_padInited = false,
}, RomImporter)
for _, version in ipairs(GameVersion.ORDER) do
@@ -525,6 +579,15 @@ function RomImporter.new(onComplete, opts)
end
end
-- On Linux handhelds a gamepad is usually already connected at boot; arm
-- the virtual cursor immediately so the player does not have to press a
-- button before seeing something move.
if self.launcher and love.system.getOS() == "Linux"
and love.joystick and love.joystick.getJoystickCount
and love.joystick.getJoystickCount() > 0 then
self:_activatePadCursor()
end
return self
end
@@ -928,15 +991,37 @@ function RomImporter:choose(version)
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then
self:startPath(path)
elseif love.system.getOS() ~= "OS X"
and love.system.getOS() ~= "Windows"
and love.system.getOS() ~= "Linux" then
return
end
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
-- on Android, which works when the game is launched as an unpacked
-- directory (see build-rg34xxsp.sh).
local name, data = findPendingRom(self.ready)
if name then
self:startData(data, name)
return
end
if love.system.getOS() == "Linux" then
local where = love.filesystem.getSourceBaseDirectory
and love.filesystem.getSourceBaseDirectory()
or love.filesystem.getSource and love.filesystem.getSource()
or "the game folder"
self.notice = {
version = self.chooseVersion,
status = "No file picker. Copy your .gb into:",
detail = where,
}
return
end
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
end
end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
self:_updatePadCursor(dt)
if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime()
repeat
@@ -953,6 +1038,125 @@ function RomImporter:update(dt)
until love.timer.getTime() - started >= 0.008
end
-- ------- gamepad virtual cursor (handheld / PortMaster) --------------------
local PAD_DEAD = 0.28
local PAD_SPEED = 560 -- px/s at full stick deflection
local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor()
if self._padCursorActive then return end
local w, h = love.graphics.getDimensions()
if not self._padInited then
self._padCursor.x = w * 0.5
self._padCursor.y = h * 0.45
self._padInited = true
end
self._padCursorActive = true
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "mods" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
end
idx = ((idx - 1 + delta) % #order) + 1
self.tab = order[idx]
self._slotPress = nil
self._modPress = nil
end
function RomImporter:_updatePadCursor(dt)
-- Real mouse motion yields the pad cursor so desktop users keep a normal
-- pointer after bumping a stick once.
local mx, my = love.mouse.getPosition()
if self._lastMouseX and self._padCursorActive then
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
self._padCursorActive = false
end
end
self._lastMouseX, self._lastMouseY = mx, my
local ax = self._padAxis.leftx or 0
local ay = self._padAxis.lefty or 0
local dx, dy = 0, 0
if math.abs(ax) > PAD_DEAD then dx = dx + ax end
if math.abs(ay) > PAD_DEAD then dy = dy + ay end
if self._padDir.dpleft then dx = dx - 1 end
if self._padDir.dpright then dx = dx + 1 end
if self._padDir.dpup then dy = dy - 1 end
if self._padDir.dpdown then dy = dy + 1 end
if dx ~= 0 or dy ~= 0 then
self:_activatePadCursor()
local mag = math.sqrt(dx * dx + dy * dy)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
local w, h = love.graphics.getDimensions()
local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(0, math.min(w, nx))
self._padCursor.y = math.max(0, math.min(h, ny))
end
-- Right stick scrolls the active list (save slots or mods).
local ry = self._padAxis.righty or 0
if math.abs(ry) > PAD_DEAD then
self:_activatePadCursor()
local step = -ry * 480 * dt
if self.tab == "mods" then
local maxS = self._modMax or 0
if maxS > 0 then
local next = (self.modScroll or 0) + step
self.modScroll = math.max(0, math.min(maxS, next))
end
elseif self.tab == "red" or self.tab == "blue" then
local maxS = (self._slotMax and self._slotMax[self.tab]) or 0
if maxS > 0 then
local next = (self.slotScroll[self.tab] or 0) + step
self.slotScroll[self.tab] = math.max(0, math.min(maxS, next))
end
end
end
end
function RomImporter:gamepadpressed(_, button)
self:_activatePadCursor()
if button == "a" then
-- Instant click at the virtual pointer (same path as a mouse/touch tap).
self:mousepressed(self._padCursor.x, self._padCursor.y, 1)
elseif button == "leftshoulder" then
self:_cycleTab(-1)
elseif button == "rightshoulder" then
self:_cycleTab(1)
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = true
elseif button == "start" or button == "back" then
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
if self.workState == "working" then return end
local version = self.tab
if version == "red" or version == "blue" then
if self.ready[version] then self:play(version) else self:choose(version) end
end
end
end
function RomImporter:gamepadreleased(_, button)
if button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = nil
end
end
function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value
if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end
end
end
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
function RomImporter:play(version)
if self.workState == "working" then return end
@@ -992,6 +1196,31 @@ local function printB(text, x, y)
love.graphics.print(text, x + 0.6, y)
end
-- UTF-8 helpers for the slot-rename field (#205). The `utf8` library only
-- exists inside LOVE (plain luajit, which loads this module in tests, has
-- none), so codepoint walking is done by hand -- the same lead-byte width
-- classes GenSave's encodeName uses. utf8Back drops the last codepoint;
-- utf8Cap truncates to maxChars whole codepoints.
local function utf8Back(t)
local i = #t
while i > 0 do
local b = t:byte(i)
i = i - 1
if b < 0x80 or b >= 0xC0 then break end -- lead or ASCII: dropped, done
end
return t:sub(1, i)
end
local function utf8Cap(t, maxChars)
local count, i = 0, 1
while i <= #t do
count = count + 1
if count > maxChars then return t:sub(1, i - 1) end
local b = t:byte(i)
i = i + ((b < 0x80) and 1 or (b < 0xE0) and 2 or (b < 0xF0) and 3 or 4)
end
return t
end
-- One reusable unit quad, recoloured per call, for every vertical gradient
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
@@ -1147,12 +1376,17 @@ function RomImporter:draw()
local pulse = self.pulse
self._s = s
-- Hover state (desktop only -- touch has no cursor). Panel methods read the
-- pointer + set self._anyHover through self:_hover; the cursor is set at the
-- end. Reset the per-frame hit rects so a tab with no controls (mods) cannot
-- Hover state. Desktop mouse, or the gamepad virtual cursor on handhelds
-- (Android stays touch-only -- no hover). Panel methods read the pointer +
-- set self._anyHover through self:_hover; the cursor is set at the end.
-- Reset the per-frame hit rects so a tab with no controls (mods) cannot
-- inherit last frame's game-panel buttons.
if self._padCursorActive then
self._mx, self._my = self._padCursor.x, self._padCursor.y
else
self._mx, self._my = love.mouse.getPosition()
self._hoverEnabled = not self.android
end
self._hoverEnabled = self._padCursorActive or not self.android
self._anyHover = false
self.romButtonRect = nil
self.playButtonRect = nil
@@ -1160,6 +1394,7 @@ function RomImporter:draw()
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / import button.
@@ -1509,8 +1744,53 @@ function RomImporter:draw()
-- events reach the launcher, so click-vs-drag is resolved here)
self:_updateSlotDrag()
-- save-slot rename modal (#205), drawn over everything
if self._rename then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
col(PAL.green, 0.5)
love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
love.graphics.print(Strings("Name save slot"), dx + 16 * s, dy + 14 * s)
-- the field: bordered strip, current text, blinking caret on the pulse
local fx, fy = dx + 16 * s, dy + 44 * s
local fw, fh = dw - 32 * s, 30 * s
col(PAL.bgBot, 0.9)
love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s)
love.graphics.setLineWidth(math.max(1, s))
col(PAL.cardBorder, 0.45)
love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s)
love.graphics.setFont(self.detailFont)
col(PAL.heading)
local shown = ellipsize(self.detailFont, self._rename.text, fw - 20 * s)
love.graphics.print(shown, fx + 10 * s, fy + (fh - self.detailFont:getHeight()) / 2)
if (self.pulse * 2 % 1) < 0.5 then
local cx = fx + 10 * s + self.detailFont:getWidth(shown) + 2 * s
col(PAL.green)
love.graphics.rectangle("fill", cx, fy + 6 * s, math.max(1, 1.5 * s),
fh - 12 * s)
end
love.graphics.setFont(self.hintFont)
col(PAL.detail)
printfB(Strings("Enter to save - Esc to cancel - empty clears"),
dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left")
end
-- pointer cursor over any interactive element (desktop only)
if self._hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
if self._hoverEnabled and not self._padCursorActive
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
if self._anyHover then
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
love.mouse.setCursor(self.handCursor)
@@ -1518,6 +1798,34 @@ function RomImporter:draw()
resetPointerCursor(self)
end
end
-- Gamepad virtual cursor (drawn last so it sits above the CRT overlay).
if self._padCursorActive then
local x, y = self._padCursor.x, self._padCursor.y
local hot = self._anyHover
love.graphics.push("all")
love.graphics.origin()
love.graphics.setLineWidth(1)
-- Drop shadow
love.graphics.setColor(0, 0, 0, 0.45)
love.graphics.polygon("fill",
x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26,
x + 18, y + 24, x + 11, y + 14, x + 20, y + 14)
-- Pointer body
if hot then
love.graphics.setColor(0.25, 0.95, 0.55, 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
love.graphics.polygon("fill",
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
love.graphics.setColor(0.05, 0.07, 0.12, 1)
love.graphics.polygon("line",
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
love.graphics.pop()
end
end
local function inside(r, x, y)
@@ -1525,6 +1833,20 @@ local function inside(r, x, y)
end
function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks
-- right-click a save-slot row to rename it (#205); desktop only (touch
-- has no secondary button)
if button == 2 then
if not self.android and self.workState ~= "working" then
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
self:_beginRename(self.panelVersion, r.id)
return
end
end
end
return
end
if button ~= 1 then return end
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
love.system.openURL(COMMUNITY_URL)
@@ -1577,17 +1899,24 @@ function RomImporter:mousepressed(x, y, button)
end
return
end
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- label never also selects the row. On desktop a press only ARMS a click:
-- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
-- on either never also selects the row. On desktop a press only ARMS a row
-- click: _updateSlotDrag commits it on release when the pointer did not move
-- (a moved pointer scrolls instead). Android has no reliable pointer
-- polling, so it selects on press. Edit and Delete fire immediately (small
-- fixed targets, no scroll conflict).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotEditRects or {}) do
if inside(r, x, y) then
if self.onEditSave then self.onEditSave(self.panelVersion, r.id) end
return
end
end
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -1629,6 +1958,16 @@ function RomImporter:mousepressed(x, y, button)
end
function RomImporter:keypressed(key)
if self._rename then
if key == "backspace" then
self._rename.text = utf8Back(self._rename.text)
elseif key == "return" or key == "kpenter" then
self:_commitRename()
elseif key == "escape" then
self._rename = nil
end
return
end
if self.workState == "working" then return end
if key == "return" or key == "space" or key == "kpenter" then
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
@@ -2049,6 +2388,13 @@ function RomImporter:_ensureSlots(version)
if not self.slots[version] then self:_refreshSlots(version) end
end
-- The host calls this when the save editor closes: the edited slot's player
-- name, badge count and dex total all feed the cached row summary, so it has
-- to be re-read rather than trusted across the round trip.
function RomImporter:savesChanged(version)
self:_refreshSlots(version)
end
-- Point the active slot at id (persisted immediately, per the contract) and
-- reflect it in the LOADED pill without a full relist.
function RomImporter:_selectSlot(version, id)
@@ -2056,6 +2402,33 @@ function RomImporter:_selectSlot(version, id)
self.activeSlot[version] = id
end
-- Inline slot rename (#205): right-click arms a modal text field; Enter
-- commits through SaveData.renameSlot (empty clears the label), Esc cancels.
-- While it is up, keypressed/textinput/mousepressed all route here first.
local MAX_SLOT_LABEL = 24
function RomImporter:_beginRename(version, id)
local label
for _, slot in ipairs(self.slots[version] or {}) do
if slot.id == id then label = slot.label break end
end
self._rename = { version = version, id = id, text = label or "" }
self._slotPress = nil -- cancel any armed click/drag on the list
end
function RomImporter:_commitRename()
local r = self._rename
if not r then return end
self._rename = nil
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
self:_refreshSlots(r.version)
end
function RomImporter:textinput(text)
if not self._rename then return end
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
end
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
-- scroll to the bottom (clamped next draw) so the new row is on screen.
function RomImporter:_newSlot(version)
@@ -2166,6 +2539,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
@@ -2185,6 +2559,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do
@@ -2211,6 +2586,24 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- Edit label, immediately left of Delete: opens the bundled save
-- editor (tools/save-editor) on this slot's file. Only drawn when the
-- host supplied onEditSave and the slot actually holds a save -- there
-- is nothing to edit in an empty slot, and offering it would open the
-- editor on a new-game stub the player never asked for.
local erect = nil
if self.onEditSave and slot.exists then
local edText = "Edit"
local edW = self.hintFont:getWidth(edText)
local edX = delX - 14 * s - edW
erect = { x = edX - 6 * s, y = delY - 4 * s,
width = edW + 12 * s, height = delH + 8 * s, id = slot.id }
local ehot = self:_hover(erect)
col(ehot and PAL.blue or PAL.warning)
love.graphics.print(edText, edX, delY)
rightReserve = rightReserve + edW + 20 * s
end
-- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0
if selected then
@@ -2229,7 +2622,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
local name = slot.name or Strings("NEW GAME")
-- a custom label (#205) wins over the player name; both ellipsize
local name = slot.label or slot.name or Strings("NEW GAME")
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV)
@@ -2259,6 +2653,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
if erect then
local evy = math.max(erect.y, listTop)
local evy2 = math.min(erect.y + erect.height, listBottom)
if evy2 > evy then
self.slotEditRects[#self.slotEditRects + 1] =
{ x = erect.x, y = evy, width = erect.width, height = evy2 - evy,
id = slot.id }
end
end
end
end
love.graphics.setScissor()
@@ -2463,7 +2866,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end
-- right cluster: status chip, toggle, Delete vertically centred
-- right cluster: status chip, toggle, Delete -- vertically centred
local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status)
+21 -2
View File
@@ -47,6 +47,17 @@ ItemEffects.BALLS = BALLS
function ItemEffects.isBall(id) return BALLS[id] or false end
function ItemEffects.isStone(id) return STONES[id] or false end
-- Does using this item take item_effects.asm's .healHP path, the one that
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
-- keeps the party picker open for these so the fill has something to draw
-- on (#252).
function ItemEffects.healsHP(id)
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef)
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
@@ -257,6 +268,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { Strings("It won't have\nany effect.") }
end
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
-- the item landed (item_effects.asm latches it with the party menu still
-- up), so latch it here and hand it back as extra.healedFrom for the
-- party-menu fill (#252)
local before = target.hp
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp
else
@@ -268,7 +284,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
cureActiveToxic(battle, target)
end
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", msgs
return "consumed", msgs, { healedFrom = before }
end
local cures = STATUS_HEAL[itemId]
@@ -289,7 +305,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
-- a revive takes the same .healHP -> .doneHealing route, animating up
-- from the fainted mon's 0 HP (#252)
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
{ healedFrom = 0 }
end
if itemId == "RARE_CANDY" then
+61 -2
View File
@@ -112,6 +112,19 @@ local function scalar(v)
return tostring(v)
end
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
-- `key > 0`. They have to hash the same, or a match ends over a difference
-- that does not exist. They routinely disagree: the menu-phase flinch
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
-- (fightLockedAction) are written by whichever machine is sitting at ITS
-- OWN menu, and in a link battle that is a different battler on each side,
-- so one peer holds flinched=false / boundTurns=0 where the other still
-- holds nil with two identical simulations underneath.
local function off(v)
return v == nil or v == false or v == 0
end
local function stageStr(b)
local out = {}
for i, stat in ipairs(STAGES) do
@@ -131,7 +144,7 @@ end
local function volStr(b)
local out = {}
for _, key in ipairs(VOLATILE) do
if b[key] ~= nil then
if not off(b[key]) then
out[#out + 1] = key .. "=" .. scalar(b[key])
end
end
@@ -168,6 +181,17 @@ end
local PARTS = { "actives", "volatile", "bench" }
-- Which components are allowed to end a match. `actives` and `bench` carry
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
-- party -- so a divergence there is a real split between the two
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
-- both sides recompute from the authoritative state every turn (see `off`
-- above): it can disagree for a turn without either side being wrong, and
-- when it does mean something real it lands in `actives` as damage or
-- status within a turn or two, where it is caught. Ending a match on it
-- alone cost players games they were winning, over nothing.
local FATAL_PART = { actives = true, bench = true }
-- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep
@@ -315,12 +339,23 @@ function LinkBattle.new(game, net, opts)
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH })
localHash = localH, remoteHash = remoteH,
fatal = true })
endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end
-- a non-fatal component split: both sides log it and carry on, so the
-- match is decided by the battle rather than by bookkeeping
local function noteDrift(s, turn, component, localH, remoteH)
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
component, tostring(turn), tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = false })
end
-- a verified turn stays recorded: consuming it here left a finished
-- battle holding 0-1 entries, so the whole-battle sweep the link suite
-- runs over localHashes had nothing left to compare
@@ -333,9 +368,12 @@ function LinkBattle.new(game, net, opts)
if mine and theirs then
for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then
if FATAL_PART[component] then
reportDesync(s, turn, component, mine[component], theirs[component])
return
end
noteDrift(s, turn, component, mine[component], theirs[component])
end
end
end
-- a v1 peer sends the combined value only
@@ -565,10 +603,19 @@ function LinkBattle.new(game, net, opts)
if net.closed and not s.linkEnded and not s.result then
endAsDraw(s)
end
-- Both early returns below skip baseUpdate, which is where the
-- presentational clock normally advances -- so tick it here, in the same
-- order baseUpdate would (fx, then the queue). Without this an
-- animation caught mid-flight froze for the whole wait: a flash stopped
-- on its inverted BGP step and repainted the UI in inverted shades, and
-- a pic part-way through a slide or grow-in stayed off screen, which is
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
if s.phase == "waitRemote" then
s:tickFx()
return -- the other side is still choosing
end
if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then
s.afterQueue = "menu"
s.phase = "menu"
@@ -742,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
end
s:act(function()
-- the two real players cleared their flinch flags when their move
-- menu opened; a spectator has no menu, so it does it here instead,
-- at the same point in the turn (see BattleState:clearTurnFlinches).
-- Without this a flinch survived into the next turn and ate a move
-- that landed in the real match, and the replay -- sharing the RNG
-- stream -- was watching a different battle from that point on.
s:clearTurnFlinches()
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
and decodeWireAction(s, hostMsg, s.player) or nil
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
@@ -837,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
if net.closed and not s.linkEnded and not s.result then
endSpectate(s)
end
-- same as the real-participant loop: every path that skips baseUpdate
-- still has to advance the presentational clock, and a spectator sits in
-- waitBoth between every single turn
if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then
s.afterQueue = "waitBoth"
s.phase = "waitBoth"
@@ -845,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
if s.phase == "menu" or s.phase == "waitBoth" then
s:tickFx()
return -- frozen between resolved turns; never a real decision here
end
baseUpdate(s, dt)
+46 -1
View File
@@ -73,6 +73,9 @@ end
function LinkState.new(game)
local self = setmetatable({}, LinkState)
self.game = game
-- a link session runs at 1X on both machines whatever either player set
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
game.linkSession = true
self.stage = "menu"
self.index = 1
self.addr = ipDigits(Net.lanIP())
@@ -98,6 +101,7 @@ end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -106,6 +110,46 @@ function LinkState:exitWith(message, reason)
end
end
-- Online play meets strangers, so it requires vanilla on both ends
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
-- boot and there is no unmerge, so switching them off has to go through a
-- relaunch -- but the player should not have to go find the mod manager and
-- work out which mods count. This turns every enabled mod off, records
-- them so the mod manager can put them back, and relaunches. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
local mods = Handshake.mods(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
end
local list = table.concat(names, ", ")
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
local text = Strings(
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
self.game.linkSession = nil
Runtime.emit("link.ended", { reason = "error" })
if self.net then self.net:close() end
game.stack:pop()
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
if not yes then return end
-- setEnabled persists the toggle itself (Loader:_saveState), so the
-- relaunch comes up vanilla and the mod manager lists them as disabled
-- for the player to switch back on afterwards
for _, mod in ipairs(mods) do
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
end
if game.restartWithMods then
game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
end
end }))
end
-- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two
@@ -202,7 +246,7 @@ function LinkState:update(dt)
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
self:offerVanillaRestart()
return
end
if self.index == 2 then
@@ -509,6 +553,7 @@ function LinkState:updateTrade(input)
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.game.linkSession = nil -- this path pops without exitWith
self.net:close()
self.game.stack:pop()
local game = self.game
+6
View File
@@ -91,6 +91,11 @@ function Tournament.new(game)
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
-- everything from here to exitWith runs at 1X regardless of the GAME
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
-- down on the logic step, so fast-forward would hand one player less
-- real time to choose than the opponent they are racing
game.linkSession = true
Sound.startLoop(game.data, MUSIC)
return self
end
@@ -113,6 +118,7 @@ end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
+58 -10
View File
@@ -3,8 +3,18 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
-- mod by removing that tree + clearing options.mods[id].
--
-- Where that tree lives is CacheFs's call, not love.filesystem's: a portable
-- install (portable.txt beside the executable) keeps its mods in the game
-- folder like everything else it owns, and only the OS save directory
-- otherwise (#330 -- love.filesystem.write always resolves to the save dir,
-- so the installer used to strand every mod in appdata). Reads stay on
-- love.filesystem: the portable folder is on the physfs read path either way
-- (it IS the source for a `love <gamedir>` run, and CacheFs mounts it for a
-- fused build), which is why those mods still loaded while landing in the
-- wrong place.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery,
@@ -15,6 +25,7 @@ local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
@@ -143,6 +154,13 @@ local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
-- A fused portable build keeps its mods in the game folder next to the
-- executable; resolving the cache root is what mounts that folder onto the
-- physfs read path, so this is what makes those mods enumerable at all
-- (#330). A source run needs nothing (the game folder IS the source), and
-- the launcher's readiness check has usually resolved it already; the call
-- is cached and idempotent.
CacheFs.root()
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
@@ -233,11 +251,14 @@ local function topLevelPaths(mount)
return paths
end
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
-- come from love.filesystem (the .zip is mounted there); every write goes
-- through CacheFs so it lands in the portable game folder when portable.txt is
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
-- CacheFs.write creates the parent chain on both paths, which also means an
-- empty folder inside the .zip is simply not carried over (it holds nothing).
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
@@ -248,13 +269,18 @@ local function copyTree(src, dst)
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
local ok, err = CacheFs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of
-- love.filesystem no-opping outside the save directory (#330). Directories
-- are removed after their children, since rmdir refuses a non-empty one.
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
@@ -263,7 +289,15 @@ local function removeTree(path)
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
CacheFs.removeDir(path)
else
CacheFs.remove(path)
end
-- A portable install can still be carrying a pre-#330 copy in the OS save
-- directory, which is where every install used to land and which physfs
-- searches first. CacheFs only touched the game folder, so clear the
-- save-directory twin too or that copy would keep the mod alive; outside
-- portable mode this repeats the delete CacheFs just did and no-ops.
fs.remove(path)
end
@@ -322,10 +356,19 @@ function LauncherMods.installZip(source)
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
-- the copy and the rollback, then hand back whatever the launcher had set
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
-- parent chain in both homes, and doing it through love.filesystem would
-- only ever make the directory in the save dir (#330).
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end
CacheFs.prefix = savedPrefix
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
@@ -334,8 +377,9 @@ function LauncherMods.installZip(source)
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
-- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
@@ -352,7 +396,11 @@ function LauncherMods.uninstall(id)
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest)
CacheFs.prefix = savedPrefix
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
+5 -1
View File
@@ -162,7 +162,11 @@ function Loader:_loadState()
if next(options.mods or {}) == nil and self.fs.getInfo
and self.fs.getInfo(MOD_STATE_FILE) then
local chunk = self.fs.load(MOD_STATE_FILE)
local ok, state = chunk and pcall(chunk)
-- `chunk and pcall(chunk)` truncates to one value, so state came back nil
-- however well the chunk ran and the migration below never fired once:
-- the guard has to be a statement for pcall's second return to survive.
local ok, state = false, nil
if chunk then ok, state = pcall(chunk) end
if ok and type(state) == "table" then
for id, disabled in pairs(state) do
if disabled then
+1 -1
View File
@@ -187,7 +187,7 @@ end
-- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight a B-cancel
-- only considers mons that gained a level during the fight -- a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
+5 -1
View File
@@ -31,7 +31,7 @@ end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent no LearnMove prompts.
-- the oldest slot out when full. Silent -- no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
@@ -75,6 +75,10 @@ function Pokemon.new(data, species, level, rng)
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = stats,
hp = stats.hp,
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
-- (#206). Evolution.apply mutates in place and never touches this.
catchRate = def.catchRate,
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
moves = moves,
}
+22
View File
@@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp)
return out
end
-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a
-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and
-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out
-- of an imported .sav arrive without one (src/save_convert/GenSave.lua
-- decodeMon, isParty = false). The original derives them on demand at
-- exactly two moments: when a box or daycare mon's status screen opens
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
-- a tampered save cannot overfill the bar. A mon that already has stats is
-- returned untouched, so a vanilla save round-trips. #233, #304
function Stats.ensure(speciesDef, mon)
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon
end
mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp)
mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp))
return mon
end
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
local STAGE_MULT = {
+15 -1
View File
@@ -23,6 +23,20 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
local FLASH_HOLD = 2 -- frames per palette step
local FLASH_CYCLES = 3
-- The screen stays black after the wipe lands. Shrink and Split ask for it
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
-- frame budget for that work, not a number pokered states; retune this one
-- constant against a reference recording if it reads long or short.
local BLACK_HOLD = 30
local TILE = 8
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
@@ -208,7 +222,7 @@ function BattleTransition:update(dt)
self.t = 0
end
else
if self.t >= self.wipeLen + 6 then
if self.t >= self.wipeLen + BLACK_HOLD then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
+9
View File
@@ -25,7 +25,16 @@ local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
--
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
-- checkout exercise the unsupported path without stubbing love.system.
function GBCFX.isSupported()
local env = os.getenv("POKEPORT_GBCFX")
if env == "1" then return true end
if env == "0" then return false end
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
+77 -19
View File
@@ -2,6 +2,11 @@
-- screen: pokered overlays the $62-$7F font area with the HP bar /
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
--
-- The two screens do NOT use the same overlay: the status screen scatters
-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps
-- its № and <ID> glyphs alive. HudTiles.tile draws the battle layout,
-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280
local Assets = require("src.render.Assets")
@@ -23,32 +28,62 @@ local PAGES = {
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
}
local tiles
function HudTiles.tile(code, x, y, tint)
if not tiles then
tiles = {}
-- The STATUS SCREEN overlays the SAME sheets differently, and the layout
-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
-- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
-- the glyphs the screen prints "№." and "<ID>№/" from
-- (constants/charmap.asm:69-73). The battle overlay instead copies
-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
-- burying № under a line tile, so the status screen needs its own table.
-- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the
-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280
local STATUS_PAGES = {
{ id = "font_battle_extra",
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
{ id = "battle_hud_1",
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
{ id = "battle_hud_3",
image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 },
{ id = "battle_hud_2",
image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 },
}
local tiles, statusTiles
-- Build one code -> {img, quad} map from a page list. `count` caps a page
-- at the number of tiles the asm actually copies (the extracted sheets all
-- carry 3 tiles; the status overlay uses fewer). A mod's registered page
-- swaps the image in either table, but only the battle table honors its
-- `base`: the status layout is the asm's own placement, and sliding hud_2
-- there would bury № again.
local function build(pages, fixedBase)
local out = {}
local registered = require("src.core.Data").font
registered = registered and registered.pages or nil
local function add(path, base)
for _, page in ipairs(pages) do
local override = registered and registered[page.id]
local path, base = page.image, page.base
if override and override.image then path = override.image end
if not fixedBase and override and override.base then base = override.base end
local ok, img = pcall(Assets.image, path)
if not ok then return end
if ok then
local iw, ih = img:getDimensions()
local per = iw / 8
for i = 0, per * (ih / 8) - 1 do
tiles[base + i] = {
local count = page.count or per * (ih / 8)
for i = 0, count - 1 do
out[base + i] = {
img = img,
quad = love.graphics.newQuad((i % per) * 8,
math.floor(i / per) * 8, 8, 8, iw, ih),
}
end
end
for _, page in ipairs(PAGES) do
local override = registered and registered[page.id]
add(override and override.image or page.image,
override and override.base or page.base)
end
end
local t = tiles[code]
return out
end
local function put(t, x, y, tint)
if not t then return end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(tint or { 1, 1, 1, 1 })
@@ -56,9 +91,23 @@ function HudTiles.tile(code, x, y, tint)
love.graphics.setColor(r, g, b, a)
end
function HudTiles.tile(code, x, y, tint)
if not tiles then tiles = build(PAGES) end
put(tiles[code], x, y, tint)
end
-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP
-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps
-- using the battle table. #280
function HudTiles.statusTile(code, x, y, tint)
if not statusTiles then statusTiles = build(STATUS_PAGES, true) end
put(statusTiles[code], x, y, tint)
end
-- lazy: the next tile() rebuilds every page from the search path
function HudTiles.invalidate()
tiles = nil
statusTiles = nil
end
Assets.register(HudTiles.invalidate)
@@ -78,6 +127,11 @@ end
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
--
-- segments: how many 8px cells the bar spans (6, the hardware width,
-- unless a caller asks for more -- the widescreen battle layout has room
-- for a longer bar in the same tiles). The color thresholds scale with
-- it so a wider bar turns yellow and red at the same fractions of full.
--
-- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
@@ -87,18 +141,22 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end
local tint
if not grayFill then
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local green = math.ceil(27 * segments / 6)
local yellow = math.ceil(10 * segments / 6)
local name = px >= green and "GREENBAR"
or px >= yellow and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
@@ -108,11 +166,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
math.min(1, c[3] / 170), 1 }
end
end
for i = 0, 5 do
for i = 0, segments - 1 do
local seg = math.min(8, math.max(0, px - i * 8))
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
end
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
end
return HudTiles
+121 -33
View File
@@ -5,10 +5,11 @@
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
-- transforms every zone's palette at send time via effectiveColors.
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors.
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
-- per-species mon colors.
local GameVersion = require("src.core.GameVersion")
@@ -18,14 +19,18 @@ local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
-- label was a misnomer -- it never was the real Game Boy Color palette).
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
-- "GBC" label was a misnomer (it never was the real Game Boy Color palette),
-- and "ADVANCED" because `redpp` is the richest colorization of the three
-- rather than anything Red-specific -- it reads as a misnomer outright on a
-- Blue playthrough. Comments elsewhere still call it RED++, the name it has
-- carried in this file since it landed.
PaletteFX.MODE_LABELS = {
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
ogred = "OG RED", gbc = "SGB", redpp = "ADVANCED", og = "OG",
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
}
PaletteFX.mode = "gbc"
@@ -88,6 +93,25 @@ function PaletteFX.ogObj()
return PaletteFX.GBC_OBJ, "gbcobj"
end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent
-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry
-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2
-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the
-- whole point: the zone shader then colors a character out of the same map
-- palette it colors the ground with, which is all the Super Game Boy can do to
-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE
-- palette's grass green instead of its light-blue (#150).
PaletteFX.OBP0_SHADES = {
{ 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 },
}
function PaletteFX.dmgObj()
return PaletteFX.OBP0_SHADES, "obp0"
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -202,32 +226,45 @@ function PaletteFX.usesGbcPack(mode)
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
-- background, pink over Blue's blue background), so the player and NPCs carry a
-- fixed object color instead of tinting with whatever region palette their
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- = light-blue and the character clashed with the grass it should blend into).
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
-- green over Red's red background, pink over Blue's blue background), so on
-- that machine the player and NPCs carry a fixed object color instead of
-- tinting with whatever region palette their feet stand over. An EARLIER
-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
-- regression. RED++ colors sprites through the usesGbcPack() path in
-- SpriteRenderer instead.
-- palette is GBC_OBJ (green), so baking that here is the fix, not that
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
-- BG palette; only OG RED short-circuits BG to the one global red palette.
--
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and
-- nothing else), so a character there wears the very palette its map does.
-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the
-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue
-- #150 actually caught was the missing rOBP0 step, not a missing object
-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4
-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1,
-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into
-- the grass exactly as #150's reference shot shows. Drawn with an identity
-- shade map it landed on shade 2 = light-blue instead, which is the clash #150
-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets
-- the zone shader color the result. RED++ colors sprites through the
-- usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc"
return mode == "ogred"
end
-- ------- post-zone sprite redraw (GBC mode)
-- ------- post-zone sprite redraw (OG RED)
--
-- In GBC mode the world canvas still runs through the per-map zone
-- In OG RED the world canvas still runs through the whole-screen zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels. (SGB used to come through here too; it no longer bakes an object
-- palette at all, so its characters are colorized by the zone like the ground
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
@@ -529,6 +566,34 @@ function PaletteFX.permute(colors, map)
colors[map[2] + 1], colors[map[3] + 1] }
end
-- ------- global shade map (rBGP)
--
-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing
-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6
-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted
-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white
-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE
-- screen darkens -- the original never cuts a window of light around the
-- player (#322) -- and FLASH clears wMapPalOffset again
-- (engine/menus/start_sub_menus.asm .flash).
--
-- Renderer:beginFrame clears this every frame and the state that draws a dark
-- map re-arms it while it draws, so it can never outlive the map it belongs
-- to: a battle or a full-screen menu draws with no map beneath it and comes
-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a`
-- leaves the original.
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
local shadeMap = nil
function PaletteFX.setShadeMap(map)
shadeMap = map
end
function PaletteFX.shadeMap()
return shadeMap
end
function PaletteFX.setMode(mode)
local prev = PaletteFX.mode
local ok = false
@@ -598,19 +663,42 @@ end
-- Transform a 4-color palette for the active COLORS display mode.
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
-- The "paper" shade of the active display mode: what a DMG-white background
-- pixel ends up as once colorization has run. Every palette in the SGB pack
-- carries the same off-white (255,239,255) as color 0, so this is well
-- defined for the whole screen rather than per zone. Goes through
-- PaletteFX.pal so OG RED short-circuits to the one global boot-ROM BG
-- palette and RED++ falls back to the ROM pack, then through effectiveColors
-- so the mono and inverted modes get their own paper (CLASSIC's pea green,
-- and the dark paper the inverted modes should have). Returns r, g, b in
-- 0..1, white if nothing resolves.
function PaletteFX.paperShade(data)
local base = PaletteFX.pal(data, "GREENBAR") or PaletteFX.GRAYS
local colors = PaletteFX.effectiveColors(base) or base
local c = colors and colors[1]
if not c then return 1, 1, 1 end
return c[1] / 255, c[2] / 255, c[3] / 255
end
function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
local out = c
if mode == "og" then
return PaletteFX.GRAYS
out = PaletteFX.GRAYS
elseif mode == "og_inv" then
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
elseif mode == "classic" then
return PaletteFX.CLASSIC
out = PaletteFX.CLASSIC
elseif mode == "gbc_inv" then
return PaletteFX.permute(c, INV_MAP)
out = PaletteFX.permute(c, INV_MAP)
end
return c
-- The shade map goes on LAST: rBGP is a hardware register write, so it
-- composes on top of whatever colors the display mode settled on -- a dark
-- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too
-- (#322). permute() is the identity (and returns `out` itself, which the
-- mod-graphics parity check leans on) while nothing has armed one.
return PaletteFX.permute(out, shadeMap)
end
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
+47 -8
View File
@@ -15,8 +15,15 @@ local Runtime = require("src.mods.Runtime")
local Renderer = {}
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
-- screen is laid out in; uiWidth/uiHeight are the surface actually
-- allocated this frame, which a state may widen through setUISize (the
-- widescreen battle layout asks for 304x144). Anything drawing a normal
-- 160x144 screen can keep reading WIDTH/HEIGHT.
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
Renderer.MAX_UI_WIDTH = 640
Renderer.MAX_UI_HEIGHT = 576
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
@@ -83,7 +90,8 @@ function Renderer:init()
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -115,7 +123,31 @@ end
-- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
local w, h = self:uiSize()
return math.max(1, math.floor(math.min(pw / w, ph / h)))
end
-- the native-pixel UI surface in use right now
function Renderer:uiSize()
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
end
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
-- only when the size actually changes, so the classic path never rebuilds
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
-- both ends -- never smaller than the Game Boy screen every layout assumes,
-- never large enough for a bad request to allocate an unbounded canvas.
function Renderer:setUISize(w, h)
if type(w) ~= "number" or type(h) ~= "number"
or w < self.WIDTH or h < self.HEIGHT
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
w, h = self.WIDTH, self.HEIGHT
end
w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
@@ -174,6 +206,9 @@ function Renderer:beginFrame(transparent)
-- draws this one
PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws()
-- rBGP is a per-frame register here: the state that draws a dark map
-- re-arms it while it draws (#322), so nothing inherits last frame's
PaletteFX.setShadeMap(nil)
PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas)
if transparent then
@@ -440,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -480,8 +516,11 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas(present)
end
-- Default letterbox is black. Battle (and any state that opts in via
-- letterboxWhite) fills the voids white so the window matches the
-- white battle canvas instead of showing black bars.
-- letterboxWhite) fills the voids with the display mode's paper shade, so
-- the bars match the canvas they frame instead of showing black. Not a
-- literal white: the battle canvas is colorized, and in SGB mode its paper
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
-- a visibly brighter border.
local clearR, clearG, clearB = 0, 0, 0
if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game")
@@ -489,7 +528,7 @@ function Renderer:endFrame(zones, worldZones)
local base = stack and stack.visibleBase and stack:visibleBase()
local state = base and stack.states and stack.states[base]
if state and state.letterboxWhite then
clearR, clearG, clearB = 1, 1, 1
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end
end
love.graphics.setColor(clearR, clearG, clearB, 1)
+26 -5
View File
@@ -18,10 +18,14 @@ local function getImage(path)
return imageCache[path]
end
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
-- shades to the resolved OBP colors, cached per (image path, group).
-- Overworld sprite OBJ-palette recolor, baked into an ImageData like
-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
-- (image path, group). Every colour mode goes through it now (#301): RED++
-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite),
-- OG RED the one boot-ROM object palette, and everything else the plain
-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG
-- shades for the zone shader to colour.
--
-- Sprite sheets carry no real alpha (every pixel, including the
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
@@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage()
-- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj())
end
return self.image
-- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
-- in DMG shades so the zone shader colors it out of the map's own palette,
-- but still bakes rOBP0 = $D0 in and keys OBJ color 0 to alpha -- the two
-- things a raw sheet blit cannot express (#301, #150). The sheets carry no
-- real alpha (see getObpImage), so returning self.image here would put an
-- opaque white box behind every character a pipeline textures.
return getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
@@ -152,6 +162,17 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true
else
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
-- has no post-zone replay to restore a bake): the sprite stays in DMG
-- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the
-- whole-canvas zone shader colors it with the map's palette. That is the
-- only thing the Super Game Boy can do to an OBJ, since pokered never
-- sends the OBJ_TRN packet that would give sprites palettes of their own
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN /
-- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued:
-- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
+20 -1
View File
@@ -27,6 +27,11 @@ local MAX_COLS = 18
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
-- (default 3, Delay3) and the box pops itself + calls onDone. No
-- blinking cursor, no Press_AB beep.
-- opts.auto.tick, when given, runs once per frame for as long as the box
-- is held open. It is the only per-frame hook a script has while a box is
-- up: StateStack updates the top state only, so the overworld and its
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
-- its spin off it, data/scripts/story5.lua, #249).
function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox)
self.game = game
@@ -179,6 +184,13 @@ function TextBox:update(dt)
self.autoSrc = self.auto.sound and self.auto.sound() or nil
self.autoTimer = 0
end
-- auto.tick: one call per frame for as long as the box is held open,
-- run before the autoSrc gate so a tick-driven gate can clear itself
-- on the same frame. It is the only per-frame hook a script gets
-- while a box is up, since StateStack updates the top state only and
-- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin,
-- #249).
if self.auto.tick then self.auto.tick() end
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish)
end
@@ -294,10 +306,17 @@ function TextBox:draw()
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
-- Only the retained line carries the offset: it slides up from where it
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
-- box's bottom border -- whenever the typewriter beat the 4-frame slide
-- (#314). The sub-tile slide is ours to begin with: ScrollTextUpOneLine
-- (home/text.asm:283) copies the rows up whole and waits 5 frames, so
-- nothing in the original is ever drawn between two rows.
local off = self.scrollPx or 0
local ys = { self.line1Y, self.line2Y }
for i, line in ipairs(self.shown) do
local y = (ys[i] or self.line2Y) + off
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
for j, code in ipairs(line) do
Font.drawCode(code, self.textX + (j - 1) * 8, y)
end
+55 -16
View File
@@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
-- trees (default) solid tree wall $0F (Viridian/Cerulean/Celadon)
-- water solid water $43 (Cinnabar/Route 19 border block)
-- black solid black (no tiled metatile)
-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon)
-- water -- solid water $43 (Cinnabar/Route 19 border block)
-- black -- solid black (no tiled metatile)
-- Other tilesets keep their designated border (interiors stay black/void).
local TREE_WALL_BLOCK = 0x0F
local WATER_BORDER_BLOCK = 0x43
@@ -475,6 +475,13 @@ function TileRenderer.new(map, data)
-- match the atlas's static tiles instead of showing raw grayscale
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
-- (see getKeyedTile): same source image and palette groups, so keep the
-- context rather than re-deriving it per draw.
gbcCtx.imagePath = map.tileset.image
gbcCtx.perRow = map.tileset.tilesPerRow
self.gbcCtx = gbcCtx
self.gbcKeyed = {}
end
end
-- a full-color atlas colors everything it paints, ring and border fill
@@ -672,25 +679,58 @@ local function getColor0KeyShader()
return color0KeyShader or nil
end
-- draw a cell's bottom tile row without touching the shader (the caller
-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's
-- upright pass wraps it with a color-0-keyed palette shader instead
-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground.
-- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for.
local function getKeyedTile(self, tile)
local ctx = self.gbcCtx
local cached = self.gbcKeyed[tile]
if cached ~= nil then return cached or nil end
local img = false
if ctx.groupColors and love.image and love.image.newImageData then
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile)
local colors = group and ctx.groupColors[group + 1]
local src = Assets.imageData(ctx.imagePath)
local ox = (tile % ctx.perRow) * 8
local oy = math.floor(tile / ctx.perRow) * 8
local out = love.image.newImageData(8, 8)
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = src:getPixel(ox + px, oy + py)
-- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so
-- the keyed pixels are exactly the ones the shader path keys
local shade0 = r > 0.83
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(px, py, r, g, b, shade0 and 0 or a)
end
end
img = love.graphics.newImage(out)
end
self.gbcKeyed[tile] = img
return img or nil
end
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local quad = self.quads[self.map:tileAt(tx, ty)]
local tile = self.map:tileAt(tx, ty)
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
if keyed then
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
else
local quad = self.quads[tile]
if quad then
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
end
end
end
end
-- redraw a cell's bottom tile row (tall grass hides the lower half of
-- sprites standing in it, like the GB sprite-priority trick)
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
local shader = getColor0KeyShader()
-- the RED++ path is pre-keyed; the white test would be a no-op there at
-- best, and a false hit on some other group's near-white color 0 at worst
local shader = not self.gbcCtx and getColor0KeyShader() or nil
if shader then love.graphics.setShader(shader) end
self:drawCellBottomRaw(cx, cy, camX, camY)
if shader then love.graphics.setShader() end
@@ -712,13 +752,6 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
end
end
-- Window cover for the static tile layer. Refill the reusable window batch
-- (and the per-entry animated batches) only when the camera has scrolled past
-- what they already cover; a small margin keeps small scrolls free. Cost
-- scales with the view, never the map -- crossing a seam or warping in builds
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
-- there, so only body tiles are gathered here.
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
function TileRenderer:ensureWindow(camX, camY, vw, vh)
@@ -873,6 +906,12 @@ end
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
function TileRenderer:release()
self:releaseBatches()
if self.gbcKeyed then
-- baked per instance, shared with nobody (see getKeyedTile)
for _, img in pairs(self.gbcKeyed) do safeRelease(img) end
self.gbcKeyed = nil
self.gbcCtx = nil
end
if self.gbcAtlas and self.image then
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
+119 -9
View File
@@ -38,6 +38,8 @@ local NAME_LENGTH = 11
local PARTY_LENGTH = 6
local MONS_PER_BOX = 20
local NUM_BADGES = 8
local NUM_CITY_MAPS = 11 -- PALLET_TOWN..SAFFRON_CITY, the bit width of
-- wTownVisitedFlag (constants/map_constants.asm)
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
@@ -65,6 +67,22 @@ O.numPcItems = O.mainData + 579 -- 1B
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
O.coins = O.mainData + 685 -- 2B BCD
-- wTownVisitedFlag (ram/wram.asm:2057): the FLY destination set, a
-- flag_array NUM_CITY_MAPS whose bit index IS the town's map index (see the
-- decode note). Triangulated from both neighbours, which agree exactly:
-- backwards from the checksum-covered, independently derived O.eventFlags
-- below by summing every wram.asm declaration between the two labels --
-- 2 (wTownVisitedFlag) + 2 (wSafariSteps) + 1 + 1 + 2 + 1 + 1 + 1 + 1 + 1
-- + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 (wBeatGymFlags) + 1 + 1 + 1
-- (wStatusFlags3, aliased wCableClubDestinationMap) + 1 + 1 + 1 + 1 + 1 + 1
-- + 1 + 1 + 1 (wMovementFlags) + 2 + 2 + 1 + 1 + 2 + 1 + 1 + 2 + 1 + 1 + 2
-- = 60, so 1104 - 60 = 1044; and forwards from O.coins (mainData + 685)
-- with 2 (wPlayerCoins) + 32 (wToggleableObjectFlags, flag_array $100) + 7
-- + 1 (wSavedSpriteImageIndex) + 33 (wToggleableObjectList) + 1 + 200
-- (wGameProgressFlags..End) + 56 + 14 (wObtainedHiddenItemsFlags,
-- flag_array MAX_HIDDEN_ITEMS = 112) + 2 (wObtainedHiddenCoinsFlags) + 1
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
@@ -232,12 +250,17 @@ local function encodeName(buf, off, len, text)
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
-- left untouched: when encoding over a template they stay as the original
-- save's post-terminator padding (so an unchanged name round-trips
-- byte-identical), and on a templateless export they stay zero-filled. The
-- game reads a name only up to the first $50, so whatever follows is inert.
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
-- field: every real save the naming screen ever wrote fills the tail
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
-- stay untouched, so an unchanged name still round-trips
-- byte-identical and stale template data survives.
if i < len then setByte(buf, off + i, 0x50) end
for j = i + 1, len - 1 do
local cur = buf[off + j + 1]
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
end
end
-- ------------------------------------------------------------------
@@ -375,6 +398,40 @@ function GenSave.crosswalks(data)
}
end
-- Gen1 has no "is nicknamed" bit. An un-nicknamed mon literally stores its
-- species' standard name in the nickname slot: engine/menus/naming_screen.asm
-- AskName's .declinedNickname copies wNameBuffer (the MonsterNames entry
-- GetMonName just loaded) straight over the mon's nickname field. The game
-- recovers "was it nicknamed?" by comparing the two --
-- engine/pokemon/evos_moves.asm RenameEvolvedMon rewrites the name on
-- evolution only while the stored one still equals the PRE-evolution
-- species' standard name ("Renames the mon to its new, evolved form's
-- standard name unless it had a nickname, in which case the nickname is
-- kept"). This project models that state as mon.nickname == nil instead:
-- every display site reads `mon.nickname or def.name` and
-- src/pokemon/Evolution.lua deliberately never touches the field. So the
-- two conventions must be translated at this boundary, or an imported
-- SQUIRTLE still reads "SQUIRTLE" after it becomes a WARTORTLE and an
-- engine-origin export writes the species CONSTANT ("NIDORAN_M", whose "_"
-- has no charmap glyph and encodes as "?") where the cartridge keeps the
-- display name. Both read back as a forced nickname (#257).
--
-- def.name is byte-for-byte what the cartridge stores: tools/extract/
-- pokemon.py parse_names reads pokered's data/pokemon/names.asm, the very
-- table GetMonName loads from, and all 151 names round-trip exactly through
-- src/save_convert/data/charmap.lua, so the equality test below is exact
-- and never mis-fires on a name the charmap mangles.
local function speciesName(cw, species)
local def = species and cw.speciesDefs[species]
return (def and def.name) or species or ""
end
-- stored fixed-length name -> save.lua nickname (nil when never nicknamed)
local function importedNickname(cw, species, stored)
if stored == speciesName(cw, species) then return nil end
return stored
end
-- ------------------------------------------------------------------
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
@@ -573,7 +630,10 @@ function GenSave.decode(bytes, data, opts)
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
if mon then
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
-- a stored name equal to the species' standard name means NOT
-- nicknamed, which this project spells as nil (#257)
mon.nickname = importedNickname(cw, mon.species,
decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH))
save.party[#save.party + 1] = mon
end
end
@@ -586,7 +646,8 @@ function GenSave.decode(bytes, data, opts)
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
if mon then
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = importedNickname(cw, mon.species,
decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH))
table.insert(save.boxes[boxNum], mon)
end
end
@@ -610,6 +671,28 @@ function GenSave.decode(bytes, data, opts)
end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
-- from 0, storing b ("the map number of the town if it has been visited"),
-- so bit 0 = map 0 = PALLET_TOWN, LSB first; and
-- engine/overworld/toggleable_objects.asm
-- MarkTownVisitedAndLoadToggleableObjects sets bit [wCurMap] on entry for
-- any map below FIRST_ROUTE_MAP. Map indices 0-10 in
-- data/generated/maps.lua match PALLET_TOWN..SAFFRON_CITY one for one.
-- This project keeps the same set as save.visited[mapId]
-- (src/ui/FlyMenu.lua, src/ui/TownMap.lua, and the only writer,
-- src/world/OverworldController.lua's mark-on-map-entry), which an import
-- used to leave nil: FLY then listed only the town the player happened to
-- be standing in when the save was loaded (#263).
save.visited = {}
for townIdx = 0, NUM_CITY_MAPS - 1 do
if bitGet(bytes, O.townVisited, townIdx) then
local townId = cw.mapsByIndex[townIdx]
if townId then save.visited[townId] = true end
end
end
-- map + position
local mapIdx = u8(bytes, O.curMap)
local mapId = cw.mapsByIndex[mapIdx]
@@ -656,6 +739,19 @@ function GenSave.encode(save, data, template)
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
-- textSpeed 1/3/5 are pokered's exact FAST/MEDIUM/SLOW values
-- (SaveData.defaultOptions). Templateless exports only: with a
-- template the byte survives untouched (the round-trip invariant), and
-- decode() never reads it back anyway.
if not src then
local opts = save.options or {}
local ob = (tonumber(opts.textSpeed) or 3) % 8
if opts.battleStyle == "set" then ob = bit.bor(ob, 0x40) end
if opts.animations == false then ob = bit.bor(ob, 0x80) end
setByte(buf, O.options, ob)
end
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
@@ -700,6 +796,18 @@ function GenSave.encode(save, data, template)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save
-- table with no `visited` key at all says nothing about the set, so leave
-- the template's bits exactly as they are rather than blanking every town.
if type(save.visited) == "table" then
for townIdx = 0, NUM_CITY_MAPS - 1 do
local townId = cw.mapsByIndex[townIdx]
bitSet(buf, O.townVisited, townIdx,
(townId and save.visited[townId]) and true or false)
end
end
-- party
local party = save.party or {}
local partyN = math.min(#party, PARTY_LENGTH)
@@ -710,8 +818,10 @@ function GenSave.encode(save, data, template)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
-- no nickname stores the species' DISPLAY name, not its ROM constant id
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
mon.nickname or speciesName(cw, mon.species))
end
-- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
@@ -734,7 +844,7 @@ function GenSave.encode(save, data, template)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
end
-- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
+1
View File
@@ -107,6 +107,7 @@ local function defaultsSave()
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
battleLayout = "og",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
+30 -6
View File
@@ -41,10 +41,17 @@ local function showMessages(game, msgs, onDone)
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
end
-- run the use-flow for an item on a chosen target
local function useOn(game, battle, id, target, list, moveIndex)
-- run the use-flow for an item on a chosen target. `picker` is the party
-- menu when it was opened with keepOpen (HP medicine only): it is still on
-- the stack, so every exit that prints has to close it afterwards. For
-- every other item the picker popped itself first and closePicker's identity
-- check makes it a no-op (#252).
local function useOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld)
local function closePicker()
if picker then picker:close() end
end
-- field POKé FLUTE: play the tune, then the no-effect text
if result == "flute_field" then
@@ -288,16 +295,29 @@ local function useOn(game, battle, id, target, list, moveIndex)
end
end
list.index = math.min(list.index, math.max(1, #list.items))
-- HP medicine: fill the bar in the still-open picker first, then print
-- and close, the order item_effects.asm .doneHealing runs in
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
-- picker is nil for every other item and for in-battle use, which keeps
-- the pop-then-print path below. #252
if picker and extra and extra.healedFrom and target then
picker:animateTo(target, extra.healedFrom, function()
showMessages(game, payload, closePicker)
end)
return
end
if battle then
list:close()
showMessages(game, payload, function() battle:itemUsed({}) end)
else
showMessages(game, payload)
showMessages(game, payload, closePicker)
end
return
end
showMessages(game, payload) -- failed
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
-- refusal closes the picker the same way (#252)
showMessages(game, payload, closePicker) -- failed
end
local function pickTargetAndUse(game, battle, id, list)
@@ -308,9 +328,13 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id]
local opts = {
pickOnly = true,
onSwitch = function(mon)
-- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone.
keepOpen = (not battle) and ItemEffects.healsHP(id),
onSwitch = function(mon, picker)
if not wantsMove then
useOn(game, battle, id, mon, list)
useOn(game, battle, id, mon, list, nil, picker)
return
end
local rows = {}
+9
View File
@@ -7,6 +7,7 @@ local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
@@ -73,6 +74,14 @@ local function withdraw(game)
list.footer = "The party is full!"
return
end
-- add_mon.asm _MoveMon's tail ("returning mon to party, compute
-- level and stats"): a box mon carries no stat block, because
-- box_struct stops before MON_LEVEL/MON_STATS, so the party copy
-- runs CalcStats. Without it a mon decoded out of an imported .sav
-- reaches the party menu with mon.stats nil and the HP bar draw
-- nil-indexes it (#304, same family as #233). Already-shaped mons
-- (everything the engine itself put in a box) pass through.
Stats.ensure(game.data.pokemon[mon.species], mon)
table.remove(box, item.value)
table.insert(game.save.party, mon)
local name = monName(game, mon)
+5 -1
View File
@@ -37,7 +37,11 @@ function DexEntryMenu.new(game, speciesOrOpts)
self.def = game.data.pokemon[species]
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local ok, img = path and pcall(love.graphics.newImage, path)
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
require("src.core.Sound").playCry(game.data, species)
return self
+16
View File
@@ -20,6 +20,22 @@ EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
-- engine/movie/evolution.asm EvolveMon runs the back-and-forth flash with
-- the whole screen on PAL_BLACK -- `ld c, 1 ; set PAL_BLACK instead of mon
-- palette` right before .animLoop, then `ld c, 0` again at .done once the
-- loop is over -- so both forms read as silhouettes while they trade places
-- and only the settled form wears a mon palette (#279). PAL_BLACK is not
-- four blacks: data/sgb/sgb_palettes.asm gives it `RGB 31,29,31, 07,07,07,
-- 02,03,03, 03,02,02`, the usual paper white with the three darker shades
-- crushed, which is why a hardware capture shows a dark mon on an unchanged
-- background rather than an all-black screen. Going through P.pal keeps
-- every COLORS mode honest for free: OG RED short-circuits every name to the
-- one global boot-ROM palette (a Game Boy Color ignores the SGB packets, so
-- it never blacks out) and the mono modes replace it in effectiveColors.
if not self.done then
local black = P.pal(game.data, "BLACK")
if black then return { P.whole(black) } end
end
-- a cancelled evolution keeps the old species (never applied), so only
-- colorize with the new form once it has actually evolved
local species = (self.done and not self.canceled) and self.newSpecies
+38 -50
View File
@@ -9,6 +9,7 @@
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local TextBox = require("src.render.TextBox")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
@@ -64,17 +65,6 @@ local function dexRatingKey(owned)
return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
end
-- \n/\v/\f-marked extracted text, one Font.draw line at a time (same
-- technique as DexEntryMenu.lua's dex-description block)
local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end
Font.draw(line, x, y)
y = y + 8
end
return y
end
function HallOfFame.new(game, onDone)
local self = setmetatable({}, HallOfFame)
self.game = game
@@ -182,26 +172,50 @@ function HallOfFame:update(dt)
self.phase = "player_stats"
self.timer = DEX_HOLD
elseif self.phase == "player_stats" then
-- name / play time / money boxes are up; then DexSeenOwnedText
-- name / play time / money boxes are up; then the dex texts
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_dex"
self.timer = DEX_HOLD
self:showDexTexts()
end
elseif self.phase == "player_dex" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_rating"
self.timer = DEX_HOLD
end
elseif self.phase == "player_rating" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
-- player_dex / player_rating are driven by the TextBox chain that
-- showDexTexts pushes; nothing is timed here
end
-- HoFDisplayPlayerStats' three HoFPrintTextAndDelay calls: seen/owned,
-- "POKéDEX Rating:", then the tier text DisplayDexRating copied into
-- wDexRatingText -- each through PrintText (the standard two-row box, so
-- the tier text's \v rows scroll inside it) followed by 120 DelayFrames
-- with no button wait. Hand-drawing these flattened the \v scrolls and
-- painted the third row over the box's bottom border (#314).
function HallOfFame:showDexTexts()
local game = self.game
local text = game.data.text or {}
local seen, owned = self:dexSeenOwned()
local seenOwned = (text._DexSeenOwnedText
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"))
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
local header = (text._DexRatingText or Strings("POKéDEX Rating{COLON}"))
:gsub("{COLON}", ":")
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
local function finish()
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
self.game.stack:pop()
game.stack:pop()
if self.onDone then self.onDone() end
end
local function showRating()
game.stack:push(TextBox.new(game, rating, finish,
{ auto = { delay = DEX_HOLD } }))
end
local function showHeader()
self.phase = "player_rating"
game.stack:push(TextBox.new(game, header, showRating,
{ auto = { delay = DEX_HOLD } }))
end
game.stack:push(TextBox.new(game, seenOwned, showHeader,
{ auto = { delay = DEX_HOLD } }))
end
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
@@ -263,28 +277,6 @@ function HallOfFame:drawPlayerStats()
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end
function HallOfFame:drawDexBox(kind)
local save = self.game.save
local text = self.game.data.text or {}
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if kind == "seen" then
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}")
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":")
Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -309,14 +301,10 @@ function HallOfFame:draw()
elseif self.phase == "player_stats" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
elseif self.phase == "player_dex" then
elseif self.phase == "player_dex" or self.phase == "player_rating" then
-- the TextBox chain draws the dex texts over the stat boxes
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("seen")
elseif self.phase == "player_rating" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("rating")
end
love.graphics.setColor(1, 1, 1, 1)
+1 -1
View File
@@ -75,7 +75,7 @@ function ListMenu.new(game, title, items, opts)
self.dialogue = opts.dialogue
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
-- and PrintText footers ("How many?", stored/withdrew) use the standard
-- bottom text box same row budget as the mart, without the money box.
-- bottom text box -- same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
+6 -6
View File
@@ -40,12 +40,12 @@ function OakSpeech:sgbPalettes(game)
end
local FALLBACKS = {
_OakSpeechText1 = "Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!",
_OakSpeechText2A = "This world is\ninhabited by\vcreatures called\vPOKéMON!",
_OakSpeechText2B = "\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession.",
_OakSpeechText3 = "{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!",
_IntroducePlayerText = "First, what is\nyour name?",
_IntroduceRivalText = "This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?",
_OakSpeechText1 = Strings.source("Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"),
_OakSpeechText2A = Strings.source("This world is\ninhabited by\vcreatures called\vPOKéMON!"),
_OakSpeechText2B = Strings.source("\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."),
_OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"),
_IntroducePlayerText = Strings.source("First, what is\nyour name?"),
_IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"),
}
local function textOr(game, key)
+11
View File
@@ -142,6 +142,17 @@ local function buildRows(game)
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
return true
end },
-- OG is the classic 160x144 battle screen; WIDE is the 304x144
-- widescreen composition (src/battle/WideBattle.lua)
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
value = function(g)
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
end,
step = function(g)
local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
return true
end },
{ id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end,
step = function(g, dir)
+203 -18
View File
@@ -23,9 +23,51 @@ local PartyMenu = {}
PartyMenu.__index = PartyMenu
PartyMenu.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
-- SGB (SetPal_PartyMenu, engine/gfx/palettes.asm:90): the party screen is
-- NOT a one-palette screen. data/sgb/sgb_packets.asm BlkPacket_PartyMenu
-- splits it into MEWMON over the mon-icon column with GREENBAR everywhere
-- else, plus one block per HP bar row whose palette
-- UpdatePartyMenuBlkPacket (engine/gfx/palettes.asm:299-325) sets from that
-- mon's GetHealthBarColor -- pal 1 GREENBAR / 2 YELLOWBAR / 3 REDBAR
-- (PalPacket_PartyMenu, sgb_packets.asm:219). Handing the whole screen
-- MEWMON instead painted every bar with MEWMON's shades, which is why a
-- full bar came out black and a low one purple (#274, absorbing #272).
--
-- Two rects differ from the packet's, both because this port draws pixels
-- where the hardware drew OAM over BG:
-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the
-- message box's top edge, which on hardware was BG under an OBJ-free
-- part of the block; here it would take MEWMON instead of the base.
-- * the bar blocks sit one tile right of the packet's 05-11 because this
-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at
-- 4; the span is the same "left cap + six fill tiles".
function PartyMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
local P = require("src.render.PaletteFX")
local base = P.pal(game.data, "GREENBAR")
if not base then return nil end
local zones = { P.whole(base) }
local mew = P.pal(game.data, "MEWMON")
if mew then zones[#zones + 1] = P.zone(mew, 1, 0, 2, 11) end
-- the TM/HM list prints ABLE / NOT ABLE where the bar would be, so those
-- rows have no bar to color (party_menu.asm .teachMoveMenu; #210)
if not self.tmhm then
local party = self.party or (game.save and game.save.party) or {}
for i, mon in ipairs(party) do
-- While a medicine's bar fill runs the block palette is STALE, not
-- recomputed: SetPartyMenuHPBarColor (party_menu.asm:80/295) is only
-- reached from the party-menu redraw loop, never from hp_bar.asm, so
-- UpdateHPBar2 lengthens the bar under the PRE-heal color and
-- RedrawPartyMenu snaps it green when the message prints. Hold the
-- starting HP here for exactly that window (#252).
local hp = mon.hp
if self.heal and self.heal.mon == mon then hp = self.heal.from end
local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp))
if bar then
zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1)
end
end
end
return zones
end
local function sameItems(_, items) return items end
@@ -50,7 +92,9 @@ local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
-- MON/FAIRY/BIRD rest on the walk frame and animate to standing
-- (tile 0); WATER (Seel) is the reverse.
-- (tile 0); WATER (Seel) is the reverse. Only the frame's LEFT half
-- ever reaches the screen -- see PartyMenu.mirrorsIcon (#276) -- which
-- is why a walk frame does not look like a walk frame here.
PartyMenu.iconFrames = {
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1
@@ -71,7 +115,45 @@ function PartyMenu.frameFor(name, alt, ih)
return alt and ((ih or 0) >= 64 and 3 or 1) or 0
end
-- HELIX is the one icon WriteMonPartySpriteOAM sends down the asymmetric
-- path (engine/gfx/mon_icons.asm:246 `cp ICON_HELIX << 2 / jr z, .helix`);
-- every other built-in icon is drawn as a mirrored left half (see
-- drawIcon). A mod that supplies its own image instead of a built-in icon
-- name has no vanilla counterpart, so its art draws whole. #276
function PartyMenu.mirrorsIcon(name)
return name ~= nil and name ~= "HELIX"
end
local iconImages = {}
-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM
-- writes OAM blocks), so they render through OBP0, and GBPalNormal
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`)
-- holds OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1,
-- color 3 as shade 3. An object never displays shade 2. This canvas has
-- no OBJ layer, so bake that map into the icon art once per path (the same
-- CPU-remap trick as SpriteRenderer.getObpImage, and the same "#obp" cache
-- key convention) and let the screen's SGB zone color the result. Without
-- it every color-2 pixel took the zone palette's shade-2 color -- the
-- ADVANCED pack's MEWMON purple {115,33,165}, i.e. the "weirdly colored"
-- party sprites of #274.
local function obpIcon(path)
if not (love.image and love.image.newImageData) then
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
end
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, _, _, a)
-- the extracted art is the four DMG grays, keyed off the red channel
-- exactly the way PaletteFX's shade-remap shader keys them
local v = 0
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
end -- OBJ color 3 -> shade 3
return v, v, v, a
end)
return love.graphics.newImage(id)
end
local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
@@ -98,14 +180,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
end
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
if not path then return end
if iconImages[path] == nil then
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
-- mod's own image (an entry table rather than an icon name) is authored
-- art with no hardware counterpart, so it loads untouched -- the same
-- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one
-- cache under different keys, so a mod pointing a table entry at a
-- built-in path still gets its unbaked copy. #274
local key = name and (path .. "#obp") or path
if iconImages[key] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
-- picked up the same way battle sprites are
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
iconImages[path] = ok and img or false
local ok, img
if name then
ok, img = pcall(obpIcon, path)
else
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
end
local img = iconImages[path]
iconImages[key] = ok and img or false
end
local img = iconImages[key]
if not img then return end
local alt = false
if selected then
@@ -118,10 +212,28 @@ local function drawIcon(game, mon, x, y, selected, counter)
alt = false
end
local iw, ih = img:getDimensions()
if ih > 16 then
local frame = PartyMenu.frameFor(name, alt, ih)
-- a 16x16 sheet (BALL, HELIX) is its own only frame
local frame = ih > 16 and PartyMenu.frameFor(name, alt, ih) or 0
if PartyMenu.mirrorsIcon(name) then
-- WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534)
-- lays each icon out as 2x2 OAM blocks that use only the frame's LEFT
-- column of tiles (base+0, base+2): the inner loop writes the same
-- wOAMBaseTile twice with the attributes alternating 0 / OAM_XFLIP and
-- only then bumps the tile by 2, because "all the sprites other than
-- the helix one have a vertical line of symmetry". MON / FAIRY / BIRD
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
-- displays (#276, absorbing #238).
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
love.graphics.draw(img, half, x, y)
-- sx = -1 about the block's right edge, so the flipped copy lands on
-- x+8..x+16: the OAM_XFLIP half
love.graphics.draw(img, half, x + 16, y, 0, -1, 1)
elseif ih > 16 then
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
else
-- HELIX and any mod art that is a single frame: drawn whole, at
-- whatever size the file is (unchanged path)
love.graphics.draw(img, x, y)
end
end
@@ -134,6 +246,11 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
-- Medicine keeps the picker on screen: item_effects.asm .doneHealing
-- animates the party HP bar and then prints the message through
-- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and
-- calls :close() itself once the message is done (#252).
self.keepOpen = opts.keepOpen
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
@@ -148,9 +265,47 @@ function PartyMenu.new(game, opts)
return self
end
-- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's
-- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two
-- waiting frames each)" over a 48-pixel bar, so the shown HP walks
-- maxHP/96 per frame -- the same rate the battle HUD drains at
-- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is
-- when the caller prints its message. #252
function PartyMenu:animateTo(mon, fromHP, onDone)
if not (mon and mon.stats) then
if onDone then onDone() end
return
end
local from = math.max(0, fromHP or mon.hp)
-- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the
-- whole fill, because the SGB bar color does not move until the redraw.
self.heal = { mon = mon, from = from, shown = from, onDone = onDone }
end
-- Close a picker the caller kept open (see self.keepOpen). A TextBox pops
-- itself BEFORE it fires onDone (src/render/TextBox.lua), so this menu is
-- the top state by then; the identity check makes it a no-op for the pickers
-- that already popped themselves, and stops a double close eating the bag
-- underneath. #252
function PartyMenu:close()
if self.game.stack:top() == self then self.game.stack:pop() end
end
function PartyMenu:update(dt)
-- icon animation counter; 320 = a whole cycle at every HP speed
self.blink = ((self.blink or 0) + 1) % 320
-- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking
-- predef in item_effects.asm, so no button is read until it lands (#252).
local heal = self.heal
if heal then
heal.shown = math.min(heal.mon.hp,
heal.shown + math.max(1, heal.mon.stats.hp) / 96)
if heal.shown >= heal.mon.hp then
self.heal = nil
if heal.onDone then heal.onDone() end
end
return
end
local input = self.game.input
local party = self.party or self.game.save.party
@@ -304,10 +459,12 @@ function PartyMenu:update(dt)
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function()
-- like surf (#320): the blink belongs UNDER the texts, not as a
-- flashbang on the empty map after them; the stack only updates
-- the top state, so the flash holds until the texts close
self.game.stack:push(Transition.whiteFlash(self.game))
end))
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
@@ -367,8 +524,12 @@ function PartyMenu:update(dt)
end
self.swapFrom = nil
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
self.game.stack:pop()
self.onSwitch(mon)
-- keepOpen callers (HP medicine) need the menu still drawn while the
-- bar fills and the message prints, and close it themselves; everyone
-- else keeps the old pop-then-call order. Popping first is what made
-- a POTION snap the picker shut before the item had even run (#252).
if not self.keepOpen then self.game.stack:pop() end
self.onSwitch(mon, self)
else
self.submenu = true
self.subIndex = 1
@@ -389,7 +550,7 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
@@ -484,6 +645,16 @@ function PartyMenu:draw()
Font.draw(Strings("No POKéMON!"), 16, 64)
end
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
-- Each bar row carries its own GREENBAR / YELLOWBAR / REDBAR zone (see
-- sgbPalettes), so the fill must stay the raw DMG shade-2 gray and let
-- the zone color it -- but only when a zone pass will actually run.
-- Renderer's blit takes the shader path exactly when the zone list is
-- non-empty AND PaletteFX.shader() resolves, which is the same pair of
-- conditions tested here; with no shader the canvas blits unshaded and
-- drawHPBar's per-pixel tint is the only color the bar can get. #274
local barZoned = PaletteFX.shader() ~= nil
and PaletteFX.pal(self.game.data, "GREENBAR") ~= nil
for i, mon in ipairs(party) do
local def = self.game.data.pokemon[mon.species]
local y = PartyMenu.entryY(i)
@@ -523,11 +694,25 @@ function PartyMenu:draw()
elseif mon.status then
Font.draw(mon.status, 136, y)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
-- tinting the fill AND running it through the row's zone
-- double-applies -- a green fill has red channel 0, so the tint
-- zeroes the bar's red and the zone's red-keyed shade shader then
-- maps every pixel to color 3, i.e. black. That is the #229 hazard
-- HudTiles documents; #274 (with #272) is this screen's instance.
--
-- While a medicine's UpdateHPBar2 fill runs, this row draws the HP the
-- animation has reached rather than the final value; drawHPBar reads
-- only .hp and .stats, so a shim table is enough and the real mon is
-- never mutated for display (#252).
local shown = mon
if self.heal and self.heal.mon == mon then
shown = { hp = math.floor(self.heal.shown), stats = mon.stats }
end
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8)
end
-- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the
-- cursor sits on the entry's *second* tile row (the level/HP line),
+71 -18
View File
@@ -13,6 +13,7 @@ local Font = require("src.render.Font")
-- battle move-type box already do (#214).
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
@@ -30,6 +31,16 @@ function SummaryMenu:sgbPalettes(game)
end
function SummaryMenu.new(game, mon)
-- status_screen.asm:66-76: StatusScreen recalculates the stat block before
-- it draws anything when the mon came from a box or the daycare ("mon is
-- in a box or daycare" -> CalcStats), because box_struct carries none.
-- Bill's PC hands us that mon table directly (src/ui/BoxMenu.lua's STATS
-- submenu entry), and for a .sav imported through
-- src/save_convert/GenSave.lua it really does arrive with mon.stats nil,
-- which crashed the HP bar draw below (#233). Redundant once
-- SaveData.validate has run over a loaded save, but this is the site the
-- original recomputes at, and it also covers a mon handed in by a mod.
Stats.ensure(game.data.pokemon[mon.species], mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local Sprites = require("src.pokemon.Sprites")
local path = Sprites.path(game.data, mon.species, "front",
@@ -59,10 +70,31 @@ end
-- drawn from the same HUD tiles the original loads
local function drawLineBox(tx, ty, b, c)
local HudTiles = require("src.render.HudTiles")
for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end
HudTiles.tile(0x77, tx * 8, (ty + b) * 8)
for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end
HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
-- Under the status screen's overlay the vertical is $78 -- DrawLineBox
-- writes `ld [hl], $78` (status_screen.asm:222), and :90-93 is what puts
-- hud_2's single bar tile there. $73 is the <ID> glyph on this screen,
-- not a line, so the whole box has to come off statusTile (#280). The
-- drawn shapes are unchanged: hud_2 tile 0 is the same bar the battle
-- layout parks at $73.
for i = 0, b - 1 do HudTiles.statusTile(0x78, tx * 8, (ty + i) * 8) end
HudTiles.statusTile(0x77, tx * 8, (ty + b) * 8)
for i = 1, c do HudTiles.statusTile(0x76, (tx - i) * 8, (ty + b) * 8) end
HudTiles.statusTile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
end
-- home/pokemon.asm:335-345 PrintLevel: the "<LV>" (":L") tile at (tx,ty)
-- then the level LEFT_ALIGNed after it; at level 100 hl is decremented so
-- the third digit is written back OVER the ":L" tile. Both status pages
-- print a level this way, and src/ui/PartyMenu.lua models the same rule for
-- its rows. #280
local function printLevel(tx, ty, level)
local HudTiles = require("src.render.HudTiles")
local x = tx * 8
if level < 100 then
HudTiles.statusTile(0x6E, x, ty * 8)
x = x + 8
end
Font.draw(tostring(level), x, ty * 8)
end
function SummaryMenu:draw()
@@ -73,21 +105,33 @@ function SummaryMenu:draw()
local data = game.data
local def = data.pokemon[mon.species]
-- shared header: pic (1,0), name (9,1), <LV> (14,2), No. (1,7)
-- shared header: pic (1,0), name (9,1), № + dex number (1,7). The pic is
-- MIRRORED -- status_screen.asm:170 draws it through
-- LoadFlippedFrontSpriteByMonIndex (home/pokemon.asm sets wSpriteFlipped),
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
-- negative x scale anchored at the pic's right edge). #280
if self.sprite then
love.graphics.draw(self.sprite, 8,
math.max(0, 56 - self.sprite:getHeight()))
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
end
local HudTiles = require("src.render.HudTiles")
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 72, 8)
HudTiles.tile(0x6E, 112, 16) -- <LV>
Font.draw(tostring(mon.level), 120, 16)
Font.draw(("No.%03d"):format(def.dex or 0), 8, 56)
-- status_screen.asm:109-113 backs hl up from DrawLineBox's end to write
-- the single-tile '№' at (1,7) and '<DOT>' at (2,7); :143-146 then
-- PrintNumbers the dex number (LEADING_ZEROES, 3 digits) at (3,7).
-- Spelling "No." out of three letter tiles pushed every digit a column
-- right of the original. #280
HudTiles.statusTile(0x74, 8, 56) -- №
Font.drawCode(0xF2, 16, 56) -- <DOT> (charmap.asm:182)
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
if self.page == 1 then
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
-- bracket around the name/HP block
-- bracket around the name/HP block, and PrintLevel at (14,2). The
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
printLevel(14, 2, mon.level)
drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
@@ -114,7 +158,12 @@ function SummaryMenu:draw()
Font.draw(Strings("TYPE2/"), 80, 88)
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
end
Font.draw(Strings("IDNo/"), 80, 104)
-- TypesIDNoOTText's third row is "<ID>№/" (status_screen.asm:205-210):
-- two single-tile glyphs and a slash, three columns wide, not the five
-- letter tiles "IDNo/" this used to spell out. #280
HudTiles.statusTile(0x73, 80, 104) -- <ID>
HudTiles.statusTile(0x74, 88, 104) -- №
Font.draw("/", 96, 104)
-- the trainer ID is rolled at new game (SaveData.newGame) and
-- backfilled on load for old saves
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112)
@@ -124,17 +173,21 @@ function SummaryMenu:draw()
-- page 2: EXP + the moves with PP (StatusScreen2)
drawLineBox(19, 1, 6, 10)
Font.draw(Strings("EXP POINTS"), 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
-- PrintNumber at (12,4) with 7 columns: the exp is RIGHT-aligned into
-- cols 12-18 (status_screen.asm:400-403), not left-aligned from col 12.
-- #280
Font.draw(("%7d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols at
-- (7,6); the narrow '<to>' tile at (14,6); PrintLevel at (16,6)
-- (status_screen.asm:393-403). The old "%d to L%d" string at x=88
-- overflowed the DrawLineBox edge.
Font.draw(Strings("LEVEL UP"), 72, 40)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.tile(0x6E, 128, 48) -- <LV>
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
printLevel(16, 6, math.min(100, mon.level + 1))
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+1 -1
View File
@@ -18,7 +18,7 @@
local Check = {}
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
Check.REPO = "bryanthaboi/gen1recomp"
local CMD = "update_check_cmd"
local STATE = "update_check_state"
+1 -1
View File
@@ -50,7 +50,7 @@ local osName = (love.system and love.system.getOS and love.system.getOS()) or
local isWindows = osName == "Windows"
local saveDir = love.filesystem.getSaveDirectory()
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest"
local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
-- the release picked by the last "check"; kept between commands so "download"
-- knows the payload url/size/name without re-fetching
+177 -58
View File
@@ -47,6 +47,24 @@ local HEAL_BALL_XY = {
-- swaps the two middle shades of the monitor/ball art in place
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations
-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY
-- above (screen = tile*8 + pixel - 8/16), measured against the player
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
-- garbage strip (#321).
local ROD_OAM = {
down = { dx = 4, dy = 15, tile = 0 }, -- dbsprite 9, 11, 4, 3, $fd
up = { dx = 4, dy = -8, tile = 0 }, -- dbsprite 9, 8, 4, 4, $fd
left = { dx = -8, dy = 4, tile = 1 }, -- dbsprite 8, 10, 0, 0, $fe
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
}
-- object_event spawn filter (toggleable_objects, items taken, beaten
-- static encounters), shared by the current map's real NPCs and the
-- visual-only ghosts on connected neighbor maps
@@ -555,6 +573,19 @@ function OverworldState:sgbWorldZones()
return zones
end
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
-- can actually reach this frame's world pass. It cannot in RED++: that mode
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
-- all and there is no palette left to permute. Only then does fxDark
-- composite the darkness by hand (#322).
function OverworldState:darkNeedsOverlay()
if not self.dark then return false end
local renderer = self.map and self.map.renderer
return PaletteFX.usesGbcPack() and renderer ~= nil
and renderer.gbcAtlas ~= nil
end
function OverworldState:npcByIndex(index)
for _, n in ipairs(self.npcs) do
if n.def.index == index then return n end
@@ -938,6 +969,24 @@ end
function OverworldState:handleInput()
local input = Game.input
-- the wall-bonk SFX cooldown ticks with any held direction, step or not
-- (it is a port invention, not part of JoypadOverworld, so the
-- wWalkCounter gate below must not freeze it mid-step)
if self:dirHeld() then
self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1)
end
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
-- walking animation" it jumps straight to .moveAhead): A, START and
-- direction initiation are only ever looked at while the player stands
-- on a tile, and a button pressed mid-step is simply never seen.
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
-- right there and froze Red between tiles, mid-animation (#286). Held
-- directions need no buffering -- isDown below picks them up on the
-- landing frame.
if self.player.moving then return end
if input:wasPressed("a") then
self:interact()
return
@@ -974,15 +1023,20 @@ function OverworldState:handleInput()
self.bumpCooldown = 16
end
end
self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1)
return result
end
end
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN)
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
-- brakes exactly like a held direction: what the Route 17 sign
-- promises ("Press the A or B Button to stay in place") and what the
-- edge-only wasPressed("a") above can never deliver, since a press
-- stalls the roll for one frame only (issue #255).
local fm = Game.data.field.forcedMovement
if fm and Game.save.onBike and not self.player.moving then
local braking = input:isDown("a") or input:isDown("b")
if fm and Game.save.onBike and not braking and not self.player.moving then
for _, m in ipairs(fm.slopeMaps or {}) do
if m == self.map.id then
self.player.facing = "down"
@@ -1070,8 +1124,28 @@ function OverworldState:checkLedgeHop(dir)
and ledge.facing == dir and ledge.input == dir
and ledge.standingTile == standing and ledge.ledgeTile == front then
local lx, ly = Collision.target(fx, fy, dir)
if self.map:inBounds(lx, ly)
and not Collision.occupied(self.entities, lx, ly, p)
if not self.map:inBounds(lx, ly) then
-- The landing is on the CONNECTED map. pokered never checks where a
-- hop lands (engine/overworld/ledges.asm HandleLedges just simulates
-- two presses in the hop direction) and the connection strip is
-- loaded, so ROUTE_4's bottom-row ledge at (12,17)/(13,17) really
-- does drop onto ROUTE_3 row 0 (south connection, offset -25 ->
-- destX = curX + 50; ROUTE_3 (62,0)/(63,0) are walkable $39/$23):
-- the one-way shortcut off the Mt Moon plaza that the in-bounds gate
-- was silently refusing, which is issue #223. Validate the seam
-- cell the way crossConnection does, hop the first cell onto the
-- ledge tile, and hand the second to checkEdgeExit, which owns the
-- crossing.
local dest, ts, cx, cy = self:connectionLanding(dir)
if not (dest and Map.defPassable(dest, ts, cx, cy, p.surfing)) then
return false
end
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
return true
end
if not Collision.occupied(self.entities, lx, ly, p)
and self.map:isWalkableCell(lx, ly) then
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
@@ -1322,12 +1396,18 @@ function OverworldState:goFishing(rod)
-- FishingInit dot animation); the rod pose draws in the meantime
self.fishing = { facing = self.player.facing }
Game.stack:push(TextBox.new(Game, ". . .", function()
self.fishing = nil
-- FishingAnim (engine/overworld/player_animations.asm) holds
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
-- PrintText and only clears it once the verdict box is done, so the rod
-- must NOT vanish with the dots box (#321).
if not enc then
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!")))
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
self.fishing = nil
end))
return
end
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
self.fishing = nil
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
@@ -2004,24 +2084,20 @@ function OverworldState:trySurf(fx, fy)
if not mon then return end
local name = mon.nickname or Game.data.pokemon[mon.species].name
local p = self.player
p.surfing = true
require("src.core.Music").setSurfing(Game.data, true)
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
:gsub("{RAM:wNameBuffer}", name)
-- GBPalWhiteOutWithDelay3 runs while the got-on text is still up
-- (start_sub_menus.asm .surf), so the blink reads as a text flash
-- instead of a flashbang on the empty map (#320). The flash sits
-- under the textbox on the stack: only the top state updates, so it
-- holds its frames until the text closes. surfing (and the sprite
-- swap) only applies when the step happens -- no paddling on land.
Game.stack:push(require("src.render.Transition").whiteFlash(Game))
Game.stack:push(TextBox.new(Game, text, function()
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
-- press steps onto the water (or across a connection strip, like
-- Cinnabar's east coast onto Route 20)
local Transition = require("src.render.Transition")
if Transition.whiteFlash then
Game.stack:push(Transition.whiteFlash(Game, nil, function()
p.surfing = true
require("src.core.Music").setSurfing(Game.data, true)
self:stepForwardOrCrossEdge(p.facing)
end))
else
self:stepForwardOrCrossEdge(p.facing)
end
end))
end
function OverworldState:tryCut(fx, fy)
@@ -2532,22 +2608,27 @@ function OverworldState:engageTrainer(npc, onDone)
local BattleState = require("src.battle.BattleState")
Game.stack:push(TextBox.new(Game, battleText, function()
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
-- PrintEndBattleText (home/trainers.asm:341) is called from
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
-- back, and before MoneyForWinningText -- not in the overworld after the
-- battle screen has torn down. Handing the line to the battle also
-- stops a post-battle evolution being sandwiched between two overworld
-- cuts (#282). Substituted here because BattleState:say takes finished
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
battle.onFinish = function(result)
if result == "win" then
Game.save.defeatedTrainers[npc.id] = true
if header and header.event then
Game.save.flags[header.event] = true
end
-- checkVictoryRewards pushes the badge/prize box and starts the map's
-- onVictory script UNDER whatever runs next, so the player still sees
-- EndBattle (now inside the battle), then the reward, then AfterBattle
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
local after = function()
self:afterBattle(result, battle)
if onDone then onDone() end
end
if wonText then
Game.stack:push(TextBox.new(Game, wonText, after))
else
after()
end
else
self:afterBattle(result, battle)
if onDone then onDone() end
@@ -2560,7 +2641,7 @@ end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
-- `hide` is { { mapId, objName }, ... } HideObject on those toggles
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories")
@@ -2928,14 +3009,25 @@ function OverworldState:onStepComplete()
self.warpEntryCell = nil
entry = nil
end
if self.justWarped then
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
-- test. justWarped only records that an arrival happened (it still
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
-- completed step with it swallowed the warp under the player's feet,
-- which is why a second ladder one cell from the first did nothing
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
-- pokered has no such counter: every completed step runs
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
-- the flag the bonk path needs, is only set by
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
-- exactly what warpEntryCell reproduces.
self.justWarped = false
elseif entry then
if entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
-- is set (Seafoam B3F currents home/overworld.asm).
-- is set (Seafoam B3F currents -- home/overworld.asm).
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and (self:dirHeld() or self.forcedWarp) then
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
@@ -3021,7 +3113,7 @@ function OverworldState:runSpinnerMoves(moves, i)
-- Scripted steps skip onStepComplete while they run; once the RLE
-- finishes, re-enter the normal landing pipeline so chained spinners,
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
-- see the tile we stopped on same as pokered after simulated joypad.
-- see the tile we stopped on -- same as pokered after simulated joypad.
self:onStepComplete()
return
end
@@ -3758,6 +3850,13 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
end
function OverworldState:drawWorld()
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
-- characters standing on it and any dialog over them darken together (#322);
-- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which
-- draws with no map beneath it -- stays lit exactly like
-- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original.
PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil)
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
@@ -3974,19 +4073,20 @@ function OverworldState:drawWorld()
end
end
-- Rock Tunnel darkness: a small window of light around the player
-- until FLASH is used (the original darkens the palette instead);
-- fills the whole world view, so surveying doesn't peek past it
-- Rock Tunnel darkness. The original never cuts a window of light around
-- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset
-- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts
-- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped
-- mode, armed at the top of drawWorld. RED++ is the one mode with no
-- palette left to shift -- TileRenderer bakes true colour into the tileset
-- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader
-- runs over the world at all -- so there the darkness is composited instead:
-- a flat veil over the whole world view (surveying still cannot peek past
-- it) at the 85/255 brightness FadePal2 leaves DMG white on.
local function fxDark()
if not self.dark then return end
local px = self.player.px - cam.x + 8
local py = self.player.py - cam.y + 8
local r = 28
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, vw, math.max(0, py - r))
love.graphics.rectangle("fill", 0, py + r, vw, vh - (py + r))
love.graphics.rectangle("fill", 0, py - r, math.max(0, px - r), r * 2)
love.graphics.rectangle("fill", px + r, py - r, vw - (px + r), r * 2)
if not self:darkNeedsOverlay() then return end
love.graphics.setColor(0, 0, 0, 1 - 85 / 255)
love.graphics.rectangle("fill", 0, 0, vw, vh)
love.graphics.setColor(1, 1, 1, 1)
end
@@ -4020,11 +4120,25 @@ function OverworldState:drawWorld()
end
if self.rodImg then
local p = self.player
local vec = DIRVEC[self.fishing.facing] or DIRVEC.down
local rx = p.px - cam.x + 4 + vec[1] * 12
local ry = p.py - cam.y + 4 + vec[2] * 12
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
if not self.rodQuads then
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
local iw, ih = self.rodImg:getDimensions()
self.rodQuads = {}
for i = 0, math.floor(ih / 8) - 1 do
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
end
end
local quad = self.rodQuads[oam.tile]
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
local rx = p.px - cam.x + oam.dx
local ry = p.py - cam.y - 4 + oam.dy
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.rodImg, rx, ry)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
elseif quad then
love.graphics.draw(self.rodImg, quad, rx, ry)
end
end
end
end
@@ -4104,10 +4218,12 @@ function OverworldState:drawWorld()
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object: draw it flat over the finished scene like the tilt path.
-- It fills the view in world-pixel units, so it only needs the scale.
if self.dark then
-- Rock Tunnel darkness is a screen-space veil, not a ground object:
-- draw it flat over the finished scene like the tilt path. It fills
-- the view in world-pixel units, so it only needs the scale, and it is
-- only needed at all in the mode whose palette cannot carry the
-- darkening itself (see fxDark).
if self:darkNeedsOverlay() then
love.graphics.push()
love.graphics.scale(scale, scale)
fxDark()
@@ -4138,9 +4254,12 @@ function OverworldState:drawWorld()
-- the pipeline owns the whole frame; nothing else draws into the world
elseif not tilt then
-- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- OBP-baked sprites replay after the zone pass in OG RED mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
-- current map's palette (see PaletteFX.markSpriteRedraw)
-- current map's palette (see PaletteFX.markSpriteRedraw). SGB no longer
-- takes that path -- its characters are colorized by the zone just like
-- the ground under them (#301) -- so there the first overdraw is already
-- the final one.
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
for _, g in ipairs(self.ghosts) do
@@ -4261,10 +4380,10 @@ function OverworldState:drawWorld()
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object -- draw it flat into the upright canvas so it darkens the
-- final composited scene uniformly (the subtle tilt keeps the
-- projected player near the flat light centre).
-- Rock Tunnel darkness is a screen-space veil, not a ground object --
-- draw it flat into the upright canvas so it darkens the final
-- composited scene uniformly. It no-ops unless this mode needs the
-- composited fallback (see fxDark).
fxDark()
Game.renderer:endUprightPass()
+1 -1
View File
@@ -152,7 +152,7 @@ function Player:update()
self.stepFlip = not self.stepFlip
-- keep animClock's pose on this frame (issue #82): bike steps land
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
-- stand whenever moving cleared a stand flash every tile on the
-- stand whenever moving cleared -- a stand flash every tile on the
-- bike, and sometimes after dismount when the clock is desynced
self.stepLanded = true
return true
+11 -45
View File
@@ -1,27 +1,8 @@
-- Driver: the bag's USE/TOSS submenu box (#284). A manual eye check, not a
-- pass/fail run.
--
-- pokered evidence. data/text_boxes.asm pins the whole thing as one entry:
--
-- text_box_text USE_TOSS_MENU_TEMPLATE, 13, 10, 19, 14, UseTossText, 15, 11
-- ; text box ID, upper-left X, upper-left Y, lower-right X, lower-right Y,
-- ; text pointer, text X, text Y
--
-- so the box covers tiles (13,10)-(19,14) and "USE" starts at tile (15,11).
-- engine/menus/start_sub_menus.asm then sets wTopMenuItemY/X to 11/14 for
-- the cursor, and UseTossText is "USE" + next "TOSS", i.e. two rows.
--
-- The bug: the port opened the submenu with a (12,10) 8x6 box, one column
-- too wide on the left and one row too tall at the bottom. The labels
-- themselves were already on the right pixel rows, but the extra bottom row
-- left them stranded near the top edge, which is what reads as "too high".
-- The fix passes the real geometry (13,10,7,5); src/ui/Menu.lua derives the
-- label and cursor columns from the box, so it needed no change and its
-- eight other callers are untouched.
--
-- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic
-- clock, so a sped-up run can capture a half-drawn frame.
--
-- Eye check on the bag's USE/TOSS submenu box (#284).
-- pokered data/text_boxes.asm: USE_TOSS_MENU_TEMPLATE covers tiles
-- (13,10)-(19,14) with "USE" at (15,11), and start_sub_menus.asm puts the
-- cursor at wTopMenuItemY/X 11/14. The port opened it one column wider and
-- one row taller, which stranded the labels near the top edge.
-- POKEPORT_DRIVER=tests/drivers/bag_usetoss_bug284_test.lua POKEPORT_IDENTITY=bug284 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -33,10 +14,8 @@ return function(game)
return ok
end
-- ---- preconditions the eye cannot separate from the bug ----------------
-- An empty bag, or an item whose branch skips the submenu, both end with
-- "no USE/TOSS box on screen", which looks identical to a broken box.
-- an empty bag, or an item whose branch skips the submenu, leaves no box on
-- screen at all, which looks the same as a broken one
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "bryan"
game.save.inventory = game.save.inventory or {}
@@ -59,27 +38,14 @@ return function(game)
U.wait(30)
U.shot(game, "bug284_bag_list.png")
-- A on the first item opens USE/TOSS
U.tap(game, "a")
U.wait(30)
U.shot(game, "bug284_usetoss.png")
U.log("........................................................")
U.log("LOOK NOW: the USE/TOSS box should be open over the bag list.")
U.log(" Screenshot: bug284_usetoss.png in the LOVE save dir.")
U.log(" RIGHT: a box hugging the bottom-right corner, its border running")
U.log(" tiles (13,10) to (19,14). USE and TOSS sit on alternating")
U.log(" rows with the cursor one column to their left, and there")
U.log(" is exactly one border row below TOSS -- no empty gap.")
U.log(" BUG #284 looks like: a roomier box whose labels crowd the top,")
U.log(" with a blank row of dead space under TOSS, and the whole")
U.log(" box starting one column further left than the original.")
U.log(" ALSO WRONG: labels pushed down but the box left oversized (that")
U.log(" moves the text off pokered's row 11), or the cursor column")
U.log(" no longer lining up one tile left of the labels.")
U.log("Compare against the original screenshot in issue #284.")
U.log("Input is yours from here: up/down moves between USE and TOSS.")
U.log("........................................................")
U.log("USE/TOSS is open over the bag list; shot in bug284_usetoss.png.")
U.log("It should run tiles (13,10)-(19,14) with the cursor one column left")
U.log("of the labels and a single border row under TOSS, no blank gap (#284).")
U.log("Up/down moves between USE and TOSS.")
while true do
coroutine.yield()
+230
View File
@@ -0,0 +1,230 @@
-- Driver: cycling COLORS mid-battle must not move a sprite (#316). pokered
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:13-15) puts
-- the back pic at `hlcoord 1, 5` so its bottom row sits on the text box, and a
-- palette swap must not lift it off.
-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug316_test.lua \
-- POKEPORT_IDENTITY=bug316 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local PaletteFX = require("src.render.PaletteFX")
-- Route 1 (5, 5) is open walkable ground; the battle is pushed straight in.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 5, facing = "down" }
-- ARTICUNO's back pic has left padding as well as bottom padding.
local PARTY = { { "BULBASAUR", 12 }, { "ARTICUNO", 40 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- preconditions the eye cannot check --------------------------------
-- No transparent padding on the back pics means no jump to see, so a broken
-- fix would read as a pass.
local function padOf(path)
if not (love.image and love.image.newImageData) then return nil end
local ok, id = pcall(love.image.newImageData, path)
if not ok then return nil end
local w, h = id:getDimensions()
local bottom = h - 1
while bottom >= 0 do
local opaque = false
for x = 0, w - 1 do
local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
end
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
return h - 1 - bottom, left, w, h
end
for _, path in ipairs({ "assets/generated/battle/redb.png",
"assets/generated/battle/back/bulbasaurb.png",
"assets/generated/battle/back/articunob.png" }) do
local pad, padL, w, h = padOf(path)
if pad == nil then
check(path .. " could be measured", false)
else
U.log((" %s is %dx%d, %d transparent bottom rows, %d left columns")
:format(path, w, h, pad, padL))
check(path .. " still carries bottom padding to lose", pad > 0)
end
end
check("BattleState.invalidate still exists (PaletteFX.setMode calls it)",
type(BattleState.invalidate) == "function")
U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", "))
-- Where drawPicsLayer puts every pic this frame; love.graphics.draw is
-- shadowed so nothing reaches the screen. Both call shapes matter:
-- draw(img, x, y, r, sx, sy) and the faint-clip draw(img, quad, x, y, ...).
local function picGeometry(battle)
local out = {}
local realDraw = love.graphics.draw
love.graphics.draw = function(_, a, b, c, d, e)
if type(a) == "number" then
out[#out + 1] = { x = a, y = b, s = d }
else
out[#out + 1] = { x = b, y = c, s = e }
end
end
pcall(battle.drawPicsLayer, battle, 0, 0, 0)
love.graphics.draw = realDraw
return out
end
local function describe(geo)
local parts = {}
for _, g in ipairs(geo) do
parts[#parts + 1] = ("(%s, %s x%s)"):format(tostring(g.x), tostring(g.y),
tostring(g.s or 1))
end
return table.concat(parts, " ")
end
local function same(a, b)
if #a ~= #b then return false end
for i = 1, #a do
if a[i].x ~= b[i].x or a[i].y ~= b[i].y or a[i].s ~= b[i].s then
return false
end
end
return true
end
-- The back pic is the only thing drawn at 2x (BattleState.backPlacement's
-- third return); the enemy front pic is 1x.
local function backEntry(geo)
for _, g in ipairs(geo) do
if g.s == 2 then return g end
end
return nil
end
-- Ground truth off the art on disk, not off the cache: comparing against an
-- earlier frame only catches a pad lost DURING the run, and this one is also
-- lost by any pic loaded after an invalidate().
local bpad, bpadL, bw, bh = padOf("assets/generated/battle/back/bulbasaurb.png")
local expX, expY = nil, nil
if bpad then
expX, expY = BattleState.backPlacement(bw, bh, bpad, bpadL, 2)
U.log((" a %dx%d back pic with %d ground rows belongs at (%d, %d) at 2x")
:format(bw, bh, bpad, expX, expY))
end
local function grounded(label, geo)
if not expY then return end
local b = backEntry(geo)
check(label .. ": the back pic stands on the text box at y = "
.. tostring(expY) .. " (got " .. tostring(b and b.y) .. ")",
b ~= nil and b.y == expY and b.x == expX)
end
-- ---- get a back pic on screen ------------------------------------------
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil)
local battle = BattleState.newWild(game, "PIDGEY", 6)
battle.onFinish = function() end
if ow then ow:pushBattle(battle) end
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
-- Red's own back pic is up during the intro, and that is the pic the report
-- names, so check it first.
check("Red's back pic is on screen", battle.showPlayerBack == true)
local introBefore = picGeometry(battle)
U.log(" intro pics before:", describe(introBefore))
grounded("before any COLORS change", introBefore)
U.shot(game, DIR .. "/bug316_1_intro_before.png")
game:keypressed("2")
U.wait(4)
local introAfter = picGeometry(battle)
U.log(" intro pics after :", describe(introAfter))
U.log(" COLORS is now", PaletteFX.modeLabel(PaletteFX.mode))
check("cycling COLORS did not move Red's back pic (#316)",
same(introBefore, introAfter))
grounded("after one COLORS change", introAfter)
U.shot(game, DIR .. "/bug316_2_intro_after.png")
-- now the mon's own back pic, at the action menu, where a player actually
-- sits when they press 2
for _ = 1, 80 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(4)
end
check("the battle reached its action menu", battle.phase == "menu")
check("the mon's back pic replaced Red's", battle.showPlayerBack == false)
local base = picGeometry(battle)
U.log(" menu pics baseline:", describe(base))
grounded("the mon's own back pic at the menu", base)
U.shot(game, DIR .. "/bug316_3_menu_" .. tostring(PaletteFX.mode) .. ".png")
-- walk the whole COLORS ladder; every mode has to leave the geometry alone
local moved, ungrounded = {}, {}
for i = 1, #PaletteFX.MODES do
game:keypressed("2")
U.wait(4)
local geo = picGeometry(battle)
local label = PaletteFX.modeLabel(PaletteFX.mode)
if not same(base, geo) then
moved[#moved + 1] = label
U.log(" MOVED under " .. label .. ":", describe(geo))
end
local b = backEntry(geo)
if expY and not (b and b.y == expY and b.x == expX) then
ungrounded[#ungrounded + 1] = label
end
if i == 1 then
U.shot(game, DIR .. "/bug316_4_menu_" .. tostring(PaletteFX.mode) .. ".png")
end
end
check("no COLORS mode moved a pic (walked the whole ladder of "
.. #PaletteFX.MODES .. ")", #moved == 0)
check("the back pic is still grounded in every COLORS mode",
#ungrounded == 0)
if #ungrounded > 0 then
U.log(" modes where it floated:", table.concat(ungrounded, ", "))
end
if #moved > 0 then
U.log(" modes that moved something:", table.concat(moved, ", "))
end
U.log(" back on", PaletteFX.modeLabel(PaletteFX.mode))
-- ---- hand off ----------------------------------------------------------
U.log("At the FIGHT/PKMN/ITEM/RUN menu: press 2 to cycle COLORS and watch the")
U.log("back sprite's feet. They stay planted on the top edge of the text box;")
U.log("#316 hopped the sprite 8 pixels up on the first press. Switching to")
U.log("ARTICUNO in slot 2 shows the sideways half of it.")
U.log("Sprites already on screen keep their baked palette, which is deliberate.")
U.log("Screenshots: " .. DIR .. "/bug316_*.png")
while true do
coroutine.yield()
end
end
+255
View File
@@ -0,0 +1,255 @@
-- Driver: the battle intro's pokeball rows, HUD chrome, prompt arrow and
-- trainer-pic slides (#317). engine/battle/common_text.asm:22-27,
-- draw_hud_pokeball_gfx.asm:1-45 (player row (88,80) +8, foe row (64,16) -8),
-- SlideTrainerPicOffScreen at core.asm:1235-1253. Ordering is asserted in
-- parity_battle_intro_chrome.lua. No POKEPORT_SPEED: audio has its own clock.
-- POKEPORT_DRIVER=tests/drivers/battle_intro_bug317_test.lua POKEPORT_IDENTITY=bug317 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local HudTiles = require("src.render.HudTiles")
local Font = require("src.render.Font")
-- pokered data/maps/objects/Route3.asm: object_event 10, 6, ... STAY, RIGHT,
-- OPP_BUG_CATCHER, 4. Range RIGHT means the sight line runs east along row
-- 6, so (13,6) is one cell outside it and walking west from there trips it.
local MAP = "ROUTE_3"
local TRAINER = "ROUTE3_YOUNGSTER1"
local STAND = { x = 13, y = 6, facing = "left" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- preconditions the eye cannot check --------------------------------
-- A missing sheet draws nothing, which looks identical to the bug.
-- drawBallRow bails silently if this image will not load
local balls = io.open("assets/generated/battle/balls.png", "rb")
check("assets/generated/battle/balls.png exists", balls ~= nil)
if balls then balls:close() end
-- HudTiles.tile is a silent no-op for a code the sheets do not carry, so
-- count the draws it issues rather than trusting the call
local function tileDraws(code)
local real = love.graphics.draw
local n = 0
love.graphics.draw = function() n = n + 1 end
pcall(HudTiles.tile, code, 0, 0)
love.graphics.draw = real
return n
end
local CHROME = {
[0x73] = "corner tick", [0x74] = "bar left cap", [0x76] = "underline run",
[0x77] = "player underline left", [0x78] = "enemy underline right",
[0x6F] = "player underline end",
}
for code, what in pairs(CHROME) do
check(("HUD chrome tile $%02X (%s) resolves"):format(code, what),
tileDraws(code) > 0)
end
local mapDef = game.data.maps[MAP]
local trainerDef
for _, o in ipairs(mapDef and mapDef.objects or {}) do
if o.name == TRAINER then trainerDef = o end
end
check(TRAINER .. " is still on " .. MAP, trainerDef ~= nil)
if trainerDef then
check("it is still a trainer object",
trainerDef.trainerClass ~= nil and trainerDef.trainerParty ~= nil)
U.log(" ", TRAINER, trainerDef.trainerClass, "party", trainerDef.trainerParty,
"at", trainerDef.x, trainerDef.y, "range", tostring(trainerDef.range))
end
-- Record what drawHUDs puts on screen for one frame without drawing it:
-- drawBallRow is shadowed on the instance, Font.draw on the module
-- BattleState routes its HUD strings through.
local function snapshot(battle)
local rows, strings = {}, {}
local realRow, realFont = battle.drawBallRow, Font.draw
local realDraw = love.graphics.draw
battle.drawBallRow = function(_, party, x, y, dx)
rows[#rows + 1] = { count = #party, x = x, y = y, step = dx }
end
Font.draw = function(text) strings[#strings + 1] = tostring(text) end
love.graphics.draw = function() end
pcall(battle.drawHUDs, battle, 0)
battle.drawBallRow, Font.draw = realRow, realFont
love.graphics.draw = realDraw
return rows, strings
end
local function drewString(strings, want)
for _, s in ipairs(strings) do
if s:find(want, 1, true) then return true end
end
return false
end
-- ---- part 1: a WILD intro, driven and photographed ---------------------
game.save.party = {
Pokemon.new(game.data, "BULBASAUR", 12),
Pokemon.new(game.data, "PIDGEY", 9),
}
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil)
local wild = BattleState.newWild(game, "RATTATA", 3)
wild.onFinish = function() end
if ow then ow:pushBattle(wild) end
for _ = 1, 400 do
if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the wild battle reached the screen",
game.stack:top() == wild and (wild.introSlide or 0) == 0)
check("the DrawAllPokeballs window is open", wild.introBalls == true)
local rows, strings = snapshot(wild)
check("a WILD intro draws exactly one ball row", #rows == 1)
if rows[1] then
check("it is the player's row at (88, 80) stepping +8",
rows[1].x == 88 and rows[1].y == 80 and rows[1].step == 8)
check("it carries all " .. #game.save.party .. " party slots",
rows[1].count == #game.save.party)
end
check("the enemy HUD is NOT up under the intro box",
not drewString(strings, wild.enemy.name))
if not U.shot(game, DIR .. "/bug317_1_wild_intro.png") then
U.log("FAIL could not capture the wild intro")
end
-- the page finishes typing, then PromptText's arrow blinks
local prompted = false
for _ = 1, 300 do
if wild.msgPrompt then prompted = true break end
U.wait(1)
end
check("the typed-out intro page raises the blinking prompt", prompted)
-- two shots half a blink apart; drawTextArea draws '▼' for frame % 60 < 30
for _ = 1, 60 do
if (wild.frame % 60) < 6 then break end
U.wait(1)
end
U.shot(game, DIR .. "/bug317_2_arrow_on.png")
for _ = 1, 60 do
if (wild.frame % 60) >= 34 then break end
U.wait(1)
end
U.shot(game, DIR .. "/bug317_3_arrow_off.png")
-- dismiss it: ClearSprites + both ClearScreenAreas, then the enemy HUD
U.tap(game, "a")
U.wait(4)
check("dismissing the box closes the window", wild.introBalls == nil)
local rows2, strings2 = snapshot(wild)
check("no ball row survives the dismissal", #rows2 == 0)
check("the enemy HUD appears once the box is gone",
drewString(strings2, wild.enemy.name))
U.shot(game, DIR .. "/bug317_4_enemy_hud.png")
-- The back pic walking off the left edge before "Go! X!". Keep watching
-- past the mid-slide shot: U.shot spins frames of its own, so breaking on
-- it would under-report how far the pic travelled.
local lowest, shot = 0, false
for _ = 1, 400 do
local off = wild:picOffset("back")
if off < lowest then lowest = off end
if not shot and off <= -24 and off >= -52 then
shot = true
U.shot(game, DIR .. "/bug317_5_back_slide.png")
end
if wild.phase == "menu" or wild.showPlayerBack == false then break end
U.wait(1)
end
check("a mid-slide frame was captured", shot)
check("the back pic walked the full 9 tiles off the left edge (reached "
.. lowest .. "px of -72)", lowest <= -72)
-- ---- part 2: a LIVE trainer intro, handed over -------------------------
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
ow = game.overworld
local function liveBattle()
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == BattleState then return s end
end
return nil
end
local function trainerNpc()
for _, n in ipairs((ow and ow.npcs) or {}) do
if n.def and n.def.name == TRAINER then return n end
end
return nil
end
local npc = trainerNpc()
check("the trainer object loaded on the live map", npc ~= nil)
-- Walk WEST along row 6 into the sight line. If a map edit moved the
-- trainer, re-approach at whatever row it now sits on.
if npc and (npc.cellY ~= STAND.y) then
U.log("trainer moved to", npc.cellX, npc.cellY, "-- re-approaching")
U.teleport(game, MAP, math.min(npc.cellX + 3, (ow.map.widthCells or 60) - 1),
npc.cellY, "left")
U.wait(10)
end
local battle
for _ = 1, 12 do
U.hold(game, "left", 12)
U.wait(20)
battle = liveBattle()
if battle then break end
-- the pre-battle line ("I like shorts!") holds the overworld; clear it
U.tap(game, "a")
U.wait(20)
battle = liveBattle()
if battle then break end
end
if not battle then
-- rather than park the player facing nothing, push the same battle
U.log("FAIL the sight line did not trip; pushing the battle directly")
local cls = trainerDef and trainerDef.trainerClass or "OPP_BUG_CATCHER"
local pty = trainerDef and trainerDef.trainerParty or 4
battle = BattleState.newTrainer(game, cls, pty)
battle.onFinish = function() end
if ow then ow:pushBattle(battle) end
end
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("a trainer battle is on screen", liveBattle() ~= nil)
check("its intro window is open", battle.introBalls == true)
local rows3 = snapshot(battle)
check("a TRAINER intro draws BOTH ball rows", #rows3 == 2)
if rows3[1] and rows3[2] then
check("the foe's row is at (64, 16) stepping -8",
rows3[1].x == 64 and rows3[1].y == 16 and rows3[1].step == -8)
check("the player's row is at (88, 80) stepping +8",
rows3[2].x == 88 and rows3[2].y == 80 and rows3[2].step == 8)
end
U.shot(game, DIR .. "/bug317_6_trainer_intro.png")
-- ---- hand off ----------------------------------------------------------
U.log("The BUG CATCHER's intro box is up. Press A to walk the rest of it.")
U.log("Correct: six ball slots per side with a thin underline under each row,")
U.log("an arrow blinking bottom-right, and each trainer pic WALKING off its")
U.log("own edge before its send-out text prints (#317).")
U.log("Screenshots of the wild half: " .. DIR .. "/bug317_*.png")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,134 @@
-- Driver: the black pause between the battle wipe and the battle screen (#315).
-- Shrink and Split end with BattleTransition_BlackScreen then DelayFrames 10
-- (engine/battle/battle_transitions.asm:390-392, 422-424); the other six get
-- the same gap free from the load the original hides behind. BLACK_HOLD in
-- src/render/BattleTransition.lua is the knob. Not under POKEPORT_SPEED.
-- POKEPORT_DRIVER=tests/drivers/battle_transition_hold_bug315_test.lua SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local BattleTransition = require("src.render.BattleTransition")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- BLACK_HOLD is a file-local, so this is the driver's own copy of it
local TARGET = 30
game.save.player.name = "bryan"
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
-- ---- measure the hold --------------------------------------------------
-- No screenshots in this pass: U.shot spins frames of its own and would
-- corrupt the count.
local function measure(opts)
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local tr = game.stack:top()
if getmetatable(tr) ~= BattleTransition then return nil, "no transition" end
local style, wipeLen = tr.style, tr.wipeLen
local f, wipeDone, popped = 0, nil, nil
while f < 600 do
f = f + 1
if not wipeDone and tr.phase == "wipe" and tr.t >= wipeLen then
wipeDone = f
end
if game.stack:top() ~= tr then popped = f break end
U.wait(1)
end
return { style = style, wipeLen = wipeLen, wipeDone = wipeDone,
popped = popped, battle = battle, tr = tr }, nil
end
local m, err = measure()
check("a BattleTransition was pushed for the battle", m ~= nil)
if not m then
U.log("could not measure:", tostring(err))
while true do coroutine.yield() end
end
U.log(("style %s: wipe is %d frames"):format(tostring(m.style), m.wipeLen))
check("the wipe reached its last frame", m.wipeDone ~= nil)
check("the transition popped itself", m.popped ~= nil)
local hold = (m.wipeDone and m.popped) and (m.popped - m.wipeDone) or -1
U.log(("black hold measured: %d frames (%.2f s at 60 Hz); target %d")
:format(hold, hold / 60, TARGET))
check(("the screen holds black well past the old 6 frames (got %d) (#315)")
:format(hold), hold >= 20)
check(("the hold is not absurdly long either (got %d)"):format(hold),
hold <= 90)
-- unwind that battle before the next one
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(10)
-- ---- screenshot the beat ------------------------------------------------
-- One frame inside the hold (solid black edge to edge) and one after the pop.
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local tr = game.stack:top()
check("second transition pushed", getmetatable(tr) == BattleTransition)
for _ = 1, 600 do
if tr.phase == "wipe" and tr.t >= tr.wipeLen then break end
U.wait(1)
end
check("hold screenshot reached disk",
U.shot(game, DIR .. "/bug315_black_hold.png"))
U.log("captured", DIR .. "/bug315_black_hold.png",
"-- this one must be SOLID BLACK")
for _ = 1, 300 do
if game.stack:top() ~= tr then break end
U.wait(1)
end
U.wait(2)
check("post-hold screenshot reached disk",
U.shot(game, DIR .. "/bug315_battle_appears.png"))
U.log("captured", DIR .. "/bug315_battle_appears.png")
-- back to the overworld and into tall grass, so the hand-off is a real
-- encounter rather than a scripted push
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(10)
-- Grass cell comes off the loaded map, not a hard-coded pair, so a map edit
-- degrades to "somewhere else in the grass" instead of a wall.
local map = ow.map
local gx, gy
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy)
and map:isWalkableCell(cx, cy + 1) then
gx, gy = cx, cy
break
end
end
if gx then break end
end
check("found a tall-grass cell on ROUTE_1 to hand off in", gx ~= nil)
if gx then
U.log(("standing in the grass at (%d, %d)"):format(gx, gy))
U.teleport(game, "ROUTE_1", gx, gy, "down")
U.wait(10)
end
-- ---- hand off, then stay out of the way --------------------------------
U.log("You are in the tall grass on ROUTE 1. Walk until an encounter fires and")
U.log("watch the join: the wipe should finish, the screen sit solid black for")
U.log(("about half a second (%d frames this run), and only then the battle")
:format(hold))
U.log("screen appear (#315). Wipe style varies, so try it a few times.")
while true do
coroutine.yield()
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
-- Driver: Bill's PC (#177) chrome (What? / BOX No. / <PK><MN>) and
-- Driver: Bill's PC (#177) -- chrome (What? / BOX No. / <PK><MN>) and
-- withdraw/deposit returning to BillsPCMenu instead of closing the PC.
return function(game)
@@ -0,0 +1,134 @@
-- Driver: SET_PAL_BATTLE_BLACK darkens the whole battle screen while the
-- blackout text is up (#292). pokered engine/battle/core.asm:1147-1159
-- runs the palette command before the text, and returns early on OAKS_LAB.
-- No POKEPORT_SPEED here: audio runs on its own real-time clock.
-- POKEPORT_DRIVER=tests/drivers/blackout_palette_bug292_test.lua \
-- POKEPORT_IDENTITY=bug292 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local PaletteFX = require("src.render.PaletteFX")
-- Route 1: no scripted battle, not the Oak's Lab exception, and (5,5) is
-- open floor there (data/generated/maps.lua).
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 5, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- preconditions the eye cannot check --------------------------------
-- A missing PAL_BLACK looks exactly like the bug on screen.
local pack = PaletteFX.pack(game.data)
local BLACK = pack and pack.palettes and pack.palettes.BLACK
check("the active COLORS pack carries PAL_BLACK", BLACK ~= nil)
if BLACK then
U.log(" PAL_BLACK shades:",
("0 = %d,%d,%d"):format(BLACK[1][1], BLACK[1][2], BLACK[1][3]),
("1 = %d,%d,%d"):format(BLACK[2][1], BLACK[2][2], BLACK[2][3]),
("3 = %d,%d,%d"):format(BLACK[4][1], BLACK[4][2], BLACK[4][3]))
check("shade 0 is the near-white paper", BLACK[1][1] > 200)
check("shades 1-3 are near-black ink",
BLACK[2][1] < 90 and BLACK[3][1] < 90 and BLACK[4][1] < 90)
end
-- A DMG never had SGB darkening, so the fix deliberately does nothing in
-- the forced-mono modes and a run in one of them proves nothing.
local mode = PaletteFX.mode
local mono = mode == "og" or mode == "og_inv" or mode == "classic"
U.log("COLORS mode:", tostring(mode), "(" .. PaletteFX.modeLabel(mode) .. ")")
if mono then
U.log("WARNING: " .. PaletteFX.modeLabel(mode) .. " is a forced-mono mode.")
U.log("WARNING: PAL_BLACK is an SGB packet and a DMG never darkened, so")
U.log("WARNING: nothing below will look dark and that is CORRECT. Press 2")
U.log("WARNING: (or set COLORS in OPTION) to SGB / GBC first, then re-run.")
end
check("the COLORS mode is one that can show the darkening", not mono)
-- ---- a losing battle ---------------------------------------------------
-- One mon on 1 HP against something that outspeeds it.
game.save.party = { Pokemon.new(game.data, "RATTATA", 3) }
game.save.party[1].hp = 1
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil)
check("this is not the Oak's Lab starter-rival exception",
(ow and ow.map and ow.map.id) ~= "OAKS_LAB")
local battle = BattleState.newWild(game, "RATICATE", 50)
battle.onFinish = function() end
if ow then ow:pushBattle(battle) end
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
-- photograph the full-color screen so the "after" shot has a reference
for _ = 1, 60 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(4)
end
check("the battle reached its action menu", battle.phase == "menu")
check("nothing is darkened yet", battle.blackedOut == nil)
if not U.shot(game, DIR .. "/bug292_1_full_color.png") then
U.log("FAIL could not capture the full-color battle")
end
-- ---- lose it -----------------------------------------------------------
-- Stop the instant the wipe registers, so the blackout text is still ahead
-- of the player.
for _ = 1, 700 do
if battle.blackedOut then break end
if battle.result then break end
U.tap(game, "a")
U.wait(4)
end
if not battle.blackedOut and not battle.result then
-- the foe kept rolling status moves: take the engine's own faint path
U.log("the foe never landed a hit; forcing the KO through onFaint")
battle.player.mon.hp = 0
battle.nextInsert = 0
battle:onFaint(battle.player)
for _ = 1, 400 do
if battle.blackedOut then break end
U.tap(game, "a")
U.wait(4)
end
end
check("the party was wiped out and the screen blacked", battle.blackedOut == true)
-- Two code paths darken the screen (the zone pass for HUD and bars, a
-- re-baked pic for the sprites) and both have to fire.
if BLACK then
local pals = battle:sgbBattlePals()
check("all four battle zones are PAL_BLACK",
pals ~= nil and pals[0] == BLACK and pals[1] == BLACK
and pals[2] == BLACK and pals[3] == BLACK)
end
U.wait(20)
if not U.shot(game, DIR .. "/bug292_2_blacked_out.png") then
U.log("FAIL could not capture the blacked-out battle")
end
local cur = battle.current
U.log("box on screen:", cur and cur.text and (cur.text:gsub("\n", " / "))
or "(between rows)")
-- ---- hand off ----------------------------------------------------------
U.log("Your last POKeMON has fainted; press A through the blackout text.")
U.log("Behind the box the enemy sprite, the HP bar and the HUD should all")
U.log("be near-black on near-white for both lines (#292). Compare")
U.log(DIR .. "/bug292_1_full_color.png with bug292_2_blacked_out.png.")
U.log("OG / OG INV / CLASSIC and the Oak's lab rival fight never darken.")
while true do
coroutine.yield()
end
end
+112
View File
@@ -0,0 +1,112 @@
-- Manual check that a boulder answers an A press (#318).
-- Every boulder points at BoulderText (pokered home/overworld_text.asm:16),
-- i.e. _BoulderText "This requires\nSTRENGTH to move!", which the extractor
-- stores as an asm label with no text: resolveText returned nil and A did
-- nothing. The data half is asserted in tests/parity_asm_plain_text.lua.
-- POKEPORT_DRIVER=tests/drivers/boulder_text_bug318_test.lua POKEPORT_IDENTITY=bug318 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/VictoryRoad1F.asm: BOULDER3 sits at (2, 10) with
-- wall to its east and west, so the only free approach is the floor below it.
-- Talking to a boulder needs no STRENGTH and no badge.
local MAP = "VICTORY_ROAD_1F"
local BOULDER = "VICTORYROAD1F_BOULDER3"
local TEXT = "TEXT_VICTORYROAD1F_BOULDER3"
local MAP_LABEL = "VictoryRoad1F"
local STAND = { x = 2, y = 11, facing = "up" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- a missing string, a renamed label and an unreached fallback all show up as
-- the same nothing-happens the bug did
local text = game.data:resolveText(MAP_LABEL, TEXT)
check(MAP_LABEL .. "/" .. TEXT .. " resolves to a string",
type(text) == "string" and text ~= "")
check("it is _BoulderText verbatim", text == game.data.text._BoulderText)
check("it mentions STRENGTH",
type(text) == "string" and text:find("STRENGTH", 1, true) ~= nil)
if type(text) == "string" then
U.log("boulder text reads:", (text:gsub("\n", " / ")))
end
-- park the player against the boulder
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local function boulderIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == BOULDER then return n end
end
return nil
end
-- re-reads game.overworld every call: the fallback below teleports again and
-- that rebuilds the state and its npc list
local function facingTheBoulder()
local ow = game.overworld
local rock = ow and boulderIn(ow)
if not rock then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == rock
end
local ow = game.overworld
local rock = ow and boulderIn(ow)
check("boulder object loaded on " .. MAP, rock ~= nil)
if rock and not facingTheBoulder() then
-- a map edit or a mod moved the object: fall back to any free walkable
-- neighbour. {dx, dy, facing} is the offset from the boulder to the stand
-- cell plus the direction that looks back at it, so +1 on x means left.
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = rock.cellX + s[1], rock.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("approach cell (%d, %d) is blocked, standing on")
:format(STAND.x, STAND.y), cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("player is standing against the boulder", facingTheBoulder())
-- press A here so the log can tell "no text entry" from "the press never
-- reached the boulder"; on screen the two look identical
local TextBox = require("src.render.TextBox")
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
local isBox = getmetatable(top) == TextBox
check("pressing A opened a text box", isBox)
if isBox then
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
local joined = table.concat(shown, " / ")
U.log("box reads:", joined)
check("the box is the boulder line, not something else",
joined:find("STRENGTH", 1, true) ~= nil)
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.shot(game, SHOT_DIR .. "/bug318_boulder.png")
U.log("captured", SHOT_DIR .. "/bug318_boulder.png")
end
U.log("The boulder has been talked to already; the box on screen is that.")
U.log("It should type \"This requires\" / \"STRENGTH to move!\" and wait for")
U.log("A or B. Before #318 the press did nothing at all: no box, no sound.")
U.log("Two more boulders on this floor, at (5,15) and (14,2).")
while true do
coroutine.yield()
end
end
+15 -56
View File
@@ -1,32 +1,8 @@
-- Driver: the Viridian caterpillar speech waits for the player (#250).
-- A manual eye check, not a pass/fail run -- nothing in this repo can judge
-- Eye check on the Viridian caterpillar speech (#250): no test can judge
-- "the text went by too fast to read".
--
-- pokered evidence. text/ViridianCity.asm spells the answer with three
-- different break markers, and they are not interchangeable:
--
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText::
-- text "CATERPIE has no"
-- line "poison, but"
-- cont "WEEDLE does."
--
-- para "Watch out for its"
-- line "POISON STING!"
-- done
--
-- src/render/TextBox.lua maps those to \n (second line), \v (scroll one
-- line, after the down-arrow and a button press) and \f (page break, clear
-- and wait). This text is not in data/generated/text.lua -- pokered
-- declares it without the leading underscore the extractor keys on -- so
-- the port carries a literal fallback, and that fallback had spelled both
-- `cont` and `para` as plain \n. Six lines then landed on one page with
-- nothing to wait on, so the whole speech scrolled past untouched.
--
-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic
-- clock while the typewriter and its SFX run on their own real-time
-- accumulator (src/core/Game.lua), which is precisely the pacing being
-- judged here.
--
-- pokered text/ViridianCity.asm spells that answer with line, cont and para,
-- which TextBox maps to \n, \v and \f. The port's literal fallback (the label
-- has no leading underscore, so it is not in text.lua) had all three as \n.
-- POKEPORT_DRIVER=tests/drivers/caterpillar_text_bug250_test.lua POKEPORT_IDENTITY=bug250 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -36,20 +12,17 @@ return function(game)
return ok
end
-- ---- preconditions the eye cannot separate from the bug ----------------
-- A missing handler, or an NPC who wandered off, both end in "no text",
-- which is not the same failure as "text that does not wait".
local MAP = "VIRIDIAN_CITY"
local NPC = "VIRIDIANCITY_YOUNGSTER2"
local TEXT = "TEXT_VIRIDIANCITY_YOUNGSTER2"
-- a missing handler, or an NPC who wandered off, ends in "no text", which is
-- not the same failure as "text that does not wait"
local mapScripts = require("data.scripts.init")
check("a hand-ported handler exists for " .. TEXT,
mapScripts.talkScript(MAP, TEXT) ~= nil)
-- the fix itself: the paginator has to find a page break and a scroll in
-- the description, because that is what makes it wait for a button
-- the page break and the scroll are what make the box wait for a button
local TextBox = require("src.render.TextBox")
local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!"
local pages = TextBox.paginate(desc)
@@ -58,8 +31,7 @@ return function(game)
pages[1] ~= nil and #pages[1] == 3)
check("page 2 is two lines (para + line)",
pages[2] ~= nil and #pages[2] == 2)
-- the wait itself: contBefore marks the line the box holds on until the
-- player presses a button, which is the whole point of the fix
-- contBefore marks the line the box holds on until a button press
check("line 3 waits for a button before scrolling in",
pages.contBefore and pages.contBefore[1] and pages.contBefore[1][3] == true)
@@ -78,10 +50,9 @@ return function(game)
-- pin the wander: he strolls out from under the A press between reads
if npc then npc.wanders = false end
-- objects live at ../pokered/data/maps/objects/ViridianCity.asm and in
-- data/generated/maps.lua; he is at (30,25), so (30,26) faces him. If a
-- map edit ever moves him, stand on any free walkable neighbour instead
-- of parking the player at a wall.
-- pokered data/maps/objects/ViridianCity.asm puts him at (30,25), so (30,26)
-- faces him; if a map edit moves him, the fallback below picks a free
-- walkable neighbour instead of parking the player at a wall.
local function facingTarget()
local ow = game.overworld
if not (ow and npc) then return false end
@@ -104,22 +75,10 @@ return function(game)
end
check("player is standing in front of the youngster", facingTarget())
U.log("........................................................")
U.log("READ NOW: press A to talk, then answer YES to his question.")
U.log(" RIGHT: the answer stops and waits for you three times. After")
U.log(" 'WEEDLE does.' the box waits with a down-arrow, then the")
U.log(" page CLEARS before 'Watch out for its POISON STING!'.")
U.log(" BUG #250 looks like: all six lines pouring past in one go, the")
U.log(" box scrolling itself, and the conversation ending before")
U.log(" you have pressed anything.")
U.log(" ALSO WRONG: it waits but never clears the page (that is \\v where")
U.log(" pokered has para), or it clears after every single line")
U.log(" (that is \\f where pokered has line/cont).")
U.log("Control case: answer NO instead and you should get the one-line")
U.log(" 'Oh, OK then!', which needs no waits at all.")
U.log("Input is yours from here, so you can re-run the talk as often as")
U.log("you like.")
U.log("........................................................")
U.log("Press A to talk, answer YES, and read the description.")
U.log("It should stop for you three times, and the page should CLEAR before")
U.log("'Watch out for its POISON STING!' -- under #250 all six lines poured")
U.log("past in one go. Answering NO is the control: one line, no waits.")
while true do
coroutine.yield()
@@ -0,0 +1,101 @@
-- Driver: holding A or B on Cycling Road holds you in place (#255). pokered
-- home/overworld.asm:1825 masks PAD_A and PAD_B along with the d-pad before it
-- forces PAD_DOWN. Hands check, so no POKEPORT_SPEED.
-- POKEPORT_DRIVER=tests/drivers/cycling_road_brake_bug255_test.lua \
-- POKEPORT_IDENTITY=bug255 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- x=2 is open road from y=8 past y=34; y=20 keeps the demo roll clear of the
-- bikers at (4,18) and (7,32).
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
-- ---- preconditions -----------------------------------------------------
-- "the bike does not roll" and "the brake works" look the same, so prove the
-- hill is pulling first.
local fm = game.data.field.forcedMovement
local isSlope = false
for _, id in ipairs((fm and fm.slopeMaps) or {}) do
if id == MAP then isSlope = true end
end
check("field.forcedMovement.slopeMaps names ROUTE_17", isSlope)
game.save.onBike = true
check("the player is on the BICYCLE (no bike, no roll)", game.save.onBike == true)
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(15)
local ow = game.overworld
check("standing on Cycling Road", ow.map.id == MAP)
local clear = true
for dy = 1, 8 do
if not ow.map:isWalkableCell(START_X, START_Y + dy) then clear = false end
end
check(("open road for 8 cells south of (%d,%d)"):format(START_X, START_Y), clear)
-- ---- the hill pulls ----------------------------------------------------
local y0 = ow.player.cellY
U.wait(48)
local rolled = ow.player.cellY - y0
check(("hands off the pad, the bike rolls south (%d cells in 48 frames)")
:format(rolled), rolled > 0)
U.shot(game, SHOT_DIR .. "/bug255_rolling.png")
-- ---- and A / B stop it -------------------------------------------------
-- Input:beginStep rebuilds `pressed` from the queue every fixed step, so one
-- queued edge plus a sticky input.state is exactly a button held down.
local function holdFor(btn, frames)
local ow2 = game.overworld
table.insert(game.input.pressQueue, btn) -- the key-down edge
game.input.state[btn] = true
-- the mask only suppresses the NEXT simulated PAD_DOWN, so a step already
-- under way still lands; the brake is measured from where it puts you
for _ = 1, 24 do
if not ow2.player.moving then break end
coroutine.yield()
end
local start = ow2.player.cellY
local drift = 0
for _ = 1, frames do
coroutine.yield()
if ow2.player.cellY ~= start then drift = ow2.player.cellY - start end
end
check(("holding %s for %d frames: the player does not drift south (%d cells)")
:format(btn:upper(), frames, drift), drift == 0)
U.shot(game, ("%s/bug255_braking_%s.png"):format(SHOT_DIR, btn))
game.input.state[btn] = false
local held = ow2.player.cellY
U.wait(48)
check(("releasing %s resumes the roll (%d cells in 48 frames)")
:format(btn:upper(), ow2.player.cellY - held),
ow2.player.cellY > held)
end
holdFor("a", 120)
holdFor("b", 120)
-- park somewhere with road left to roll before handing over
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(10)
game.save.onBike = true
-- ---- hand off ----------------------------------------------------------
U.log(("On the bike on Cycling Road at (%d,%d), already rolling south.")
:format(START_X, START_Y))
U.log("Hold A, then B, then let go, then hold A while pressing DOWN.")
U.log("Correct: dead stop while held, roll resumes the instant you release,")
U.log("a held direction still steers. #255 was B doing nothing and A")
U.log("stalling one frame. Shots in " .. SHOT_DIR .. "/bug255_*.png")
while true do
coroutine.yield()
end
end
+71
View File
@@ -0,0 +1,71 @@
-- Look at the Pokedex entry pic (#307).
-- `local ok, img = path and pcall(love.graphics.newImage, path)` is one
-- expression, not a guarded pcall: img was always nil and every dex page drew
-- an empty pic box. parity_dex_pic.lua asserts the image loads; placement
-- (top-left of the page, bottom-aligned to y=60) is the half only an eye can judge.
-- POKEPORT_DRIVER=tests/drivers/dex_pic_bug307_test.lua POKEPORT_IDENTITY=bug307 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local Sprites = require("src.pokemon.Sprites")
-- the starters are the reported case (Oak's lab preview); PIDGEY is an
-- ordinary list entry as the control
local CASES = { "BULBASAUR", "CHARMANDER", "SQUIRTLE", "PIDGEY" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- an unresolved path, a PNG missing from the cache and the truncation bug
-- itself all render as the same empty box
local DexEntryMenu = require("src.ui.DexEntryMenu")
for _, species in ipairs(CASES) do
local path = Sprites.path(game.data, species, "front", { kind = "dex" })
check(species .. " resolves a dex front-pic path",
type(path) == "string" and path ~= "")
local page = DexEntryMenu.new(game, { species = species, forceOwned = true })
local held = page.sprite ~= nil
check(species .. " dex page holds its pic", held)
if held and page.sprite.getDimensions then
local w, h = page.sprite:getDimensions()
U.log(" " .. species .. " pic is", w .. "x" .. h,
"drawn at x=8, y=" .. math.max(0, 60 - h))
end
end
-- mark the cases seen so the list can reach them
local dex = game.save.pokedex
if dex then
for _, species in ipairs(CASES) do
dex.seen[species] = true
dex.owned[species] = true
end
U.log("flagged", #CASES, "species as seen+owned so the list can open them")
end
-- screenshots, one page at a time
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
for _, species in ipairs(CASES) do
while game.stack:top() do game.stack:pop() end
Screens.push(game, "DexEntryMenu", { species = species, forceOwned = true })
U.wait(30)
U.shot(game, SHOT_DIR .. "/dex_" .. species:lower() .. ".png")
U.log("captured", SHOT_DIR .. "/dex_" .. species:lower() .. ".png")
end
-- leave a live page up for the hand-off
while game.stack:top() do game.stack:pop() end
Screens.push(game, "DexEntryMenu", { species = "BULBASAUR", forceOwned = true })
U.wait(10)
U.log("A BULBASAUR dex entry is open; shots of all four cases are in " .. SHOT_DIR)
U.log("The front sprite belongs in the top-left, standing on the line above")
U.log("HT/WT, not floating or overlapping the text. Under #307 that whole")
U.log("side was blank white. A or B closes the page.")
while true do
coroutine.yield()
end
end
+1 -1
View File
@@ -56,7 +56,7 @@ return function(game)
end
-- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then
-- \v "POKéMON with!" the bug auto-scrolled past this with no ▼.
-- \v "POKéMON with!" -- the bug auto-scrolled past this with no ▼.
U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up")
U.tap(game, "a")
local box = waitForTextBox(90)
+122
View File
@@ -0,0 +1,122 @@
-- Driver: the foe's party ball row between a KO and the next send-out (#283).
-- pokered ReplaceFaintedEnemyMon (engine/battle/core.asm:892-896) callfars
-- DrawEnemyPokeballs before it falls into EnemySendOut; the port never did.
-- POKEPORT_DRIVER=tests/drivers/enemy_balls_bug283_test.lua \
-- POKEPORT_IDENTITY=bug283 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed)
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- roster 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so the row
-- has a KO'd ball, a live one and four empties to tell apart.
local OPP, ROSTER = "OPP_YOUNGSTER", 1
-- ---- preconditions ------------------------------------------------------
-- Each of these fails silently as "no ball row on screen", same as the bug.
check("trainer class " .. OPP .. " is in the data",
game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil)
-- drawBallRow bails out silently when the sheet will not load, and the
-- underline comes off the $73/$74/$76/$78 HUD glyph pages
local ballSheet = love.filesystem.getInfo("assets/generated/battle/balls.png")
check("assets/generated/battle/balls.png is in the cache", ballSheet ~= nil)
local okImg = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
check("the ball sheet actually decodes", okImg)
check("BattleState:drawBallRow exists",
type(BattleState.drawBallRow) == "function")
local HudTiles = require("src.render.HudTiles")
check("HudTiles.tile is available for the underline",
type(HudTiles.tile) == "function")
-- SHIFT keeps the row up through the whole YES/NO prompt (showEnemyBalls is
-- not cleared until the send-out act); SET only flashes it for 16 frames.
game.save.options = game.save.options or {}
game.save.options.battleStyle = "shift"
game.save.player.name = "bryan"
-- two party slots: SHIFT only offers the switch when wPartyCount > 1
game.save.party = {
Pokemon.new(game.data, "MEWTWO", 70),
Pokemon.new(game.data, "CHARIZARD", 50),
}
check("a one-shot lead is in the party", game.save.party[1] ~= nil)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER)
check("trainer battle constructed", ok and battle ~= nil)
if not ok then
U.log("could not start", OPP, "->", tostring(battle))
while true do coroutine.yield() end
end
check("the foe has at least two mons to swap between", #battle.enemyParty >= 2)
battle.onFinish = function() end
ow:pushBattle(battle)
-- tap A until cond(), polling every frame so a one-frame window is caught
local function tapUntil(cond, taps, gap)
for _ = 1, (taps or 60) do
if cond() then return true end
U.tap(game, "a")
for _ = 1, (gap or 6) do
if cond() then return true end
U.wait(1)
end
end
return cond()
end
check("reached the FIGHT/PKMN/ITEM/RUN menu",
tapUntil(function() return battle.phase == "menu" end, 60))
-- FIGHT, then the first move: L70 MEWTWO one-shots a L11 RATTATA, so the KO
-- is the real sequence and not a poked hp value.
U.tap(game, "a")
U.wait(10)
U.tap(game, "a")
U.wait(10)
-- Stop the instant showEnemyBalls goes up: that flag is raised exactly where
-- ReplaceFaintedEnemyMon calls DrawEnemyPokeballs.
local reached = tapUntil(function() return battle.showEnemyBalls == true end,
90, 4)
check("the foe's first mon was KO'd and the ball row went up (#283)", reached)
check("the KO'd slot really is fainted",
battle.enemyParty[1] ~= nil and battle.enemyParty[1].hp <= 0)
check("a live reserve is still in the row",
battle.enemyParty[2] ~= nil and battle.enemyParty[2].hp > 0)
if reached then
check("row screenshot reached disk",
U.shot(game, DIR .. "/bug283_balls_after_ko.png"))
U.log("captured", DIR .. "/bug283_balls_after_ko.png")
-- a few more presses put the SHIFT prompt on top of the still-visible row
tapUntil(function() return battle.showEnemyBalls ~= true end, 3, 20)
check("row screenshot during the SHIFT prompt reached disk",
U.shot(game, DIR .. "/bug283_balls_with_prompt.png"))
U.log("captured", DIR .. "/bug283_balls_with_prompt.png")
U.log("showEnemyBalls is still up at hand-off:",
tostring(battle.showEnemyBalls))
end
-- ---- hand off -----------------------------------------------------------
U.log("The foe's first mon is KO'd and we are paused before the send-out.")
U.log("Top left should carry six small Poke Balls running right to left from")
U.log("(64,16) on a HUD rule: one crossed out, one solid, four empty.")
U.log("#283 was that corner empty. Press A, the row clears on the send-out.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,315 @@
-- Driver: the evolution flash must put the WHOLE screen on PAL_BLACK (#279).
-- pokered engine/movie/evolution.asm EvolveMon flashes under PAL_BLACK; only the
-- settled form wears a mon palette, and PAL_BLACK keeps colour 0 as paper white
-- (sgb_palettes.asm:46), so the background behind the silhouettes stays put.
-- POKEPORT_DRIVER=tests/drivers/evolution_black_bug279_test.lua \
-- POKEPORT_IDENTITY=bug279 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Probe = dofile("tests/drivers/shot_probe.lua")
local PaletteFX = require("src.render.PaletteFX")
local EvolutionState = require("src.ui.EvolutionState")
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local fails = 0
local function check(ok, msg)
U.log(ok and "PASS" or "FAIL", msg)
if not ok then fails = fails + 1 end
return ok
end
local function rgb(c)
return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil"
end
local function ramp(p)
if not p then return "nil" end
local s = {}
for i = 1, 4 do s[i] = rgb(p[i]) end
return table.concat(s, " ")
end
local function samePal(a, b)
if not a or not b then return false end
for i = 1, 4 do
if not a[i] or not b[i] then return false end
for k = 1, 3 do if a[i][k] ~= b[i][k] then return false end end
end
return true
end
-- Text speed 1 keeps the "Congratulations!" box brisk; FLASH_FRAMES is a
-- frame count, so the flash itself is unaffected.
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
-- =====================================================================
-- Part 1: the halves the eye cannot check. A missing BLACK entry or a zone
-- that does not cover the screen looks exactly like the bug.
-- =====================================================================
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
local BLACK = PaletteFX.pal(game.data, "BLACK")
check(BLACK ~= nil, "data.palettes carries a BLACK entry (PAL_BLACK)")
U.log("BLACK ramp:", ramp(BLACK))
-- sgb_palettes.asm RGB 31,29,31 / 07,07,07 / 02,03,03 / 03,02,02, scaled
-- to 8 bits by the extractor
check(BLACK and BLACK[1][1] == 255 and BLACK[1][2] == 239 and BLACK[1][3] == 255,
"PAL_BLACK colour 0 is still paper white (the background does NOT black out)")
check(BLACK and BLACK[2][1] == 58 and BLACK[3][1] == 16 and BLACK[4][1] == 25,
"PAL_BLACK shades 1..3 are crushed (58,58,58 / 16,25,25 / 25,16,16)")
-- sgbPalettes is a pure function of the state's own fields, so drive it
-- directly for the cases the live run cannot hold still. MAGIKARP is REDMON
-- and GYARADOS BLUEMON: a CATERPIE line settles on GREENMON either way and
-- would prove nothing.
local function zonesFor(fields)
local fake = setmetatable(fields, EvolutionState)
return EvolutionState.sgbPalettes(fake, game)
end
local function colorsFor(fields)
local z = zonesFor(fields)
return z and z[1] and z[1].colors, z and z[1]
end
local flashCols, flashZone = colorsFor({
done = false, canceled = false,
mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS",
})
check(samePal(flashCols, BLACK),
"mid-flash the whole screen is PAL_BLACK, not a mon palette")
U.log("mid-flash zone resolves to:", ramp(flashCols))
check(flashZone ~= nil and flashZone.x == 0 and flashZone.y == 0
and flashZone.w == 160 and flashZone.h == 144,
"that zone covers the whole 160x144 screen (SetPal_PokemonWholeScreen)")
local settled = colorsFor({
done = true, canceled = false,
mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS",
})
check(samePal(settled, PaletteFX.monPal(game.data, "GYARADOS")),
"once .done, the NEW form wears its own palette again (GYARADOS/BLUEMON)")
local cancelled = colorsFor({
done = true, canceled = true,
mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS",
})
check(samePal(cancelled, PaletteFX.monPal(game.data, "MAGIKARP")),
"a B-cancelled evolution settles on the OLD form (MAGIKARP/REDMON)")
-- Mode guards: routing the blackout through PaletteFX.pal is what keeps the
-- other COLORS modes honest. A hardcoded {0,0,0} passes everything above.
PaletteFX.setMode("ogred")
local ogFlash = colorsFor({
done = false, canceled = false,
mon = { species = "WEEDLE" }, newSpecies = "KAKUNA",
})
check(samePal(ogFlash, PaletteFX.ogBg()),
"OG RED does NOT black out (a Game Boy Color never sees the SGB packet)")
PaletteFX.setMode("redpp")
check(PaletteFX.pal(game.data, "BLACK") ~= nil,
"RED++ resolves BLACK from data/palettes_gbc.lua (no ROM fallback needed)")
PaletteFX.setMode("og")
check(samePal(PaletteFX.effectiveColors(BLACK), PaletteFX.GRAYS),
"plain DMG replaces it wholesale, so the mono modes are unchanged")
PaletteFX.setMode("gbc")
game.save.options.colors = "gbc"
-- The bytes the pixel probe hunts for, read out of the data and read while
-- the mode is SGB (under OG RED, PaletteFX.pal short-circuits every name to
-- the one palette).
local YELLOW = PaletteFX.monPal(game.data, "WEEDLE") -- YELLOWMON
local REDMON = PaletteFX.monPal(game.data, "MAGIKARP") -- REDMON
local BLUE = PaletteFX.monPal(game.data, "GYARADOS") -- BLUEMON
local OGBG = PaletteFX.GBC_BG
check(YELLOW ~= nil and REDMON ~= nil and BLUE ~= nil,
"WEEDLE/MAGIKARP/GYARADOS mon palettes resolve")
U.log("WEEDLE:", ramp(YELLOW), " MAGIKARP:", ramp(REDMON),
" GYARADOS:", ramp(BLUE))
local function probe(label, wanted)
local shot = Probe.grab()
if not shot then
U.log("WARN pixel probe unavailable (no screenshot capture); judge",
label, "by eye only")
return nil
end
local counts, total = Probe.count(shot, wanted)
local parts = {}
for name, n in pairs(counts) do
parts[#parts + 1] = ("%s=%d"):format(name, n)
end
table.sort(parts)
U.log(("probe[%s] %d px sampled: %s"):format(label, total,
table.concat(parts, " ")))
return counts
end
-- =====================================================================
-- Part 2: reach the moment and photograph it.
-- =====================================================================
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
local function evoTop()
local t = game.stack:top()
return (t and t.screenId == "EvolutionState") and t or nil
end
local function waitFor(cond, max)
for _ = 1, max or 600 do
if cond() then return true end
U.wait(1)
end
return false
end
local function pagesText(st)
if not st or not st.pages then return nil end
local parts = {}
for _, page in ipairs(st.pages) do
if type(page) == "table" then
for _, line in ipairs(page) do
if type(line) == "string" then parts[#parts + 1] = line end
end
elseif type(page) == "string" then
parts[#parts + 1] = page
end
end
return table.concat(parts, " ")
end
local function findText(needle)
for _, st in ipairs(game.stack.states or {}) do
local blob = pagesText(st)
if blob and blob:find(needle, 1, true) then return st end
end
return nil
end
local function mashUntil(cond, max)
for _ = 1, max or 200 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return cond()
end
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is
-- taken. EvolutionState:update only compares self.t against that constant,
-- so this ends the animation early without touching the palette logic.
local function skipToEnd()
local st = evoTop()
if st then st.t = 214 end
end
local function startEvo(species, into)
local mon = Pokemon.new(game.data, species, 7)
table.insert(game.save.party, 1, mon)
Evolution.evolve(game, mon, into)
if not waitFor(evoTop, 300) then
check(false, "EvolutionState opened for " .. species .. " -> " .. into)
return nil
end
return mon
end
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
local weedle = startEvo("WEEDLE", "KAKUNA")
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
local c1 = probe("flash/SGB", {
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
black1 = BLACK[2], black2 = BLACK[3], paper = BLACK[1],
})
if c1 then
check(c1.monYellow1 == 0 and c1.monYellow2 == 0,
"no WEEDLE/KAKUNA yellow anywhere on screen during the flash")
check(c1.black1 + c1.black2 > 0,
"the crushed PAL_BLACK shades ARE on screen (the silhouette)")
check(c1.paper > 0,
"paper white still fills the background (PAL_BLACK keeps colour 0)")
end
-- ---- case 1b: it comes back once the flash is over ----------------------
skipToEnd()
if not waitFor(function() return findText("evolved into") ~= nil end, 240) then
check(false, "the Congratulations text appears when the flash ends")
end
U.wait(50) -- let the box finish typing before the shot
U.shot(game, DIR .. "/evo279_2_congrats_sgb.png")
local c2 = probe("congrats/SGB", {
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], black1 = BLACK[2],
})
if c2 then
check(c2.monYellow1 + c2.monYellow2 > 0,
"the settled KAKUNA is yellow again under the Congratulations box")
end
check(weedle == nil or weedle.species == "KAKUNA",
"the mon actually evolved (control: the fix touched no game logic)")
mashUntil(function() return not evoTop() and not findText("evolved into") end, 90)
U.wait(10)
-- ---- case 2: B-cancel settles on the OLD form's colours -----------------
local karp = startEvo("MAGIKARP", "GYARADOS")
U.wait(24)
U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel
if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then
check(false, "holding B prints \"stopped evolving\"")
end
U.wait(50)
U.shot(game, DIR .. "/evo279_3_cancelled_sgb.png")
local c3 = probe("cancelled/SGB", {
red1 = REDMON[2], red2 = REDMON[3], blue1 = BLUE[2], blue2 = BLUE[3],
})
if c3 then
check(c3.red1 + c3.red2 > 0,
"a cancelled MAGIKARP settles back into MAGIKARP's own red")
check(c3.blue1 == 0 and c3.blue2 == 0,
"no GYARADOS blue leaks in (wEvoOldSpecies, not the new species)")
end
check(karp == nil or karp.species == "MAGIKARP", "the cancelled mon kept its species")
mashUntil(function() return not evoTop() and not findText("stopped evolving") end, 90)
U.wait(10)
-- ---- case 3: OG RED must NOT black out ---------------------------------
-- A Game Boy Color never sees the SGB packet, so its one global BG palette
-- applies all the way through. Switch mode with only the overworld on the
-- stack: setMode's cache drop / map reload must not land on a live evolution.
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
U.wait(20)
startEvo("WEEDLE", "KAKUNA")
U.wait(24)
U.shot(game, DIR .. "/evo279_4_flash_ogred.png")
local c4 = probe("flash/OG RED", {
ogPink = OGBG[2], ogRed = OGBG[3], black1 = BLACK[2], black2 = BLACK[3],
})
if c4 then
check(c4.ogPink + c4.ogRed > 0, "OG RED stays red/pink through the flash")
check(c4.black1 == 0 and c4.black2 == 0,
"PAL_BLACK's crushed shades never appear in OG RED")
end
skipToEnd()
waitFor(function() return findText("evolved into") ~= nil end, 240)
mashUntil(function() return not evoTop() and not findText("evolved into") end, 90)
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
U.wait(20)
U.log(fails == 0 and "all #279 preconditions passed"
or (fails .. " #279 precondition(s) FAILED -- read up"))
-- =====================================================================
-- Part 3: one more, live, for the human.
-- =====================================================================
startEvo("WEEDLE", "KAKUNA")
U.log("A WEEDLE is evolving now in SGB colours, about four seconds. While the")
U.log("two forms trade places the whole screen is on PAL_BLACK: near-black")
U.log("silhouettes on the unchanged off-white background, colour back only")
U.log("under the Congratulations box. #279 flashed both forms in full colour.")
U.log("Hold B mid-flash to cancel; it settles on the OLD form's colours.")
U.log("Screenshots: " .. DIR .. "/evo279_*.png")
while true do
coroutine.yield()
end
end
+189
View File
@@ -0,0 +1,189 @@
-- Driver: the fishing rod is ONE 8x8 tile in the player's hands, mirrored
-- between left and right, and it stays up through the verdict text (#321).
-- engine/overworld/player_animations.asm:471 FishingRodOAM draws one tile per
-- facing ($fd up/down, $fe sides, OAM_XFLIP on right) out of the 8x24 sheet
-- at :489; :437 clears BIT_LEDGE_OR_FISHING only after PrintText.
-- POKEPORT_DRIVER=tests/drivers/fishing_rod_bug321_test.lua POKEPORT_IDENTITY=bug321 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- The Viridian City pond (water block x=8..13, y=24..27) is the one spot
-- where all four shores are a few steps apart, so every facing fits on one
-- screen. shoreFor() below re-derives each stand cell from the map data.
local MAP = "VIRIDIAN_CITY"
local SPOTS = {
{ x = 10, y = 23, facing = "down", note = "north shore" },
{ x = 8, y = 28, facing = "up", note = "south shore" },
{ x = 14, y = 24, facing = "left", note = "east shore" },
{ x = 7, y = 24, facing = "right", note = "west shore" },
}
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local ROD = "OLD_ROD"
-- ---- preconditions the eye cannot separate from the bug ----------------
-- A missing sheet, a rod that never reaches goFishing and a rod drawn at
-- garbage coordinates all look the same: no rod in the hands.
local fx = game.data.field.overworldFx
local rodDef = fx and fx.fishingRod
check("field.overworldFx.fishingRod resolves",
rodDef ~= nil and type(rodDef.path) == "string")
if rodDef then U.log("rod sheet:", rodDef.path) end
-- three stacked 8x8 tiles, of which exactly one is ever drawn; if the sheet
-- is not 8x24 the ROD_OAM tile indices mean nothing
if rodDef and rodDef.path then
local ok, img = pcall(love.graphics.newImage, rodDef.path)
if check("the rod sheet loads", ok and img ~= nil) then
local w, h = img:getDimensions()
U.log(("rod sheet is %dx%d"):format(w, h))
check("it is 8 wide (one tile)", w == 8)
check("it is 24 tall (three stacked 8x8 tiles, RedFishingRodTiles)", h == 24)
end
end
check("OLD ROD is a real item", game.data.items[ROD] ~= nil)
local ItemEffects = require("src.inventory.ItemEffects")
local result = ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld)
check("using it routes to the fishing branch", result == "fish")
-- Old Rod always hooks a L5 MAGIKARP, so the verdict box is deterministic
local rodFishing = (game.data.field.fishing or {})[ROD]
check("OLD ROD always gets a bite (field.fishing.OLD_ROD.always)",
rodFishing ~= nil and rodFishing.always ~= nil)
-- the bite ends in a battle once the box is dismissed
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.inventory = { [ROD] = 1 }
game.save.bagOrder = { ROD }
check("the rod is the only thing in the bag (so it is item #1)",
(game.save.inventory[ROD] or 0) > 0)
-- ---- helpers -----------------------------------------------------------
-- Walk the real path, START-menu bag -> rod -> USE: BagMenu's fish branch
-- is what calls goFishing, and it refuses unless the faced cell is water,
-- so this doubles as proof the stand cell is right.
local function useRod()
Screens.push(game, "BagMenu")
U.wait(20)
U.tap(game, "a") -- select OLD ROD -> USE/TOSS
U.wait(20)
U.tap(game, "a") -- USE
U.wait(30)
end
local function topBox()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
return top, table.concat(shown, " / ")
end
-- A press while the box is still typing only finishes the line, so tap
-- until the box on top is a different one (or we run out of patience).
local function advance(box)
for _ = 1, 12 do
U.tap(game, "a")
U.wait(12)
if game.stack:top() ~= box then return true end
end
return false
end
-- if a map edit moved the pond, find any shore facing water the same way
-- rather than parking the player at a wall
local function shoreFor(map, spot)
local d = DELTA[spot.facing]
if map:inBounds(spot.x + d[1], spot.y + d[2])
and map:isWaterCell(spot.x + d[1], spot.y + d[2])
and map:isWalkableCell(spot.x, spot.y) then
return spot.x, spot.y
end
for y = 0, map.def.height * 2 - 1 do
for x = 0, map.def.width * 2 - 1 do
if map:isWalkableCell(x, y) and not map:isWaterCell(x, y)
and map:inBounds(x + d[1], y + d[2])
and map:isWaterCell(x + d[1], y + d[2]) then
U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s")
:format(spot.x, spot.y, x, y, spot.facing))
return x, y
end
end
end
return spot.x, spot.y
end
-- ---- the four facings --------------------------------------------------
for i, spot in ipairs(SPOTS) do
local last = (i == #SPOTS)
U.teleport(game, MAP, spot.x, spot.y, spot.facing)
U.wait(15)
local ow = game.overworld
local sx, sy = shoreFor(ow.map, spot)
if sx ~= spot.x or sy ~= spot.y then
U.teleport(game, MAP, sx, sy, spot.facing)
U.wait(15)
ow = game.overworld
end
local fcx, fcy = ow.player:facingCell()
check(("facing %s from (%d,%d), the %s: water in front")
:format(spot.facing, sx, sy, spot.note),
ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy))
useRod()
local dots, dotsText = topBox()
check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil)
if dotsText then U.log("box reads:", dotsText) end
check(("%s: the rod is up (overworld.fishing is set)"):format(spot.facing),
ow.fishing ~= nil)
check(("%s: the rod remembers this facing"):format(spot.facing),
ow.fishing ~= nil and ow.fishing.facing == spot.facing)
if not U.shot(game, ("%s/bug321_rod_%s_dots.png"):format(SHOT_DIR, spot.facing)) then
check(("%s: dots screenshot reached disk"):format(spot.facing), false)
end
-- the timing half of #321: the dots box closes, the verdict box opens,
-- and the rod must still be in the player's hands the whole time
if dots then advance(dots) end
local verdict, verdictText = topBox()
check(("%s: the verdict box opened"):format(spot.facing), verdict ~= nil)
if verdictText then U.log("box reads:", verdictText) end
check(("%s: the rod is STILL up while the verdict text shows"):format(spot.facing),
ow.fishing ~= nil)
if not U.shot(game, ("%s/bug321_rod_%s_verdict.png"):format(SHOT_DIR, spot.facing)) then
check(("%s: verdict screenshot reached disk"):format(spot.facing), false)
end
-- The last facing is left live for the hand-off; the others are cleared
-- by the next teleport, which pops the stack, so no battle starts here.
if last then
U.log("leaving this box open for you")
end
end
-- ---- hand off ----------------------------------------------------------
U.log("West shore of the Viridian pond, facing right, mid-cast.")
U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror")
U.log("image left vs right, still up for the whole verdict box (#321).")
U.log("Shots: " .. SHOT_DIR .. "/bug321_rod_<facing>_{dots,verdict}.png")
U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.")
while true do
coroutine.yield()
end
end
+121 -27
View File
@@ -1,27 +1,45 @@
-- Visual + render-decision regression for #150 ("Grass transparency is off").
--
-- The reporter's should_be.png shows Red standing in Route 1 tall grass with a
-- GREEN cap that blends into the grass. The default SGB color mode instead
-- region-tints the player with the per-map SGB BG palette: Red's dark-gray cap
-- (DMG shade 2) maps to the ROUTE palette's shade-2 = light-blue (165,214,255)
-- (data/generated/palettes.lua ROUTE), so the character clashes with the grass.
-- On real GBC/SGB an OBJ carries its own object palette; OG RED already bakes
-- PaletteFX.ogObj() (green over Red, pink over Blue -- color/sprites.asm
-- ColorOverworldSprite) onto overworld characters. The fix makes SGB mode do
-- the same while leaving terrain on its per-map SGB palette.
-- GREEN cap that blends into the grass. That green is the ROUTE palette's own
-- entry 2 (173,230,90), not an object palette of the character's: overworld
-- OBJs run through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`), which
-- lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1, so the cap lands on
-- the very colour the grass beside it uses. Drawn with an identity shade map
-- the cap landed on shade 2 = light-blue (165,214,255) instead -- the clash
-- #150 reported. The first fix baked PaletteFX.ogObj() (the Game Boy Color
-- boot ROM's green) onto SGB characters, which is a different machine's answer
-- and became #301 ("people in SGB mode are green"): the Super Game Boy cannot
-- colour an OBJ apart from the BG at all, since pokered never sends OBJ_TRN
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ /
-- CHR_TRN / PCT_TRN and nothing else). So SGB bakes the OBP0 ramp
-- (PaletteFX.dmgObj) and lets the zone shader colour the result.
--
-- Gate (fails before the fix, passes after):
-- * PaletteFX.usesSpriteObp("gbc") == true
-- * the player SpriteRenderer bakes a distinct OBJ image in SGB mode
-- (resolveImage() ~= the raw grayscale sheet) -- the pixel path that
-- recolors the cap green
-- * that baked OBJ palette's shade-2 (the cap) is green, not ROUTE light-blue
-- * PaletteFX.usesSpriteObp("gbc") == false -- SGB owns no object palette
-- * the player SpriteRenderer still bakes a distinct image in SGB mode
-- (resolveImage() ~= the raw grayscale sheet): rOBP0 plus the alpha key
-- * that bake puts the cap (sheet shade 2) on DMG shade 1, which the zone
-- shader then reads out of the map palette as its entry 2 -- ROUTE's grass
-- green, not its light-blue
-- Regression guard (must hold before AND after -- terrain is untouched):
-- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and
-- light-blue (165,214,255), so the grass field keeps its green+blue dither.
--
-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view) and
-- grass_bug150_ogred.png (OG RED reference -- green character, red terrain).
-- The SECOND half of #150 is the feet overdraw itself. Tall grass hides the
-- lower half of whoever stands in it (the GB OBJ-priority trick: an OBJ shows
-- through BG colour 0 and hides under colours 1-3), which the port reproduces
-- by redrawing the cell's bottom tile row over the sprite with shade 0 keyed
-- to alpha. Every mode but ADVANCED still has DMG white sitting in shade 0 at
-- draw time, so a white test finds it; ADVANCED bakes the real per-tile GBC
-- palette into the atlas first (TileRenderer.getGbcAtlas), which turns the
-- grass tile's shade 0 into its palette's light green -- the white test then
-- never fires and the patch paints an opaque block over the player. Hence the
-- gap-pixel gate below, run in BOTH modes so the two can't drift apart again.
--
-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view),
-- grass_bug150_advanced.png (the ADVANCED view) and grass_bug150_ogred.png
-- (OG RED reference -- green character, red terrain).
--
-- Run: POKEPORT_DRIVER=tests/drivers/grass_overlay_bug150_test.lua \
-- POKEPORT_IDENTITY=bug150 POKEPORT_TOUCH=0 love .
@@ -43,6 +61,31 @@ return function(game)
return false
end
-- How many of the 16x8 feet-overdraw pixels let the thing underneath show
-- through, measured the way the screen does it: render the cell's bottom
-- tile row over an opaque magenta field and count the magenta survivors.
-- Mode-agnostic on purpose -- it asks what reached the framebuffer, not
-- which of the two keying paths (shader or baked alpha) produced it.
local function grassGapPixels(ow)
local p = ow.player
local canvas = love.graphics.newCanvas(16, 8)
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 0, 1, 1)
love.graphics.setColor(1, 1, 1, 1)
-- camera placed so the cell's bottom tile row lands at the canvas origin
ow.map.renderer:drawCellBottom(p.cellX, p.cellY, p.cellX * 16, p.cellY * 16 + 8)
love.graphics.setCanvas()
local id = canvas:newImageData()
local n = 0
for y = 0, 7 do
for x = 0, 15 do
local r, g, b = id:getPixel(x, y)
if r > 0.9 and g < 0.1 and b > 0.9 then n = n + 1 end
end
end
return n
end
-- a party + starter flag so the overworld is fully usable
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
@@ -64,24 +107,35 @@ return function(game)
U.wait(40) -- let the grass/flower tile animation cycle
U.shot(game, DIR .. "/grass_bug150_sgb.png")
-- === render-decision gate: fails before the fix, passes after =========
check(PaletteFX.usesSpriteObp("gbc") == true,
"SGB mode bakes an OBJ palette onto overworld characters")
-- the feet overdraw is see-through in SGB (the mode the reporter compared
-- ADVANCED against), so this side of the gate holds before AND after
local sgbGaps = grassGapPixels(ow)
check(sgbGaps > 0,
"SGB grass feet overdraw shows the sprite through its gaps ("
.. sgbGaps .. "/128 px)")
-- the player's sprite must resolve to a baked OBJ image (not the raw
-- grayscale sheet) in SGB mode -- this is the exact path that colors the cap
-- === render-decision gate: fails before the fix, passes after =========
check(PaletteFX.usesSpriteObp("gbc") == false,
"SGB owns no object palette (it cannot colour an OBJ apart from the BG)")
-- the player's sprite must still resolve to a baked image (not the raw
-- grayscale sheet) in SGB mode -- that bake is rOBP0 plus the alpha key
local spr = p.sprite
check(spr and spr.image and spr:resolveImage() ~= spr.image,
"player sprite bakes a distinct OBJ image in SGB mode")
"player sprite bakes a distinct OBP0 image in SGB mode")
-- the baked object palette's shade-2 (the cap) is a green, and specifically
-- NOT the ROUTE light-blue the region shader used to hand it
local obj = PaletteFX.ogObj() -- {white, brightgreen, darkgreen, black}
local cap = obj and obj[3] -- DMG shade 2 -> 3rd palette entry
-- OBP0 (`dc 3,1,0,0`) sends the cap -- sheet shade 2 -- to DMG shade 1, so
-- the zone shader hands it the map palette's entry 2 rather than the entry 3
-- light-blue an identity shade map used to pick
local obp = PaletteFX.dmgObj()
check(obp and obp[3][1] == 170 and obp[3][2] == 170 and obp[3][3] == 170,
"OBP0 bake puts sheet shade 2 on DMG shade 1 (170)")
local cap = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))
cap = cap and cap[2] -- DMG shade 1 -> 2nd palette entry
check(cap and cap[2] > cap[1] and cap[2] > cap[3],
"OBJ cap color is green-dominant (g>r and g>b)")
"the cap's resolved map colour is green-dominant (g>r and g>b)")
check(cap and not (cap[1] == 165 and cap[2] == 214 and cap[3] == 255),
"OBJ cap color is NOT ROUTE light-blue (165,214,255)")
"the cap's resolved map colour is NOT ROUTE light-blue (165,214,255)")
-- === regression guard: terrain palette untouched =====================
local terrain = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))
@@ -90,7 +144,47 @@ return function(game)
check(hasColor(terrain, 165, 214, 255),
"ROUTE terrain palette still contains light-blue (grass keeps its blue dither)")
-- === ADVANCED (RED++): the same overdraw, over a baked true-colour atlas ==
-- setMode drops every cached Map/TileRenderer and reloads the visible one,
-- so re-read the state's map before touching its renderer.
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
U.wait(20)
ow = game.overworld
check(ow.map.renderer.gbcAtlas ~= nil,
"ADVANCED baked the per-tile GBC atlas for ROUTE_1")
local advGaps = grassGapPixels(ow)
check(advGaps > 0,
"ADVANCED grass feet overdraw shows the sprite through its gaps ("
.. advGaps .. "/128 px)")
-- the gaps must be the SAME pixels the other modes key -- a baked-in green
-- shade 0 is what #150 saw, so the count has to match SGB's exactly
check(advGaps == sgbGaps,
"ADVANCED keys the same shade-0 pixels SGB does (" .. advGaps
.. " vs " .. sgbGaps .. ")")
U.shot(game, DIR .. "/grass_bug150_advanced.png")
-- ROUTE_1 is the OVERWORLD tileset; the other two tilesets that own a grass
-- tile (FOREST $20, PLATEAU $45) file it under the very same pack group 2,
-- so all three baked the same green over shade 0 and all three broke
-- together. One tileset passing proves nothing about the other two.
for _, spot in ipairs({ { "VIRIDIAN_FOREST", 6, 6, "FOREST" },
{ "ROUTE_23", 10, 44, "PLATEAU" } }) do
local id, cx, cy, tsId = spot[1], spot[2], spot[3], spot[4]
U.teleport(game, id, cx, cy, "down")
U.wait(10)
ow = game.overworld
check(ow.map.tileset.id == tsId
and ow.map:isGrassCell(ow.player.cellX, ow.player.cellY),
id .. ": player stands on " .. tsId .. " tall grass")
check(ow.map.renderer.gbcAtlas ~= nil, id .. ": ADVANCED baked its atlas")
local gaps = grassGapPixels(ow)
check(gaps > 0, id .. ": ADVANCED grass feet overdraw shows the sprite "
.. "through its gaps (" .. gaps .. "/128 px)")
end
-- OG RED reference for the human diff (green character over red terrain)
U.teleport(game, "ROUTE_1", 10, 6, "down")
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
U.wait(20)
+1 -1
View File
@@ -1,7 +1,7 @@
-- Driver: gym-leader post-battle dialogue chain (#164).
-- Invokes checkVictoryRewards for Brock then Misty (same path as a win)
-- and screenshots pages that must include badge-effect + TM explanation
-- text not just a synthetic "received badge/TM" stub.
-- text -- not just a synthetic "received badge/TM" stub.
--
-- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love .
return function(game)
+264
View File
@@ -0,0 +1,264 @@
-- Driver: the Pewter JIGGLYPUFF sings AND dances (#249).
-- scripts/PewterPokecenter.asm PewterPokecenterJigglypuffText: stop the music,
-- DelayFrames 32, MUSIC_JIGGLYPUFF_SONG, then a clockwise quarter turn every 24
-- frames until the song ends, 48 more frames, PlayDefaultMusic. TextScriptEnd
-- is the only thing that closes the box. Not under POKEPORT_SPEED.
-- POKEPORT_DRIVER=tests/drivers/jigglypuff_bug249_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local mapScripts = require("data.scripts.init")
local TextBox = require("src.render.TextBox")
local Music = require("src.core.Music")
local MAP = "PEWTER_POKECENTER"
local NPC = "PEWTERPOKECENTER_JIGGLYPUFF"
local TEXT = "TEXT_PEWTERPOKECENTER_JIGGLYPUFF"
local SONG, MAP_SONG = "Music_JigglypuffSong", "Music_Pokecenter"
-- scripts/PewterPokecenter.asm .FacingDirections, in order
local RING = { "down", "left", "up", "right" }
local SILENCE, STEP, TAIL = 32, 24, 48 -- the three DelayFrames counts
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function nextInRing(dir)
for i, d in ipairs(RING) do
if d == dir then return RING[i % #RING + 1] end
end
return nil
end
-- ---- preconditions the eye cannot check --------------------------------
-- A missing handler, text entry or song def all look the same on screen: a
-- JIGGLYPUFF that just stands there.
local handler = mapScripts.talkScript(MAP, TEXT)
check(MAP .. "/" .. TEXT .. " has a talk handler",
type(handler) == "function")
local line = game.data.text and game.data.text._PewterPokecenterJigglypuffText
check("_PewterPokecenterJigglypuffText resolves to a string",
type(line) == "string" and line ~= "")
if type(line) == "string" then
U.log("the box should read:", (line:gsub("\n", " / ")))
end
-- Music.playOnce returns false on a missing def, and the dance then skips
-- straight to its 48-frame tail with no spin at all.
local songs = game.data.audio and game.data.audio.songs
check("audio.songs." .. SONG .. " resolves (PlayMusic MUSIC_JIGGLYPUFF_SONG)",
songs ~= nil and songs[SONG] ~= nil)
check("audio.songs." .. MAP_SONG .. " resolves (PlayDefaultMusic comes back to it)",
songs ~= nil and songs[MAP_SONG] ~= nil)
local mapSong = game.data.audio and game.data.audio.mapSongs
and game.data.audio.mapSongs[MAP]
check("the Center's map theme is " .. MAP_SONG, mapSong == MAP_SONG)
-- SPRITE_FAIRY has to be a walker or three of the four facings have no
-- frames to draw and the "turn" is invisible even when it happens
local fairy = game.data.sprites and game.data.sprites.SPRITE_FAIRY
check("SPRITE_FAIRY renders all four facings",
fairy ~= nil and fairy.walker == true and (fairy.frames or 0) >= 4)
local opts = game.save.options or {}
U.log("audio device present:", love.audio ~= nil,
" MUSIC VOL (0-7):", tostring(opts.musicVol),
" SFX VOL (0-7):", tostring(opts.sfxVol))
if not love.audio or opts.musicVol == 0 then
U.log("WARNING: music output is off, so the cut to silence and the song",
"itself will not be audible; raise MUSIC VOL in OPTION first")
end
-- ---- park the player against the JIGGLYPUFF -----------------------------
-- data/maps/objects/PewterPokecenter.asm: object_event 1, 3, SPRITE_FAIRY.
-- It sits against the west wall, so approach from the floor to its east.
local STAND = { x = 2, y = 3, facing = "left" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local function puffIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == NPC then return n end
end
return nil
end
-- re-reads game.overworld every call: the fallback below teleports again,
-- which rebuilds the state and its npc list
local function facingThePuff()
local ow = game.overworld
local puff = ow and puffIn(ow)
if not puff then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == puff
end
local ow = game.overworld
local puff = ow and puffIn(ow)
check("JIGGLYPUFF object loaded on " .. MAP, puff ~= nil)
if puff and not facingThePuff() then
-- Approach cell is blocked (map edit, or a mod moved the object): take any
-- free neighbour instead. {dx, dy, facing} is the offset from the fairy
-- plus the direction that looks back at it, so +1 on x means facing left.
local sides = {
{ 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = puff.cellX + s[1], puff.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("approach cell (%d, %d) is blocked, standing on")
:format(STAND.x, STAND.y), cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
puff = game.overworld and puffIn(game.overworld)
end
check("player is standing against the JIGGLYPUFF", facingThePuff())
-- ---- one frame, sampling everything at once ----------------------------
local function boxOnTop()
local top = game.stack:top()
if getmetatable(top) == TextBox then return top end
return nil
end
local function sample(pressA)
if pressA then U.tap(game, "a") else U.wait(1) end
local box = boxOnTop()
return {
box = box ~= nil,
typed = box ~= nil and box.done == true,
tick = box ~= nil and box.auto ~= nil and type(box.auto.tick) == "function",
facing = puff and puff.facing or nil,
song = Music.oneShotPlaying(),
}
end
-- ---- 1. talk to it and watch the whole dance, mashing A throughout -----
U.log("--- run 1: the full dance, with A held down on it ---------------")
local before = puff and puff.facing
U.tap(game, "a")
U.wait(2)
local box = boxOnTop()
check("pressing A opened a text box", box ~= nil)
if box then
U.log("the JIGGLYPUFF turned to face the player:",
tostring(before), "->", tostring(puff.facing))
check("the box carries an auto.tick hook (the per-frame dance driver)",
box.auto ~= nil and type(box.auto.tick) == "function")
check("the box is an auto box, so no blinking arrow and no A dismissal",
box.auto ~= nil)
end
-- Mash A every 3rd frame for the whole run; in the original the box cannot
-- be dismissed at all, so this must change nothing.
local log, turns = {}, {}
local typedAt, songAt, songEnd, closedAt = nil, nil, nil, nil
local last = puff and puff.facing
for i = 1, 3000 do
log[i] = sample(i % 3 == 0)
if not typedAt and log[i].typed then typedAt = i end
if not songAt and log[i].song then songAt = i end
if songAt and not songEnd and not log[i].song then songEnd = i end
if log[i].facing and log[i].facing ~= last then
turns[#turns + 1] = { at = i, from = last, to = log[i].facing }
last = log[i].facing
end
if not log[i].box then closedAt = i break end
end
U.log(("frames: text finished typing at %s, song started at %s, song ended " ..
"at %s, box closed at %s")
:format(tostring(typedAt), tostring(songAt), tostring(songEnd),
tostring(closedAt)))
for n, t in ipairs(turns) do
U.log((" turn %d on frame %d: %s -> %s"):format(n, t.at, tostring(t.from),
tostring(t.to)))
end
if check("the song played", songAt ~= nil) then
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic
local gap = songAt - (typedAt or 1)
U.log(("silence between the text finishing and the song starting: %d frames " ..
"(pokered waits %d)"):format(gap, SILENCE))
check(("that silence is about %d frames"):format(SILENCE),
gap >= SILENCE - 4 and gap <= SILENCE + 8)
end
check("the JIGGLYPUFF turned at least four times", #turns >= 4)
local ringOk, spacingOk = true, true
for n, t in ipairs(turns) do
if t.to ~= nextInRing(t.from) then
ringOk = false
U.log((" turn %d is not a clockwise quarter turn: %s -> %s (expected %s)")
:format(n, tostring(t.from), tostring(t.to),
tostring(nextInRing(t.from))))
end
if n > 1 then
local d = t.at - turns[n - 1].at
if d < STEP - 6 or d > STEP + 6 then
spacingOk = false
U.log((" turn %d came %d frames after the last one (expected %d)")
:format(n, d, STEP))
end
end
end
check("every turn is one clockwise quarter turn (DOWN->LEFT->UP->RIGHT)",
ringOk and #turns >= 4)
check(("the turns are %d frames apart"):format(STEP),
spacingOk and #turns >= 2)
-- not just "it eventually closed": an A-dismissable box closes too, only far
-- too early, and that is the bug
check("mashing A never closed the box early",
(closedAt or #log) >= SILENCE + 4 * STEP)
if check("the box closed itself with no button press", closedAt ~= nil) then
check("it stayed up for the whole song, A mashing and all",
songEnd ~= nil and closedAt > songEnd)
if songEnd then
local tail = closedAt - songEnd
U.log(("the box lingered %d frames after the song (pokered waits %d, " ..
"plus the box's own pop delay)"):format(tail, TAIL))
check(("that tail is about %d frames"):format(TAIL),
tail >= TAIL - 6 and tail <= TAIL + 20)
end
end
-- ---- 2. do it again, screenshot each quarter turn, hand off mid-song ---
U.log("--- run 2: screenshots, then the pad is yours -------------------")
U.wait(30)
puff = game.overworld and puffIn(game.overworld)
check("still standing against the JIGGLYPUFF", facingThePuff())
U.tap(game, "a")
U.wait(2)
U.shot(game, DIR .. "/bug249_0_box.png")
local shots, seen = 0, puff and puff.facing
for _ = 1, 600 do
U.wait(1)
if not boxOnTop() then break end
if puff and puff.facing ~= seen then
seen = puff.facing
shots = shots + 1
-- U.shot costs a frame or two, which is why run 1 did the timing
local path = ("%s/bug249_%d_%s.png"):format(DIR, shots, seen)
if U.shot(game, path) then U.log("captured", path) end
if shots >= 4 then break end
end
end
check("captured four quarter turns", shots >= 4)
U.log("The JIGGLYPUFF has been talked to and is mid-song. Mash A: the box has no")
U.log("arrow and must not close. The fairy should turn a quarter turn clockwise")
U.log("(down, left, up, right) every 24 frames until the song ends, then close")
U.log("itself (#249). The Center theme returns just before it does; expected.")
while true do
coroutine.yield()
end
end
+237
View File
@@ -0,0 +1,237 @@
-- Driver: manual audio check for the low-health siren cutting out (#293).
-- pokered sets wLowHealthAlarm on a red bar (core.asm:1858-1875) and only
-- RemoveFaintedPlayerMon (core.asm:1011-1016) clears it, so a sounding siren
-- rides through the next hit. No POKEPORT_SPEED: audio has its own clock.
-- POKEPORT_DRIVER=tests/drivers/lowhp_alarm_bug293_test.lua \
-- POKEPORT_IDENTITY=bug293 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Sound = require("src.core.Sound")
local ALARM = "Low_Health_Alarm"
local function siren() return Sound.isLooping(ALARM) end
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- preconditions the ear cannot check --------------------------------
-- A siren with no source and a siren stopped every frame sound the same.
check("BattleState:lowHealthAlarmActive exists",
type(BattleState.lowHealthAlarmActive) == "function")
check("BattleState:updateFx exists (it is what latches the siren)",
type(BattleState.updateFx) == "function")
-- Sound.startLoop uses the registered sfx def when there is one and
-- otherwise synthesizes the siren; a ROM import has no def, so the synth
-- path is the normal one.
local def = game.data.audio and game.data.audio.sfx
and game.data.audio.sfx[ALARM]
if def then
check("data.audio.sfx." .. ALARM .. " resolves", true)
else
local ok, src = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
check("no " .. ALARM .. " def, so ChipAudio synthesizes the siren",
ok and src ~= nil)
if ok and src and src.stop then pcall(src.stop, src) end
end
local vol = game.save.options and game.save.options.sfxVol
U.log("audio device present:", love.audio ~= nil,
" SFX VOL (0-7):", tostring(vol))
if not love.audio or vol == 0 then
U.log("WARNING: sound output is off, so nothing below will be audible;",
"raise SFX VOL in OPTION first -- a muted run and a dead siren",
"are the same thing to an ear")
end
-- ---- a lead whose bar is red and who cannot end the fight --------------
-- :L20 so a weak foe cannot one-shot it, and TAIL_WHIP only (power 0), so
-- the wild mon survives every turn and keeps hitting back.
local function freshLead()
local mon = Pokemon.new(game.data, "RATTATA", 20)
mon.moves = { { id = "TAIL_WHIP", pp = 30 } }
game.save.party = { mon }
return mon
end
-- put the drawn bar at 9 of 48 px: HP_BAR_RED is < 10 px
-- (GetHealthBarColor, core.asm), the same threshold HudTiles.drawHPBar
-- tints with
local function intoTheRed(mon)
mon.hp = math.max(1, math.floor(mon.stats.hp * 9 / 48))
return math.max(1, math.floor(mon.hp * 48 / math.max(1, mon.stats.hp)))
end
local lead = freshLead()
local px = intoTheRed(lead)
U.log(("lead: RATTATA :L20 %d/%d HP -> %d of 48 px")
:format(lead.hp, lead.stats.hp, px))
check("the lead's bar is red (red is under 10 px)", px < 10)
check("the lead has HP to spare for a non-lethal hit", lead.hp >= 6)
U.teleport(game, "ROUTE_1", 5, 5, "down")
local function sample(battle, pressA)
if pressA then U.tap(game, "a") else U.wait(1) end
return {
on = siren(),
hp = battle.player.mon.hp,
shown = battle.player.shownHP,
text = battle.current and battle.current.text or "",
}
end
-- longest run of consecutive silent frames in [from, to], and where it
-- started: this is the number a human hears as a gap
local function longestGap(log, from, to)
local best, bestAt, run, runAt = 0, nil, 0, nil
for i = from, math.min(to, #log) do
if log[i].on then
run, runAt = 0, nil
else
if run == 0 then runAt = i end
run = run + 1
if run > best then best, bestAt = run, runAt end
end
end
return best, bestAt
end
local function startBattle(species, level, enemyMoves)
local battle = BattleState.newWild(game, species, level)
battle.onFinish = function() end
if enemyMoves then
-- BOTH: the battler's curMoves aliases mon.moves at construction
-- (BattleState.lua:345) and TrainerAI.chooseMove reads curMoves, so
-- replacing only mon.moves leaves the AI on the original moveset
battle.enemy.mon.moves = enemyMoves
battle.enemy.curMoves = enemyMoves
end
game.overworld:pushBattle(battle)
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(4)
end
return battle
end
-- The A presses are folded into the sampling loop rather than done with
-- U.tap first: applyDamage takes the HP off the model at queue-build time,
-- so a faster foe can land its hit within a couple of frames of the move
-- being chosen and a U.tap would sample straight past it.
local function playTurn(battle, maxFrames)
local pre = battle.player.mon.hp
local log, hitAt, emptyAt, settleAt = {}, nil, nil, nil
for i = 1, maxFrames or 900 do
local pressA = i == 1 or i == 6
or (i > 6 and i % 6 == 0 and battle.phase ~= "menu")
log[i] = sample(battle, pressA)
if not hitAt and log[i].hp < pre then hitAt = i end
if hitAt and not emptyAt and (log[i].shown or 0) <= 0 then emptyAt = i end
if hitAt and not settleAt and log[i].shown == log[i].hp then
settleAt = i
end
if settleAt and i > settleAt + 40 then break end
end
return log, pre, hitAt, settleAt, emptyAt
end
-- =====================================================================
-- 1. the non-lethal hit: the siren must ride it out unbroken
-- =====================================================================
U.log("--- battle 1: a hit you survive -------------------------------")
-- PIDGEY :L3 knows only GUST (SAND_ATTACK is not until :L5), accurate and
-- worth a couple of HP against a :L20 lead, so it lands and never kills.
local b1 = startBattle("PIDGEY", 3)
check("reached the action menu", b1.phase == "menu")
check("the siren is sounding before anything happens", siren())
U.shot(game, DIR .. "/bug293_1_menu.png")
local log, pre, hitAt, settleAt = playTurn(b1)
U.log(("turn: %d frames sampled, HP %d -> %d, hit landed on frame %s, ")
:format(#log, pre, b1.player.mon.hp, tostring(hitAt))
.. ("bar settled on frame %s"):format(tostring(settleAt)))
if check("the foe's attack connected", hitAt ~= nil) then
local to = settleAt and (settleAt + 20) or #log
local gap, gapAt = longestGap(log, hitAt, to)
-- the reported symptom, in frames: 60 frames is one second
U.log(("longest silence between the hit and the bar settling: %d frames%s")
:format(gap, gapAt and (" (from frame " .. gapAt .. ")") or ""))
check("the siren never dropped out across the hit (#293)", gap == 0)
if gap > 0 then
U.log(" it went quiet during:", log[gapAt].text ~= "" and
log[gapAt].text:gsub("\n", " / ") or "(no text on screen)")
end
check("and it is still sounding once the bar has settled",
siren() and b1.player.mon.hp > 0)
U.shot(game, DIR .. "/bug293_2_after_hit.png")
end
-- =====================================================================
-- 2. the killing blow: it must hold until the bar has drained empty
-- =====================================================================
U.log("--- battle 2: the hit that kills you --------------------------")
U.teleport(game, "ROUTE_1", 5, 5, "down")
local lead2 = freshLead()
local px2 = intoTheRed(lead2)
check("the lead's bar is red again", px2 < 10)
-- One guaranteed-lethal move instead of the wild AI's random pick: only
-- one of a :L40 PIDGEY's four real moves does any damage.
local b2 = startBattle("PIDGEY", 40, { { id = "WING_ATTACK", pp = 35 } })
check("reached the action menu", b2.phase == "menu")
check("the siren is sounding before the killing blow", siren())
local log2, pre2, hitAt2, settleAt2, emptyAt2 = playTurn(b2)
U.log(("lethal turn: %d frames sampled, HP %d -> %d, hit on frame %s, ")
:format(#log2, pre2, b2.player.mon.hp, tostring(hitAt2))
.. ("bar empty on frame %s"):format(tostring(emptyAt2)))
if check("the killing blow landed", hitAt2 ~= nil and b2.player.mon.hp <= 0) then
local to = emptyAt2 or settleAt2 or #log2
local gap, gapAt = longestGap(log2, hitAt2, to)
U.log(("longest silence between the killing blow and the empty bar: " ..
"%d frames%s"):format(gap, gapAt and (" (from frame " .. gapAt ..
")") or ""))
check("the siren held all the way down to an empty bar (#293)", gap == 0)
if gap > 0 then
U.log(" it went quiet during:", log2[gapAt].text ~= "" and
log2[gapAt].text:gsub("\n", " / ") or "(no text on screen)")
end
if emptyAt2 then
-- and only then does it stop (RemoveFaintedPlayerMon)
local stopped = false
for i = emptyAt2, #log2 do
if not log2[i].on then stopped = true break end
end
check("and went quiet once the bar was empty (RemoveFaintedPlayerMon)",
stopped)
end
U.shot(game, DIR .. "/bug293_3_ko.png")
end
-- =====================================================================
-- 3. hand off, already at the FIGHT menu with the siren going
-- =====================================================================
U.teleport(game, "ROUTE_1", 5, 5, "down")
local lead3 = freshLead()
intoTheRed(lead3)
local b3 = startBattle("PIDGEY", 3)
check("handing off at the action menu", b3.phase == "menu")
check("handing off with the siren sounding", siren())
U.shot(game, DIR .. "/bug293_4_handoff.png")
U.log("FIGHT menu, wild PIDGEY, RATTATA in the red and the siren going.")
U.log("A, A uses TAIL WHIP (power 0), so the PIDGEY keeps hitting back;")
U.log("it takes several turns to die.")
U.log("The siren must not break under the foe's move, the animation or the")
U.log("bar drain, and on the lethal hit it holds to an empty bar (#293).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,99 @@
-- Driver: the seam where the TYPE/PP box meets the move box (#240). pokered
-- MoveSelectionMenu (engine/battle/core.asm:2492-2501) writes '─' at (4,12) and
-- '┘' at (10,12) into the tilemap, REPLACING the tile. Eye check, no SPEED.
-- POKEPORT_DRIVER=tests/drivers/moveselect_border_bug240_test.lua \
-- POKEPORT_IDENTITY=bug240 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Font = require("src.render.Font")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- preconditions -----------------------------------------------------
-- a missing glyph page draws nothing in those cells, which reads as a clean
-- seam and would pass by accident
check("Font.BORDER.h (the '─' patch glyph) is defined",
Font.BORDER ~= nil and Font.BORDER.h ~= nil)
check("Font.BORDER.br (the '┘' patch glyph) is defined",
Font.BORDER ~= nil and Font.BORDER.br ~= nil)
check("Font.drawCode exists (the transparent blit at the heart of this)",
type(Font.drawCode) == "function")
check("Font.drawBox exists (the white fill the patch has to match)",
type(Font.drawBox) == "function")
local extra = love.filesystem.getInfo("assets/generated/fonts/font_extra.png")
check("assets/generated/fonts/font_extra.png is in the cache", extra ~= nil)
game.save.player.name = "bryan"
-- CHARIZARD at 50 knows four moves, so the list fills all four rows and the
-- PP figure sits directly above the (10,12) cell being judged
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
local lead = game.save.party[1]
check("the lead knows at least one move", #lead.moves >= 1)
U.log("move list:", (function()
local names = {}
for _, m in ipairs(lead.moves) do
names[#names + 1] = game.data.moves[m.id].name
end
return table.concat(names, ", ")
end)())
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local function tapUntil(cond, taps, gap)
for _ = 1, (taps or 60) do
if cond() then return true end
U.tap(game, "a")
for _ = 1, (gap or 6) do
if cond() then return true end
U.wait(1)
end
end
return cond()
end
check("reached the FIGHT/PKMN/ITEM/RUN menu",
tapUntil(function() return battle.phase == "menu" end, 60))
check("cursor starts on FIGHT", battle.menuIndex == 1)
-- A on FIGHT opens the move list: this is the screen being judged.
U.tap(game, "a")
U.wait(20)
check("the FIGHT move list is open (#240 lives on this screen)",
battle.phase == "moveSelect")
check("move-list screenshot reached disk",
U.shot(game, DIR .. "/bug240_move_list.png"))
U.log("captured", DIR .. "/bug240_move_list.png")
-- move the cursor down one row too: the PP figure changes, and the '┘'
-- corner under it must stay identical
U.tap(game, "down")
U.wait(20)
check("move-list screenshot on the second move reached disk",
U.shot(game, DIR .. "/bug240_move_list_row2.png"))
U.log("captured", DIR .. "/bug240_move_list_row2.png")
-- ---- hand off ----------------------------------------------------------
U.log("The FIGHT move list is open. Judge tiles (4,12) and (10,12) on the row")
U.log("where the TYPE/PP box meets the move box; (10,12) sits under the PP")
U.log("count. Both want clean border: no black blobs showing through, no")
U.log("white gap, and no change as you move the cursor with UP/DOWN. (#240)")
while true do
coroutine.yield()
end
end
+25 -66
View File
@@ -1,35 +1,9 @@
-- Driver: manual audio check for the silent Pewter NIDORAN (#247).
--
-- pokered scripts/PewterNidoranHouse.asm, PewterNidoranHouseNidoranText:
-- text_far _PewterNidoranHouseNidoranText
-- text_asm
-- ld a, NIDORAN_M
-- call PlayCry
-- call WaitForSoundToFinish
-- jp TextScriptEnd
-- so the box types "NIDORAN: Bowbow!" first and the male NIDORAN cry sounds
-- second, once the typewriter is done. The box then still waits for a
-- button: PewterNidoranHouse_Script is `jp EnableAutoTextBoxDrawing`, and
-- AutoTextBoxDrawingCommon (pokered home/window.asm) zeroes
-- wDoNotWaitForButtonPressAfterDisplayingText, so DisplayTextID falls through
-- to WaitForTextScrollButtonPress like any other NPC line.
-- data/scripts/flavor/pewter_nidoran_house.lua ported the dialogue row but
-- never the cry row, so the NIDORAN was silent.
--
-- This is an audio fix, so nothing here can assert it. What this driver does
-- is check the halves an ear cannot check (the cry row is wired into the talk
-- script in the adjacency Commands.play_cry/show_text require, and the
-- NIDORAN_M cry sample resolves the way Sound.playCry looks it up), park the
-- player one cell below the NIDORAN already facing it, and then hand input
-- straight back so a human hears the cry live.
--
-- Do NOT add POKEPORT_SPEED to this run: fast-forward scales only the logic
-- clock while audio runs on its own real-time 60 Hz accumulator
-- (src/core/Game.lua), so the cry would stop lining up with the box it
-- belongs to.
--
-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua \
-- POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Audio check for the silent Pewter NIDORAN (#247). pokered
-- scripts/PewterNidoranHouse.asm types the line, then `ld a, NIDORAN_M / call
-- PlayCry / call WaitForSoundToFinish`, and the box still waits for a button;
-- the port ported the dialogue row and not the cry row.
-- Don't add POKEPORT_SPEED: audio runs on its own real-time accumulator.
-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local mapScripts = require("data.scripts.init")
@@ -44,11 +18,9 @@ return function(game)
return ok
end
-- ---- preconditions the ear cannot check --------------------------------
-- Commands.play_cry only stashes ctx.pendingCry; Commands.show_text is what
-- turns it into the box's opts.auto. The two rows therefore have to be
-- adjacent and in that order, or the cry is handed to the wrong box or
-- never consumed at all -- and either way the NIDORAN stays quiet.
-- turns it into the box's opts.auto, so the two rows have to be adjacent and
-- in that order or the cry goes to the wrong box, or to none at all
local rows = mapScripts.talkScript(MAP, TEXT)
local cryAt, textAt
for i, row in ipairs(rows or {}) do
@@ -58,16 +30,15 @@ return function(game)
local cry = cryAt and rows[cryAt]
check("talk script carries a play_cry row", cry ~= nil)
check("cry species is " .. SPECIES, cry ~= nil and cry[2] == SPECIES)
-- the third arg is play_cry's waitForButton form, which keeps
-- DisplayTextID's trailing WaitForTextScrollButtonPress instead of letting
-- the box pop itself the instant the cry goes quiet
-- the third arg is play_cry's waitForButton form, which keeps DisplayTextID's
-- trailing WaitForTextScrollButtonPress instead of letting the box pop itself
-- the instant the cry goes quiet
check("cry row keeps the button wait", cry ~= nil and cry[3] == true)
check("play_cry sits immediately before show_text",
cryAt ~= nil and textAt == cryAt + 1)
-- Sound.playCry reads data.audio.cries[species]; a missing or misspelled
-- key is a silent no-op with no error, which is indistinguishable by ear
-- from the bug itself
-- Sound.playCry reads data.audio.cries[species]; a missing or misspelled key
-- is a silent no-op with no error, which sounds exactly like the bug
local cries = game.data.audio and game.data.audio.cries
check("data.audio.cries." .. SPECIES .. " resolves",
cries ~= nil and cries[SPECIES] ~= nil)
@@ -80,10 +51,9 @@ return function(game)
"raise SFX VOL in OPTION first")
end
-- ---- park the player in front of the NIDORAN ---------------------------
-- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on
-- (4, 5) facing LEFT at the little boy on (3, 5), so the free approach cell
-- is the floor directly below it.
-- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on (4, 5)
-- facing LEFT at the little boy on (3, 5), so the free approach cell is the
-- floor directly below it
U.teleport(game, MAP, 4, 6, "up")
U.wait(10)
@@ -94,8 +64,8 @@ return function(game)
return nil
end
-- re-reads game.overworld every call: the fallback below teleports again,
-- which rebuilds the whole state and its npc list
-- re-reads game.overworld every call: the fallback below teleports again and
-- that rebuilds the state and its npc list
local function facingTheMon()
local ow = game.overworld
local mon = ow and nidoranIn(ow)
@@ -109,10 +79,9 @@ return function(game)
check("NIDORAN object loaded on " .. MAP, mon ~= nil)
if mon and not facingTheMon() then
-- the hard-coded approach cell stopped working (map edit, or a mod moved
-- the object): take any free walkable neighbour and turn toward the mon.
-- {dx, dy, facing} is the offset from the mon to the stand cell plus the
-- direction that looks back at it, so +1 on x means facing left.
-- a map edit or a mod moved the object: fall back to any free walkable
-- neighbour. {dx, dy, facing} is the offset from the mon to the stand cell
-- plus the direction that looks back at it, so +1 on x means left.
local sides = {
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
}
@@ -129,20 +98,10 @@ return function(game)
end
check("player is standing in front of the NIDORAN", facingTheMon())
-- ---- hand off, then stay out of the way --------------------------------
U.log("........................................................")
U.log("LISTEN NOW: press A to talk to the NIDORAN in front of you.")
U.log(" RIGHT: the box types out \"NIDORAN: Bowbow!\", the male NIDORAN")
U.log(" cry sounds ONCE after the last character lands, then the")
U.log(" arrow blinks and the box waits for your A or B.")
U.log(" BUG #247 sounds like: the line types out and then nothing, dead")
U.log(" silence under the house music, arrow blinking.")
U.log(" ALSO WRONG: the cry fires while the text is still typing (row")
U.log(" order broken), or the box closes itself the moment the cry")
U.log(" ends with no button press (waitForButton lost).")
U.log("Input is yours from here on -- talk again as often as you like, and")
U.log("the little boy on the left is the silent control to compare against.")
U.log("........................................................")
U.log("Press A to talk to the NIDORAN in front of you.")
U.log("The box types \"NIDORAN: Bowbow!\", then the cry sounds once after the")
U.log("last character lands and the box waits for A or B; under #247 the line")
U.log("typed and nothing followed. The boy on the left is the silent control.")
while true do
coroutine.yield()
+144
View File
@@ -0,0 +1,144 @@
-- Driver: play a real online match, hosting side.
--
-- Pair with tests/drivers/online_match_join.lua in a second instance:
--
-- POKEPORT_IDENTITY=pokehost ONLINE_CODE_FILE=/tmp/poke_code.txt \
-- POKEPORT_DRIVER=tests/drivers/online_match_host.lua love .
-- POKEPORT_IDENTITY=pokeguest ONLINE_CODE_FILE=/tmp/poke_code.txt \
-- POKEPORT_DRIVER=tests/drivers/online_match_join.lua love .
--
-- Two real windows, the real relay (POKEPORT_RELAY_ADDR to point elsewhere),
-- the real LinkState menus and the real lockstep battle -- the loopback
-- suites can't see anything the transport, the two separate processes or
-- the two save identities contribute. The host writes its room code to
-- ONLINE_CODE_FILE for the joiner to pick up, since on a real screen that
-- code is read aloud to a friend.
--
-- Prints ONLINE_MATCH_HOST: lines; the wrapper script greps them.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local LinkState = require("src.link.LinkState")
local Runtime = require("src.mods.Runtime")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt"
local TAG = "ONLINE_MATCH_HOST:"
local function log(...) U.log(TAG, ...) end
-- a desync is the whole point of the exercise, so make it loud
local desyncs = {}
-- wrap emit rather than subscribing: with no mods loaded Runtime.events is
-- the null sink, and a vanilla online run is exactly the case under test
local realEmit = Runtime.emit
Runtime.emit = function(name, p)
if name == "link.desync" and p then
desyncs[#desyncs + 1] = p
log(("DESYNC turn=%s component=%s fatal=%s"):format(
tostring(p.turn), tostring(p.component), tostring(p.fatal)))
end
return realEmit(name, p)
end
game.save.player.name = "HOST"
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "SNORLAX", 50),
Pokemon.new(game.data, "ALAKAZAM", 50),
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(30)
local link = LinkState.new(game)
game.stack:push(link)
U.wait(10)
-- the GAME SPEED forcing under test: a link session pins the logic clock
-- to 1X no matter what the option or POKEPORT_SPEED says
game.save.options.speed = 10
U.wait(2)
log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed())
-- top menu: LAN / ONLINE MATCH / TOURNAMENT
U.tap(game, "down"); U.wait(3)
U.tap(game, "a"); U.wait(5) -- ONLINE MATCH
if link.stage ~= "onlineMenu" then
log("FAIL expected onlineMenu, at", tostring(link.stage))
-- a mods-enabled build lands on the vanilla-restart prompt instead
U.wait(60)
U.shot(game, DIR .. "/online_host_blocked.png")
return
end
U.tap(game, "a"); U.wait(10) -- HOST ONLINE
-- wait for the relay to hand back a room code
local code
for _ = 1, 900 do
if link.net and link.net.code then code = link.net.code break end
U.wait(1)
end
if not code then
log("FAIL no room code from the relay:", tostring(link.net and link.net.error))
U.shot(game, DIR .. "/online_host_nocode.png")
return
end
log("hosting code", code)
U.shot(game, DIR .. "/online_host_1_code.png")
local f = io.open(CODE_FILE, "w")
if f then f:write(code) f:close() end
-- wait for the joiner
for _ = 1, 1800 do
if link.stage == "modeSelect" then break end
U.wait(1)
end
if link.stage ~= "modeSelect" then
log("FAIL nobody joined (stage " .. tostring(link.stage) .. ")")
return
end
log("paired with", tostring(link.peerName))
U.shot(game, DIR .. "/online_host_2_paired.png")
U.tap(game, "down"); U.wait(3) -- TRADE / BATTLE -> BATTLE
U.tap(game, "a"); U.wait(10)
-- host owns the level rule; ANY is the default row
for _ = 1, 600 do
if link.stage == "battleOptions" then break end
U.wait(1)
end
if link.stage == "battleOptions" then
U.shot(game, DIR .. "/online_host_3_options.png")
U.tap(game, "a"); U.wait(5)
end
-- the battle: mash A, exactly like a player who just wants it over with
local battle
for _ = 1, 1800 do
local top = game.stack:top()
if top and top.kind == "link" then battle = top break end
U.wait(1)
end
if not battle then
log("FAIL battle never started (stage " .. tostring(link.stage) .. ")")
U.shot(game, DIR .. "/online_host_nobattle.png")
return
end
log("battle started vs", tostring(battle.opponentName))
U.shot(game, DIR .. "/online_host_4_battle.png")
local shots = 0
for i = 1, 200000 do
if battle.result then break end
U.tap(game, "a")
if i % 900 == 0 and shots < 3 then
shots = shots + 1
U.shot(game, DIR .. ("/online_host_5_turn%d.png"):format(shots))
end
end
U.wait(60)
U.shot(game, DIR .. "/online_host_6_result.png")
log("result", tostring(battle.result), "turns", tostring(battle.turnCount))
log("desyncs", #desyncs)
log("DONE")
end
+129
View File
@@ -0,0 +1,129 @@
-- Driver: play a real online match, joining side.
-- See tests/drivers/online_match_host.lua for how to run the pair.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local LinkState = require("src.link.LinkState")
local CodeEntry = require("src.link.CodeEntry")
local Runtime = require("src.mods.Runtime")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt"
local TAG = "ONLINE_MATCH_JOIN:"
local function log(...) U.log(TAG, ...) end
local desyncs = {}
-- wrap emit rather than subscribing: with no mods loaded Runtime.events is
-- the null sink, and a vanilla online run is exactly the case under test
local realEmit = Runtime.emit
Runtime.emit = function(name, p)
if name == "link.desync" and p then
desyncs[#desyncs + 1] = p
log(("DESYNC turn=%s component=%s fatal=%s"):format(
tostring(p.turn), tostring(p.component), tostring(p.fatal)))
end
return realEmit(name, p)
end
game.save.player.name = "GUEST"
game.save.party = {
Pokemon.new(game.data, "BLASTOISE", 50),
Pokemon.new(game.data, "GENGAR", 50),
Pokemon.new(game.data, "DRAGONITE", 50),
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(30)
-- the host writes its room code out once the relay assigns one
local code
for _ = 1, 3600 do
local f = io.open(CODE_FILE, "r")
if f then
local text = (f:read("*a") or ""):gsub("%s+", "")
f:close()
if #text == CodeEntry.LENGTH then code = text break end
end
U.wait(1)
end
if not code then
log("FAIL host never published a code")
return
end
log("joining code", code)
local link = LinkState.new(game)
game.stack:push(link)
U.wait(10)
game.save.options.speed = 20
U.wait(2)
log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed())
log("before nav: stage", tostring(link.stage), "index", tostring(link.index),
"top", tostring(game.stack:top() == link), "mods", #require("src.link.Handshake").mods(game))
U.tap(game, "down"); U.wait(3)
log("after down: stage", tostring(link.stage), "index", tostring(link.index))
U.tap(game, "a"); U.wait(5) -- ONLINE MATCH
log("after a: stage", tostring(link.stage), "index", tostring(link.index))
if link.stage ~= "onlineMenu" then
log("FAIL expected onlineMenu, at", tostring(link.stage))
U.wait(60)
U.shot(game, DIR .. "/online_join_blocked.png")
return
end
U.tap(game, "down"); U.wait(3)
U.tap(game, "a"); U.wait(5) -- JOIN ONLINE -> code entry
if link.stage ~= "codeEntry" then
log("FAIL expected codeEntry, at", tostring(link.stage))
return
end
-- set the six slots straight rather than scrubbing each one with UP
-- presses; the scrub interaction has its own coverage (online_play_test)
for i = 1, CodeEntry.LENGTH do
local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true)
link.codeEntry.chars[i] = idx or 1
end
U.wait(2)
U.shot(game, DIR .. "/online_join_1_code.png")
U.tap(game, "a"); U.wait(10)
for _ = 1, 1800 do
if link.stage == "waitMode" or link.stage == "battleWait" then break end
if link.net and link.net.error then
log("FAIL join error:", tostring(link.net.error))
return
end
U.wait(1)
end
log("connected, stage", tostring(link.stage))
U.shot(game, DIR .. "/online_join_2_connected.png")
local battle
for _ = 1, 1800 do
local top = game.stack:top()
if top and top.kind == "link" then battle = top break end
U.wait(1)
end
if not battle then
log("FAIL battle never started (stage " .. tostring(link.stage) .. ")")
U.shot(game, DIR .. "/online_join_nobattle.png")
return
end
log("battle started vs", tostring(battle.opponentName))
U.shot(game, DIR .. "/online_join_3_battle.png")
local shots = 0
for i = 1, 200000 do
if battle.result then break end
U.tap(game, "a")
if i % 900 == 0 and shots < 3 then
shots = shots + 1
U.shot(game, DIR .. ("/online_join_4_turn%d.png"):format(shots))
end
end
U.wait(60)
U.shot(game, DIR .. "/online_join_5_result.png")
log("result", tostring(battle.result), "turns", tostring(battle.turnCount))
log("desyncs", #desyncs)
log("DONE")
end
+12 -49
View File
@@ -1,29 +1,8 @@
-- Driver: party menu cursor alignment (#278). A manual eye check, not a
-- pass/fail run -- no assertion in this repo can judge where a triangle
-- sits against a reference screenshot.
--
-- pokered evidence. home/pokemon.asm PartyMenuInit seeds the shared menu
-- cursor coordinates:
--
-- ld hl, wTopMenuItemY
-- inc a ; a = 1
-- ld [hli], a ; top menu item Y
-- xor a
-- ld [hli], a ; top menu item X
--
-- and home/window.asm PlaceMenuCursor walks that many rows down from
-- hlcoord 0, 0. Meanwhile party_menu.asm RedrawPartyMenu_ starts the name
-- column at hlcoord 3, 0. So a party entry's name is on tile row 0 while
-- its cursor belongs on tile row 1: the level/HP line, level with the
-- middle of the two-row icon.
--
-- The bug: PartyMenu drew the cursor at entryY(i), the name row, putting it
-- a full tile (8px) too high on every slot. The fix draws it at y + 8.
--
-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic
-- clock while rendering and audio run on their own real-time accumulators
-- (src/core/Game.lua), so a sped-up run can capture a half-drawn frame.
--
-- Driver: party menu cursor alignment (#278). Eye check, not pass/fail.
-- pokered home/pokemon.asm PartyMenuInit seeds wTopMenuItemY = 1 while
-- party_menu.asm RedrawPartyMenu_ starts the name column at hlcoord 3, 0,
-- so the cursor belongs on an entry's second row (level/HP), not its name
-- row. No POKEPORT_SPEED: rendering runs on its own real-time clock.
-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug278_test.lua POKEPORT_IDENTITY=bug278 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -36,12 +15,8 @@ return function(game)
return ok
end
-- ---- preconditions a human's eye cannot separate from the bug ----------
-- A cursor drawn off-screen, or a party too short to show the stride,
-- both look exactly like "the offset is wrong". Check them first.
-- the geometry contract the fix depends on: 16px stride, name row at the
-- top of each entry. If entryY ever changes, the +8 has to be revisited.
-- the geometry the +8 depends on: 16px stride, name row at the top of
-- each entry. If entryY changes, the cursor offset has to be revisited.
check("entryY stride is 16px", PartyMenu.entryY(2) - PartyMenu.entryY(1) == 16)
check("slot 1 name row is y=0", PartyMenu.entryY(1) == 0)
@@ -54,7 +29,6 @@ return function(game)
game.save.player.name = "bryan"
check("party has enough slots to judge the stride", #game.save.party >= 3)
-- the window actually rendered: a black frame is not an offset bug
check("renderer is up", game.renderer ~= nil)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
@@ -66,8 +40,7 @@ return function(game)
U.shot(game, "bug278_party_cursor_slot1.png")
-- move the cursor down so the stride is visible too: a fix that is right
-- on slot 1 and wrong further down would otherwise read as a pass
-- step down as well, so a stride bug cannot hide behind a correct slot 1
U.tap(game, "down")
U.wait(15)
U.shot(game, "bug278_party_cursor_slot2.png")
@@ -75,20 +48,10 @@ return function(game)
U.wait(15)
U.shot(game, "bug278_party_cursor_slot3.png")
U.log("........................................................")
U.log("LOOK NOW: the party menu is open. Screenshots were written to the")
U.log(" LOVE save dir as bug278_party_cursor_slot1/2/3.png.")
U.log(" RIGHT: the black triangle sits on the LOWER of each entry's two")
U.log(" rows, level with the LEVEL/HP line and with the middle of")
U.log(" the two-row party icon.")
U.log(" BUG #278 looks like: the triangle riding up on the NAME row, its")
U.log(" tip level with the first letter of the nickname.")
U.log(" ALSO WRONG: correct on slot 1 but drifting on slots 2-4 (that is")
U.log(" a stride bug, not an offset bug), or the triangle sliding")
U.log(" a half tile so it straddles both rows.")
U.log("Compare against the reference shot in issue #278. Input is yours")
U.log("from here: up/down re-checks every slot, B closes the menu.")
U.log("........................................................")
U.log("Party menu is open; shots are in the LOVE save dir as")
U.log("bug278_party_cursor_slot1/2/3.png. The cursor should sit on the")
U.log("lower row of each entry, level with LEVEL/HP, not up on the name")
U.log("row (#278). Up/down re-checks every slot, B closes the menu.")
while true do
coroutine.yield()
+278
View File
@@ -0,0 +1,278 @@
-- Driver: watch a POTION fill the party menu's HP bar (#252).
-- pokered engine/items/item_effects.asm .doneHealing runs UpdateHPBar2 with
-- the party menu still drawn; status cures branch off and never touch the bar.
-- Not under POKEPORT_SPEED: SFX_HEAL_HP rides the real-time audio clock.
-- POKEPORT_DRIVER=tests/drivers/party_heal_bug252_test.lua \
-- POKEPORT_IDENTITY=bug252 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local ItemEffects = require("src.inventory.ItemEffects")
local PartyMenu = require("src.ui.PartyMenu")
local TextBox = require("src.render.TextBox")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function top() return game.stack:top() end
local function isPicker(s)
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
end
local function isBox(s) return getmetatable(s) == TextBox end
local function inStack(pred)
for _, s in ipairs(game.stack.states or {}) do
if pred(s) then return true end
end
return false
end
-- ---- preconditions ------------------------------------------------------
-- A lost item id, a healsHP gate that dropped an item, or a use() that stops
-- handing back the pre-heal HP all read as "the animation is broken".
U.log("======== #252 party-menu HP fill: machine checks ========")
check("ItemEffects.healsHP exists", type(ItemEffects.healsHP) == "function")
check("PartyMenu:animateTo exists", type(PartyMenu.animateTo) == "function")
check("PartyMenu:close exists", type(PartyMenu.close) == "function")
if type(ItemEffects.healsHP) == "function" then
-- .doneHealing is reached by exactly the .healHP items; FULL_HEAL and the
-- single-status cures jump to the no-bar branch
for _, id in ipairs({ "POTION", "SUPER_POTION", "HYPER_POTION",
"MAX_POTION", "FULL_RESTORE", "REVIVE",
"MAX_REVIVE" }) do
check(id .. " takes the .healHP (animated) path",
ItemEffects.healsHP(id) == true)
end
for _, id in ipairs({ "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL",
"ICE_HEAL", "FULL_HEAL", "ETHER" }) do
check(id .. " does NOT animate the bar (status cure / PP)",
ItemEffects.healsHP(id) ~= true)
end
end
for _, id in ipairs({ "POTION", "MAX_POTION", "REVIVE", "ANTIDOTE" }) do
check(id .. " resolves in the item table", game.data.items[id] ~= nil)
end
-- use() must hand back the PRE-heal HP so the fill has a start (wHPBarOldHP)
do
local scratch = Pokemon.new(game.data, "BULBASAUR", 20)
scratch.hp = 3
local scratchSave = { party = { scratch }, inventory = {}, flags = {},
pokedex = { seen = {}, owned = {} } }
local result, msgs, extra = ItemEffects.use(game.data, scratchSave,
"POTION", scratch)
check("POTION on a hurt mon reports consumed", result == "consumed")
check("...and hands back extra.healedFrom = the pre-heal HP",
type(extra) == "table" and extra.healedFrom == 3)
check("...with the restored-HP message",
type(msgs) == "table" and type(msgs[1]) == "string"
and msgs[1]:find("restored", 1, true) ~= nil)
local _, _, cureExtra = ItemEffects.use(game.data, scratchSave,
"ANTIDOTE", scratch)
check("ANTIDOTE hands back no healedFrom (no fill)",
cureExtra == nil or cureExtra.healedFrom == nil)
end
-- ---- the fixture --------------------------------------------------------
-- CHARIZARD L50 sits near 150 max HP, so a MAX_POTION from 1 HP is the full
-- 96-frame fill across the whole 48-pixel bar.
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
local fainted = Pokemon.new(game.data, "PIKACHU", 30)
local poisoned = Pokemon.new(game.data, "SNORLAX", 40)
lead.hp = 1
fainted.hp = 0
poisoned.status = "PSN"
game.save.party = { lead, fainted, poisoned }
game.save.player.name = "RED"
for _, row in ipairs({ { "MAX_POTION", 9 }, { "POTION", 9 },
{ "REVIVE", 9 }, { "ANTIDOTE", 9 } }) do
Bag.add(game.save, row[1], row[2])
end
U.log(("lead: %s %d/%d HP"):format(lead.species, lead.hp, lead.stats.hp))
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- ---- menu navigation ---------------------------------------------------
local function cursorTo(menu, want)
for _ = 1, 40 do
if not menu or menu.index == want then return menu and menu.index == want end
U.tap(game, menu.index < want and "down" or "up")
U.wait(3)
end
return menu.index == want
end
-- START -> ITEM -> <id> -> USE, leaving the party picker open. Returns the
-- picker, or nil plus a reason.
local function openPickerFor(id)
U.tap(game, "start")
U.wait(10)
local menu = top()
if not (menu and menu.screenId == "StartMenu") then
return nil, "start menu never opened"
end
-- the ITEM row shifts with POKéDEX / LINK / MODS, so never hardcode it
local itemRow
for i, it in ipairs(menu.items or {}) do
if it.label == "ITEM" then itemRow = i break end
end
if not itemRow or not cursorTo(menu, itemRow) then return nil, "no ITEM row" end
U.tap(game, "a")
U.wait(10)
local bag = top()
if not (bag and bag.screenId == "BagMenu") then return nil, "bag never opened" end
local bagRow
for i, r in ipairs(bag.items or {}) do
if r.value == id then bagRow = i break end
end
if not bagRow or not cursorTo(bag, bagRow) then return nil, id .. " not in bag" end
U.tap(game, "a")
U.wait(10)
-- outside battle every usable item offers USE / TOSS first; USE is row 1
local ut = top()
if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then
if not cursorTo(ut, 1) then return nil, "USE row unreachable" end
U.tap(game, "a")
U.wait(10)
end
local picker = top()
if not isPicker(picker) then return nil, "party picker never opened" end
return picker
end
local function backToOverworld()
for _ = 1, 30 do
if top() == game.overworld then return true end
U.tap(game, "b")
U.wait(6)
end
return top() == game.overworld
end
-- ======== scripted run: MAX_POTION on the 1 HP lead ======================
U.log("======== #252 scripted run: MAX POTION on a 1 HP CHARIZARD ========")
local picker, why = openPickerFor("MAX_POTION")
check("party picker opened for MAX POTION" .. (why and (" (" .. why .. ")") or ""),
picker ~= nil)
if picker then
check("cursor sits on the hurt lead", cursorTo(picker, 1))
U.shot(game, DIR .. "/bug252_picker_before.png")
local hpBefore = lead.hp
U.tap(game, "a") -- choose the lead
-- THE DEFECT: the picker used to pop here, before the item had even run.
check("the party menu is STILL the top state after the A press",
isPicker(top()))
check("the fill is running (picker.heal is set)",
type(picker.heal) == "table")
if type(picker.heal) == "table" then
check("the fill starts from the pre-heal HP (wHPBarOldHP)",
math.floor(picker.heal.shown + 0.5) == hpBefore)
end
-- Sample the climb and prove input is ignored for its duration
-- (UpdateHPBar2 blocks). U.frame() is the real yield count: the taps below
-- each burn a frame, so an iteration counter would under-report.
local startFrame, samples, blocked = U.frame(), {}, true
local iter, shot1, shot2 = 0, false, false
for _ = 1, 400 do
if not picker.heal then break end
local shown = picker.heal.shown
samples[#samples + 1] = shown
local frac = shown / math.max(1, lead.stats.hp)
if not shot1 and frac > 0.33 then
shot1 = true
U.shot(game, DIR .. "/bug252_fill_third.png")
elseif not shot2 and frac > 0.66 then
shot2 = true
U.shot(game, DIR .. "/bug252_fill_two_thirds.png")
else
-- mash B and A: neither may do anything while the bar is filling
U.tap(game, (iter % 2 == 0) and "b" or "a")
end
if picker.heal and not isPicker(top()) then blocked = false end
iter = iter + 1
U.wait(1)
end
local frames = U.frame() - startFrame
U.log(("fill ran ~%d frames (%.2f s at 60 Hz)"):format(frames, frames / 60))
check("the fill took more than half a second (it animates, not snaps)",
frames > 30)
check("the fill is not absurdly long (< 3 s)", frames < 180)
check("A and B did nothing while the bar filled", blocked)
local rose = #samples >= 2 and samples[#samples] > samples[1]
check("the drawn HP climbed over those frames", rose)
if #samples >= 2 then
U.log(("shown HP walked %.1f -> %.1f of %d")
:format(samples[1], samples[#samples], lead.stats.hp))
end
check("the mon really is at full HP now", lead.hp == lead.stats.hp)
-- .showHealingItemMessage: the message prints with the menu still drawn
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
check("the message box opened", isBox(top()))
check("...over the STILL-drawn party menu", inStack(isPicker))
U.wait(60) -- let the line type out, so the shot shows the text not an empty box
U.shot(game, DIR .. "/bug252_message_over_party.png")
-- TextBox pops BEFORE it fires onDone, which is what makes
-- PartyMenu:close's identity check land on the picker
for _ = 1, 30 do
if not inStack(isPicker) then break end
U.tap(game, "a")
U.wait(8)
end
check("the picker is gone once the message is dismissed", not inStack(isPicker))
local back = top()
check("and we are back on the ITEM list", back ~= nil and back.screenId == "BagMenu")
U.shot(game, DIR .. "/bug252_back_on_bag.png")
end
backToOverworld()
-- ======== contrast: ANTIDOTE must NOT animate ===========================
U.log("======== #252 contrast: ANTIDOTE (no bar fill at all) ========")
local cure = openPickerFor("ANTIDOTE")
if check("party picker opened for ANTIDOTE", cure ~= nil) then
check("keepOpen is off for a status cure", cure.keepOpen ~= true)
cursorTo(cure, 3) -- the poisoned SNORLAX
U.tap(game, "a")
U.wait(6)
check("no fill was started for a status cure", cure.heal == nil)
check("the picker popped itself, like every non-medicine item",
not inStack(isPicker))
U.shot(game, DIR .. "/bug252_antidote_message.png")
check("PSN was cured", poisoned.status == nil)
end
backToOverworld()
-- ---- verdict, then re-arm and hand off ----------------------------------
U.log(("======== machine checks: %d passed, %d failed ========"):format(pass, fail))
lead.hp = 1
fainted.hp = 0
poisoned.status = "PSN"
local rearmed = openPickerFor("MAX_POTION")
if rearmed then cursorTo(rearmed, 1) end
U.log("The bag, USE and the party picker are re-opened with the cursor on a")
U.log("1 HP CHARIZARD and a MAX POTION chosen. Press A, watch slot 1's bar:")
U.log("the list stays up, the bar lengthens over ~1.5s with the number, and")
U.log("buttons do nothing until it lands. #252 was the list snapping shut.")
U.log("Spare items are in the bag if you want to run it again.")
while true do
coroutine.yield()
end
end

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