mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-24 22:46:09 +02:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 493fca7297 | |||
| 7d5856f93b | |||
| ef43814efc | |||
| bed1062a5f | |||
| 190c03cfca | |||
| cec264ad81 | |||
| 041051e154 | |||
| 92fd4221a0 | |||
| 07c828cab0 | |||
| b89771508e | |||
| 2c1c2fe080 | |||
| 6f4c59df8b | |||
| 0d14980205 | |||
| 1bd6e35931 | |||
| da73a4fe25 | |||
| cd4ef14f13 | |||
| d575a64287 | |||
| ff826ce01e | |||
| 0ab4ef2755 | |||
| 6f343d9b29 | |||
| 41790637f0 | |||
| 4ab0ac433b | |||
| 44c304574b | |||
| c8b05e7aa1 | |||
| 8c6c360ce0 | |||
| 6b6b8f46a1 | |||
| 2d62805128 | |||
| d27c59b18d | |||
| 1905261c5b | |||
| 333949ce0d | |||
| fb95dc5b6a | |||
| 96ed862b50 | |||
| 58681fe62e | |||
| 2ad2e028d1 | |||
| 654650e3ff | |||
| fad7443f94 | |||
| f895293217 | |||
| ebf44f20d3 | |||
| ca4d3d283c | |||
| 5807b8d836 | |||
| b550db3abb | |||
| 01c5eb2189 | |||
| dd0f0e0892 | |||
| d17f0725d8 | |||
| 3b7fc42858 | |||
| c9221daa90 | |||
| 0147e3d87b | |||
| fb2cebd54f | |||
| cb504e5328 | |||
| bdbdbf2ed6 | |||
| 7ae469603c | |||
| 77e189bc2e | |||
| e88f2ef060 | |||
| 16eefcde68 | |||
| 6d55da0ed2 | |||
| 745374d8ae | |||
| 3470e3c4ae | |||
| f76a8b2f96 | |||
| 24a7ff86d4 | |||
| c2e1d810f9 | |||
| 6d6ccb5778 | |||
| d54f9a02e0 | |||
| e094b73536 | |||
| 6e7b9c0848 |
+16
-14
@@ -140,8 +140,8 @@ ship text.
|
||||
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
||||
overworld, script VM and save format. The mod API is shared across both
|
||||
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
|
||||
engine, overworld, script VM and save format. The mod API is shared across both
|
||||
generations (same hook names, same event names, same registry names) but Gen 2
|
||||
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||
|
||||
@@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||
```
|
||||
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
||||
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
|
||||
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
|
||||
the key for you. The mod still installs to one directory, `mods/<id>/`, shared
|
||||
by every game -- targeting is declared, never filed.
|
||||
|
||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||
have actually run your mod on Gold or Silver.
|
||||
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold, Silver or Crystal.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||
@@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written
|
||||
before the key existed changes behavior; list both generations or say `"all"`
|
||||
when you mean everywhere.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
|
||||
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
|
||||
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
|
||||
and drop their writes with a report, and which hooks and events are still to
|
||||
come.
|
||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||
Gen 1 mod, and it is the one to start from.
|
||||
|
||||
|
||||
@@ -56,14 +56,16 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
||||
Silver ROM is the only game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||
Silver, or Crystal ROM is the only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
|
||||
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
|
||||
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
|
||||
construction, and Crystal is the newest of the three, so the launcher lists it
|
||||
as Crystal (Beta).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -71,14 +73,17 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
|
||||
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
|
||||
game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||
- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
||||
- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -122,24 +127,23 @@ supported out of the box.
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt),
|
||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||
your tilt/zoom preferences without forgetting them. Details in
|
||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
### Rulesets
|
||||
@@ -219,7 +223,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC"
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
|
||||
+3
-13
@@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC"
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
@@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_silver.json"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_crystal.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
@@ -228,11 +230,6 @@ 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
|
||||
@@ -323,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||
|
||||
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
|
||||
|
||||
@@ -66,6 +66,12 @@ function love.conf(t)
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
|
||||
-- independent of t.accelerometerjoystick below (which instead maps the
|
||||
-- accelerometer onto joystick axes and stays off -- see that flag's
|
||||
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
|
||||
-- reflective-screen look) has sensor data available without reviving #468.
|
||||
t.modules.sensor = true
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
|
||||
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
|
||||
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
||||
},
|
||||
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
|
||||
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
|
||||
-- port's talk pipeline, so it's dropped like other cry-only lines).
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
|
||||
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
|
||||
TEXT_POKEMONFANCLUB_PIKACHU = {
|
||||
{ "show_text", "_PokemonFanClubPikachuText" },
|
||||
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
|
||||
},
|
||||
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
|
||||
-- itself, flavor line only.
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
|
||||
-- shape with SEEL, PlayCry at :89.
|
||||
TEXT_POKEMONFANCLUB_SEEL = {
|
||||
{ "show_text", "_PokemonFanClubSeelText" },
|
||||
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
|
||||
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
|
||||
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
|
||||
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
|
||||
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
|
||||
return {
|
||||
SS_ANNE_1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
||||
{"face_player"},
|
||||
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
|
||||
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
|
||||
-- (cry playback has no equivalent Commands.lua verb in this port, so only
|
||||
-- the flavor text is ported).
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
|
||||
return {
|
||||
SS_ANNE_B1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
||||
{ "face_player" },
|
||||
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
|
||||
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,10 +18,11 @@ return {
|
||||
|
||||
-- VermilionCityMachopText: cries out, then follows up with a
|
||||
-- second line about stomping the land flat.
|
||||
-- (pokered/scripts/VermilionCity.asm)
|
||||
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
|
||||
TEXT_VERMILIONCITY_MACHOP = {
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 1
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
|
||||
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 2
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
|
||||
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
|
||||
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
|
||||
-- TextScriptEnd. This port has no cry-playback command, so only the
|
||||
-- flavor line is ported.
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
|
||||
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
|
||||
|
||||
return {
|
||||
VERMILION_PIDGEY_HOUSE = {
|
||||
talk = {
|
||||
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
||||
{"face_player"},
|
||||
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
|
||||
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
||||
},
|
||||
},
|
||||
|
||||
+36
-22
@@ -381,22 +381,35 @@ M.VERMILION_CITY = {
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||
-- flashing the ticket just lets you through -- he never hides, and
|
||||
-- once the ship has sailed he only reports it gone
|
||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
{ "jump", 12 }, -- 8
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||
},
|
||||
-- scripts/VermilionCity.asm:158 (#1651)
|
||||
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
done))
|
||||
return
|
||||
end
|
||||
-- scripts/VermilionCity.asm:195
|
||||
local p = ow and ow.player
|
||||
if not p or p.facing == "right"
|
||||
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1WelcomeToSSAnneText
|
||||
or "Welcome to S.S.\nANNE!", done))
|
||||
return
|
||||
end
|
||||
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
|
||||
and (t._VermilionCitySailor1FlashedTicketText
|
||||
or "{PLAYER} flashed\nthe S.S.TICKET!")
|
||||
or (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard.")
|
||||
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
|
||||
TEXT_SSANNE2F_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "jump", 10 }, -- 9 (already beaten: silent)
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 9 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text or {}
|
||||
local p = ow and ow.player
|
||||
-- scripts/Museum1F.asm:45 (#1690)
|
||||
if p and ((p.cellY == 4 and p.cellX == 13)
|
||||
or (p.cellY == 3 and p.cellX == 12)) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1DoYouKnowWhatAmberIsText
|
||||
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
|
||||
nil, { choice = function(yes)
|
||||
game.stack:push(TextBox.new(game, yes
|
||||
and (t._Museum1FScientist1TheresALabSomewhereText
|
||||
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
|
||||
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
|
||||
or "AMBER is fossil-\nized tree sap."), done))
|
||||
end }))
|
||||
return
|
||||
end
|
||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1TakePlentyOfTimeText
|
||||
or "Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:58
|
||||
if p and p.cellY ~= 4 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1GoToOtherSideText
|
||||
or "Please go to the\nother side!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:72
|
||||
local money = function() return game.save.money end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
+74
-79
@@ -2,6 +2,8 @@
|
||||
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
||||
-- and the Hall of Fame record. Each cites its pokered source.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -9,20 +11,30 @@ local M = {}
|
||||
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", flag }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "ask", askText }, -- 4
|
||||
{ "jump_if_false", 10 }, -- 5
|
||||
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
|
||||
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
|
||||
refusedText, followText)
|
||||
local rows = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", flag },
|
||||
{ "jump_if_true", "already_got" },
|
||||
{ "ask", askText },
|
||||
{ "jump_if_false", "refused" },
|
||||
-- give-then-print like the three rod-house scripts (GiveItem fills
|
||||
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
||||
{ "give_item", rodItem, 1, false }, -- 6
|
||||
{ "show_text", receivedText }, -- 7
|
||||
{ "set_flag", flag }, -- 8
|
||||
{ "jump", 10 }, -- 9 is below
|
||||
{ "give_item", rodItem, 1, false },
|
||||
{ "set_flag", flag },
|
||||
{ "show_text", receivedText },
|
||||
}
|
||||
if followText then rows[#rows + 1] = { "show_text", followText } end
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "refused" }
|
||||
rows[#rows + 1] = { "show_text", refusedText }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "already_got" }
|
||||
rows[#rows + 1] = { "show_text", afterText }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.VERMILION_OLD_ROD_HOUSE = {
|
||||
@@ -31,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
|
||||
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
||||
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
||||
"OLD_ROD", "EVENT_GOT_OLD_ROD"),
|
||||
"OLD_ROD", "EVENT_GOT_OLD_ROD",
|
||||
"_VermilionOldRodHouseFishingGuruThatsSoDisappointingText",
|
||||
"_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"),
|
||||
},
|
||||
}
|
||||
M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" }
|
||||
|
||||
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
talk = {
|
||||
@@ -43,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
"_FuchsiaGoodRodHouseFishingGuruText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD"),
|
||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD",
|
||||
"_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"),
|
||||
},
|
||||
}
|
||||
M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" }
|
||||
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
talk = {
|
||||
@@ -55,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
||||
"_Route12SuperRodHouseFishingGuruTryFishingText",
|
||||
"SUPER_ROD", "EVENT_GOT_SUPER_ROD"),
|
||||
"SUPER_ROD", "EVENT_GOT_SUPER_ROD",
|
||||
"_Route12SuperRodHouseFishingGuruThatsDisappointingText",
|
||||
"_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"),
|
||||
},
|
||||
}
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||
@@ -893,81 +904,65 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||
-- and the open-water ids of the rows she sits in
|
||||
local DOCK_SHIP_COLUMNS = {
|
||||
{ bx = 5, top = 4, bottom = 8 },
|
||||
{ bx = 6, top = 5, bottom = 9 },
|
||||
{ bx = 7, top = 6, bottom = 10 },
|
||||
{ bx = 8, top = 7, bottom = 11 },
|
||||
}
|
||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
local f = game.save.flags
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
-- the ship is long gone: erase her right away, and anyone who
|
||||
-- still lands here is sent back out past the guard
|
||||
-- still lands here is sent back out past the guard unless a mod
|
||||
-- explicitly permits this occupied map state. This hook surrounds
|
||||
-- only the ejection decision; map-script registration and dispatch
|
||||
-- stay unchanged, and the departed ship remains erased.
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
ow.map:setBlock(b.bx, b.by, b.water)
|
||||
end
|
||||
ow.map.renderer:rebuild()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
local occupancyAllowed = false
|
||||
if Runtime.wantsHook("map.occupancy_allowed") then
|
||||
local player = ow.player or {}
|
||||
occupancyAllowed = Runtime.call("map.occupancy_allowed",
|
||||
function() return false end, game, {
|
||||
mapId = "VERMILION_DOCK",
|
||||
reason = "ss_anne_departed",
|
||||
gameVersion = game.save and game.save.version,
|
||||
x = player.cellX,
|
||||
y = player.cellY,
|
||||
}) == true
|
||||
end
|
||||
if not occupancyAllowed then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
end
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||
-- drift off the funnel, the horn blows, the ship is erased to
|
||||
-- open water, and the player is walked off the dock into the
|
||||
-- city past the guard (VermilionCity's
|
||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local Music = require("src.core.Music")
|
||||
Music.stop()
|
||||
Music.play(game.data, "Music_Surfing")
|
||||
local function puff(n, cx)
|
||||
if n <= 0 then return end
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||
-- rSCX split; with no split scroll here she sails one block per beat
|
||||
-- and the water closes in astern (#360)
|
||||
for step = 1, 8 do
|
||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||
setBlock(col.bx - step, 1, col.top)
|
||||
setBlock(col.bx - step, 2, col.bottom)
|
||||
end
|
||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
end
|
||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||
-- frames before the walk out
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
ow:queueScript({
|
||||
-- scripts/VermilionDock.asm:50 zeroes the player image index and
|
||||
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
|
||||
{ "face_player_dir", "down" },
|
||||
{ "wait", 120 },
|
||||
{ "play_sound", "SS_Anne_Horn" },
|
||||
-- scripts/VermilionDock.asm:80 .shift_columns_up
|
||||
{ "ss_anne_departs" },
|
||||
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
|
||||
{ "play_sound", "SS_Anne_Horn" },
|
||||
{ "wait", 120 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
{ "warp", "VERMILION_CITY", 18, 31, "up" },
|
||||
{ "move_player", "up", 2 },
|
||||
})
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -922,10 +922,12 @@ M.SS_ANNE_2F = {
|
||||
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||
|
||||
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
|
||||
## 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.
|
||||
|
||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||
|
||||
@@ -55,10 +55,10 @@ The short version, for an author deciding what to write:
|
||||
```
|
||||
|
||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gen2"` now expands to both Gold and Silver.
|
||||
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
|
||||
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
|
||||
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
|
||||
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
loader's gate reads.
|
||||
@@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
|
||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
||||
Generation-agnostic; nothing to adapt.
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
|
||||
`mod.developer` is the same fixed boot-time boolean on both generations and is
|
||||
available while the entry chunk runs. Gold does not gain Gen 1's developer
|
||||
console or F5 hot-reload hotkey; the field reports the loader's mode only.
|
||||
|
||||
**`mod.world`.** Same method set, resolved against Gold's world
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
@@ -573,7 +576,7 @@ gains a field instead of the name gaining a prefix.
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
composed frame before ShaderFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
@@ -783,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
||||
|
||||
The list is much shorter than it was. What is outstanding, in descending value:
|
||||
|
||||
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
|
||||
processing. Gold already has a native weather/between-turn pipeline but does
|
||||
not yet expose the shared data-only descriptor hook.
|
||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||
|
||||
+203
-5
@@ -228,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
### Active Gen 1 block checks
|
||||
|
||||
Red, Blue, and Yellow expose
|
||||
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
|
||||
ID at one zero-based block coordinate only when `mapId` is the active map.
|
||||
The value is a scalar snapshot: changing it cannot change the map. This lets a
|
||||
mod compare a small runtime map signature before it applies a lawful authored
|
||||
replacement, without reading the mutable map or ROM cache through engine
|
||||
internals.
|
||||
|
||||
The method fails closed. Before an overworld exists it returns
|
||||
`nil, "no overworld"`; for a different active map it returns
|
||||
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
|
||||
return `nil, "invalid block coordinates"`; and negative or out-of-range
|
||||
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
|
||||
or malformed active block returns `nil, "block unavailable"`. The caller must
|
||||
require every expected cell to match before changing presentation. This method
|
||||
is Gen 1-only; Gold callers receive no parity promise for it.
|
||||
|
||||
The same unavailable result covers missing or sparse active block storage and
|
||||
an accessor result that does not match its validated active block slot.
|
||||
|
||||
### Conditional map occupancy
|
||||
|
||||
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
|
||||
decision to eject the player from an otherwise valid loaded map. Its first
|
||||
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
|
||||
been erased when the hook runs, and the hook does not replace or suppress any
|
||||
base or peer map handler.
|
||||
|
||||
The wrapper receives `(next, game, context)`. The dock context is a copied
|
||||
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
|
||||
record. Vanilla returns `false`. Return exactly `true` to allow the player to
|
||||
remain; every other value denies occupancy and preserves the normal message
|
||||
and warp. A composable wrapper calls downstream first and only adds its own
|
||||
permission:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
|
||||
local allowed = next(game, ctx)
|
||||
local mine = ctx.mapId == "VERMILION_DOCK"
|
||||
and ctx.reason == "ss_anne_departed"
|
||||
and myPublicEligibilityCheck(game)
|
||||
return allowed == true or mine == true
|
||||
end)
|
||||
```
|
||||
|
||||
With no wrapper, the hook allocates no context and vanilla behavior is
|
||||
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
|
||||
string, number, table, or other malformed final answer fails closed. Disabling
|
||||
or uninstalling the permitting mod therefore restores vanilla ejection without
|
||||
changing the S.S. Anne story flag or restoring the ship.
|
||||
|
||||
Normal hook-chain ownership applies: a wrapper that does not call `next`
|
||||
intentionally owns the final answer and does not run lower-priority wrappers.
|
||||
Permission wrappers must call `next` as shown above to compose. A noncompliant
|
||||
wrapper that returns false without calling `next` safely denies occupancy and
|
||||
can suppress downstream permission by this standard rule. A malformed answer
|
||||
also fails closed and cannot force occupancy.
|
||||
|
||||
## Party ordering
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
@@ -265,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker:
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
Fly town list. Gold does not expose these two methods yet.
|
||||
|
||||
## Self-driven world actors
|
||||
|
||||
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
|
||||
overworld's scripted-movement list. A non-empty list is how the overworld
|
||||
knows a cutscene is running, so it gates player input for as long as the
|
||||
actor walks -- right for Oak marching to his lab, wrong for an actor that
|
||||
moves on its own schedule (a networked player's ghost, an ambient walker).
|
||||
Five handle methods drive one without that lockout:
|
||||
|
||||
```lua
|
||||
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
|
||||
sprite = "SPRITE_RED" })
|
||||
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
|
||||
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
|
||||
ghost:setPassable(true) -- walk-through
|
||||
```
|
||||
|
||||
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
|
||||
queue. It deliberately does **not** check collision: a caller replaying a
|
||||
move that was already decided elsewhere (validated on a peer's machine, or
|
||||
authored) would let the two copies disagree about where the actor is if this
|
||||
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
|
||||
`placeAt(x, y, facing)` snaps with no animation and clears any step in
|
||||
flight, for a warp arrival or a resync too far gone to walk off.
|
||||
`isMoving()` lets a driver pace itself instead of stomping a move already
|
||||
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
|
||||
engine's own user is Yellow's companion Pikachu); a passable object still
|
||||
draws and can still be talked to.
|
||||
|
||||
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
|
||||
has nothing to say for it. The **`world.talk`** hook is the A press on an
|
||||
object, raised before the map's text tables get it:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||
if mine(target) then
|
||||
say(target) -- the mod answers for an object it owns
|
||||
return -- ...by not calling next()
|
||||
end
|
||||
return next(ow, target) -- everything else falls through unchanged
|
||||
end)
|
||||
```
|
||||
|
||||
With no subscriber the A press reaches `talkTo` exactly as before. An object
|
||||
mid-step raises no hook, matching the vanilla gate.
|
||||
|
||||
## Adopting an already-paired link session
|
||||
|
||||
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
|
||||
session on a transport that is *already* paired, skipping the address/code
|
||||
entry UI while keeping the hello and fingerprint compatibility exchange
|
||||
intact. `transport` is anything `Session` accepts, which is what lets a mode
|
||||
tunnel a battle through its own connection rather than opening a second one.
|
||||
|
||||
When the battle finishes, **`link.battle_ended`** reports the outcome:
|
||||
|
||||
```lua
|
||||
mod.events:on("link.battle_ended", function(ev)
|
||||
-- ev = { result, myParty, theirParty, peerName, role }
|
||||
end)
|
||||
```
|
||||
|
||||
The party copies are the point. Cable rules leave the real party untouched,
|
||||
so a mode built on link battles -- a tournament ladder, a battle royale --
|
||||
has no other way to learn what the fight cost, and by the time the state
|
||||
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
|
||||
|
||||
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
|
||||
is the title screen's NEW GAME closure made callable, with `opts.intro =
|
||||
false` to land straight in the world -- a mode that hands out its own starting
|
||||
state has no use for Oak's speech. `CodeEntry.new` takes an optional
|
||||
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
|
||||
can also carry a room code or an address.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
@@ -687,15 +821,38 @@ the hook context.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
||||
`--developer` on the command line:
|
||||
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
|
||||
on the command line:
|
||||
|
||||
```sh
|
||||
love . --developer
|
||||
```
|
||||
|
||||
While developer mode is active:
|
||||
The mod loader independently derives a matching boolean for every sandboxed
|
||||
entry chunk as `mod.developer`. It is available while the entry file is
|
||||
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
|
||||
out of player builds:
|
||||
|
||||
```lua
|
||||
if mod.developer then
|
||||
mod.commands:register("my_mod:diagnostics", function(ctx)
|
||||
-- open or print this mod's diagnostic view
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
`mod.developer` is a plain boolean snapshot for this boot. It grants no
|
||||
permission and exposes neither the process environment nor the loader. In a
|
||||
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
|
||||
`true`. On Gen 1 those inputs separately enable the console and hot-reload
|
||||
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
|
||||
signal and diagnostics; it does not enable the game's console or hot reload.
|
||||
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
|
||||
console or hotkeys. Use a mod option for player-facing feature toggles rather
|
||||
than treating developer mode as configuration.
|
||||
|
||||
While developer mode is active on Gen 1:
|
||||
|
||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||
@@ -806,7 +963,7 @@ contract, so a mod does not need a desktop-specific rendering path.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
layers. It runs after registered present pipelines and before ShaderFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
@@ -857,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber.
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
## Battle field residual hook
|
||||
|
||||
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
|
||||
damage without mutating live battlers. It is guarded and runs after vanilla
|
||||
status residuals, before field-token expiry and `battle.turn_ended`. The
|
||||
wrapper receives `(next, context)`, calls `next(context)` for the existing
|
||||
descriptor list, and appends data-only rows:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||
local rows = next(context)
|
||||
rows[#rows + 1] = {
|
||||
side = "enemy", amount = 7,
|
||||
message = context.battlers.enemy.name .. " is buffeted!",
|
||||
}
|
||||
return rows
|
||||
end)
|
||||
```
|
||||
|
||||
`context.field` is a detached, data-only view with the same
|
||||
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
|
||||
`field.sides` or any live battler aliases. The projection recursively retains
|
||||
raw tables and finite numbers, strings, and booleans under scalar keys. It
|
||||
strips metatables and omits functions, userdata, threads, unsupported keys, and
|
||||
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
|
||||
even if a live field token uses one internally, and changing any nested view
|
||||
value cannot change live field state. `context.battlers.player` and `.enemy` are
|
||||
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
|
||||
is the current turn number. A descriptor accepts `side`
|
||||
(`player` or `enemy`), a positive, finite integer number `amount`, and an
|
||||
optional string `message`.
|
||||
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
|
||||
The engine retains HP-bar, faint, experience, and replacement authority. If
|
||||
both active battlers take terminal residual damage together and the player has
|
||||
no healthy reserve, this hook batch queues only the player faint authority and
|
||||
resolves as a blackout loss without an enemy-faint EXP award or replacement.
|
||||
That precedence is local to accepted rows from this hook; native faint paths
|
||||
are unchanged when no hook is active. Hook callbacks remain process-local;
|
||||
checkpoints serialize only field data. Gold does not yet raise this hook; its
|
||||
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
|
||||
|
||||
## Process-lifecycle hooks
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
|
||||
@@ -15,9 +15,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
|
||||
* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded
|
||||
* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
|
||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
|
||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
||||
|
||||
| token | means |
|
||||
| --- | --- |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||
| `"all"` | every game this engine has |
|
||||
|
||||
@@ -768,7 +768,8 @@ the same:
|
||||
So: read the log for coverage problems, and the manager for load problems.
|
||||
|
||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
||||
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
|
||||
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
|
||||
|
||||
## What this guide does not promise
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# RFC 0013: Conditional map occupancy and active-block reads
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
|
||||
S.S. Anne has departed. A content mod can add a city-side route back to that
|
||||
empty harbor, but it cannot preserve the dock visit: replacing the complete
|
||||
dock handler would discard vanilla behavior and peer handlers, while an added
|
||||
handler cannot cancel the base handler's ejection.
|
||||
|
||||
A mod that changes one active map block also needs to prove that it is looking
|
||||
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
|
||||
intentionally presentation-oriented and does not expose block identity.
|
||||
Requiring internal `Map` state or generated ROM data would cross the public mod
|
||||
boundary and make a wrong-version edit difficult to fail closed.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
|
||||
ordinary hook composition remains the authority, an empty hook chain is a
|
||||
provable no-op, and mods receive copied or scalar data rather than mutable
|
||||
engine state. It supports the approved Mew-under-the-truck implementation plan
|
||||
without adding any Mew-specific rule, asset, flag, or content to the engine.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### `map.occupancy_allowed`
|
||||
|
||||
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
|
||||
ship blocks with water and immediately before it would display the departure
|
||||
message and warp the player to Vermilion City.
|
||||
|
||||
A wrapper has this shape:
|
||||
|
||||
```lua
|
||||
function(next, game, context) -> boolean
|
||||
```
|
||||
|
||||
The context is a new table with these fields:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `mapId` | `"VERMILION_DOCK"` at this call site |
|
||||
| `reason` | Stable reason key `"ss_anne_departed"` |
|
||||
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
|
||||
| `x`, `y` | Current player cell coordinates when present |
|
||||
|
||||
Vanilla returns `false`. The player remains only when the final chain result is
|
||||
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
|
||||
ejection. A wrapper composes by calling `next(game, context)` and returning
|
||||
true when either downstream or its own narrow rule permits occupancy. The hook
|
||||
does not replace `MapScripts` registration, merging, or dispatch, and does not
|
||||
change the departure flag or reconstruct the ship.
|
||||
|
||||
As with every wrapper hook, a callback that does not call `next` intentionally
|
||||
owns the final answer and does not run lower-priority callbacks. Permission
|
||||
wrappers must call downstream to compose. A false, non-forwarding wrapper
|
||||
safely denies occupancy and can suppress downstream permission by this normal
|
||||
rule. A malformed final result also fails closed and cannot permit occupancy.
|
||||
|
||||
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
|
||||
context and follows the prior branch exactly.
|
||||
|
||||
### `WorldAPI:activeBlockAt`
|
||||
|
||||
Gen 1's public `mod.world` facade adds:
|
||||
|
||||
```lua
|
||||
activeBlockAt(mapId, blockX, blockY) -> blockId
|
||||
| nil, reason
|
||||
```
|
||||
|
||||
`mapId` must equal the active map ID. Coordinates are finite, integral,
|
||||
zero-based block coordinates. A successful result is a numeric scalar copied
|
||||
from the active runtime map. The method never returns the map's mutable block
|
||||
array and never writes game or save state.
|
||||
|
||||
Failure reasons are stable:
|
||||
|
||||
| Condition | Reason |
|
||||
|---|---|
|
||||
| No active overworld map | `no overworld` |
|
||||
| `mapId` differs from the active map | `map is not active` |
|
||||
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
|
||||
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
|
||||
| Active block data is absent or malformed | `block unavailable` |
|
||||
|
||||
The block slot and the active map accessor must both contain the same valid
|
||||
nonnegative integer. Missing or sparse storage and inconsistent accessor data
|
||||
return `block unavailable`.
|
||||
|
||||
Requiring the expected map ID and rejecting all ambiguous input lets a mod
|
||||
compare every cell in its version-specific signature before it calls an
|
||||
existing mutation API. Red, Blue, and Yellow each use their own loaded map
|
||||
data. Gold does not gain this method in this RFC.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. No hook, event, registry, manifest field, save
|
||||
field, map handler, or WorldAPI method is removed or renamed. With no hook
|
||||
subscriber the departed-dock behavior is unchanged. Existing callers cannot
|
||||
invoke the new WorldAPI method accidentally.
|
||||
|
||||
The occupancy answer is not persisted by the engine. Disabling or uninstalling
|
||||
a mod removes its wrapper through normal owner cleanup, so a later dock entry
|
||||
uses vanilla ejection. The engine writes no new save state and neither API
|
||||
returns or serializes ROM or save data.
|
||||
|
||||
## Verification requirements
|
||||
|
||||
The parity gate must prove:
|
||||
|
||||
- vanilla departed-dock message and warp remain with no subscriber;
|
||||
- one permission wrapper can allow occupancy without removing the ship-erasure
|
||||
work or any map handler;
|
||||
- multiple cooperative wrappers preserve downstream permission;
|
||||
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
|
||||
closed according to the normal wrapper-chain rule;
|
||||
- Red, Blue, and Yellow contexts keep their version identity separate;
|
||||
- disabling or removing the owner restores vanilla behavior without a save
|
||||
migration;
|
||||
- `activeBlockAt` accepts only the active map and valid in-range integral
|
||||
coordinates, returns a scalar, and never mutates map or save state; and
|
||||
- the test fixtures and resulting changes contain no ROM or save payload.
|
||||
|
||||
The hook must be driven through a real public `hooks:wrap` chain. The block API
|
||||
must be exercised through a public `WorldAPI` instance. Tests must not replace
|
||||
the production map handler with a test-only implementation.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/modding.md` documents both contracts, their failure behavior, the
|
||||
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
|
||||
schema changes occur, so generated registry documentation is unchanged.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, superseded, or deprecated.
|
||||
@@ -0,0 +1,158 @@
|
||||
# RFC 0014: Mod-driven world actors and adopted link sessions
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
|
||||
already start a link battle. It cannot make either of them behave like
|
||||
something the mod itself owns.
|
||||
|
||||
Three walls, each of which stops a mode rather than inconveniencing it:
|
||||
|
||||
**An actor cannot move on its own schedule.** The only public way to animate a
|
||||
spawned object is `Handle:scriptMove`, which queues onto
|
||||
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
|
||||
knows a cutscene is running -- `handleInput` gates on it -- so anything
|
||||
animated that way freezes the player's controls for as long as it walks. That
|
||||
is correct for Oak marching into his lab and wrong for an ambient walker or a
|
||||
networked player's ghost, which move continuously and must not lock anyone out.
|
||||
|
||||
**A spawned object cannot answer the A press.** Talking is resolved from the
|
||||
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
|
||||
path has nothing to say for it, and the mod that created it has no way to say
|
||||
anything either.
|
||||
|
||||
**A link battle cannot ride a connection the mod already has.** `LinkState`
|
||||
owns pairing, so a mode that already has a socket to its peer must either open
|
||||
a second connection for the battle or reimplement lockstep. And when the
|
||||
battle ends, cable rules leave the real party untouched, so the damage exists
|
||||
only in the battle's own copies -- which are gone by the time the state
|
||||
unwinds. A mode built on link battles cannot learn what the fight cost.
|
||||
|
||||
The immediate consumer is an overworld multiplayer mode, but nothing here is
|
||||
specific to it: the first two are wanted by any mod with an actor that moves
|
||||
itself, and the third by any mode that runs battles over its own transport --
|
||||
a tournament ladder, a draft, a gauntlet.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
This adds nothing to the compatibility surface's shape; it extends the
|
||||
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
|
||||
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
|
||||
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
|
||||
|
||||
There is no in-repo D-number registry to amend; the consuming design lives
|
||||
outside this repository, as it did for RFC 0011.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `world.talk`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||
-- ow = the OverworldState raising it
|
||||
-- target = the object on the faced cell
|
||||
if mine(target) then
|
||||
say(target)
|
||||
return -- the mod answered; the text path is skipped
|
||||
end
|
||||
return next(ow, target) -- anything else falls through unchanged
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `OverworldState:interact`, on the branch that has already resolved
|
||||
an object on the faced cell (including across a counter) and confirmed it is
|
||||
not mid-step and not the Pikachu follower. It runs before the map's text
|
||||
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
|
||||
fallthrough, which is a file-local function rather than a per-press closure,
|
||||
so an unhooked A press allocates nothing it did not allocate before.
|
||||
|
||||
### New event: `link.battle_ended`
|
||||
|
||||
```lua
|
||||
mod.events:on("link.battle_ended", function(ev)
|
||||
-- ev = {
|
||||
-- result = "win" | "lose" | "draw" | "ended",
|
||||
-- myParty = lockstep copy of our party,
|
||||
-- theirParty = lockstep copy of theirs,
|
||||
-- peerName = string,
|
||||
-- role = "host" | "guest",
|
||||
-- }
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
|
||||
state has been popped and before `exitWith` unwinds the link stack. Guarded by
|
||||
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
|
||||
is allocated and the branch runs exactly as before.
|
||||
|
||||
The party copies are the point of the event. They are the lockstep records the
|
||||
battle actually fought with, which is where the damage lives under cable rules.
|
||||
|
||||
### New `WorldAPI` handle methods
|
||||
|
||||
```lua
|
||||
handle:stepNow(dir) --> true when the step started
|
||||
handle:canStep(dir) --> would stepNow land somewhere legal?
|
||||
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
|
||||
handle:isMoving() --> true while a step is animating
|
||||
handle:setPassable(flag) --> may the player walk through this object?
|
||||
```
|
||||
|
||||
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
|
||||
therefore without the input lockout. It does **not** consult collision: the
|
||||
intended caller is replaying a move already decided elsewhere (validated on a
|
||||
peer, or authored), and re-judging it locally would let two copies of the same
|
||||
actor disagree about where it is. `canStep` is the separate opinion for callers
|
||||
that want it. `setPassable` sets the flag `Collision.occupied` already honours
|
||||
for Yellow's companion Pikachu; a passable object still draws and still talks.
|
||||
|
||||
### Two supporting changes
|
||||
|
||||
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
|
||||
with `opts.intro = false` to land directly in the world. A mode that issues its
|
||||
own starting state has no use for the intro cutscene.
|
||||
|
||||
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
|
||||
slot-scrub widget that enters a link code can also carry a room code or a
|
||||
host:port address. Called with no argument it is byte-for-byte the previous
|
||||
widget.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
**Existing mods change nothing.** Every item above is a new name or a new
|
||||
optional argument; no existing name, payload, signature, or default changes.
|
||||
|
||||
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
|
||||
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
|
||||
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
|
||||
`tests/run_modkit.lua` proves on every run.
|
||||
|
||||
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
|
||||
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
|
||||
without building an event payload, and no handle method is reachable unless a
|
||||
mod calls it.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
|
||||
path. It asserts the unhooked build first (the A press reaches `talkTo`),
|
||||
then that a mod owning the object suppresses the text path by not calling
|
||||
`next`, that an object the mod ignores still falls through, and that an
|
||||
object mid-step raises no hook at all.
|
||||
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
|
||||
path. It asserts that with nothing subscribed the event is not wanted (so no
|
||||
payload is built) and the battle still unwinds, then that a subscriber
|
||||
receives the result, the role, the peer name and both party copies including
|
||||
the damage the real party never took, from both sides of the cable.
|
||||
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
|
||||
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
|
||||
passes 208/208 with both names covered and no DEBT entry added.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded or deprecated.
|
||||
@@ -0,0 +1,123 @@
|
||||
# RFC 0016: Engine-owned field residual descriptors
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Battle-rule mods can keep deterministic data-only state in the public battle
|
||||
field and observe `battle.turn_ended`, but that event fires after the engine's
|
||||
residual and faint pipeline. A listener cannot safely deal end-of-round field
|
||||
damage: directly changing live HP bypasses bar drains, faint messages,
|
||||
experience, replacements, double-faint resolution, and checkpoint continuation.
|
||||
Putting callbacks into `battle.field` is also rejected by the checkpoint
|
||||
serializer, correctly, because executable state is not save-safe.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-004: public engine-owned field residual execution**, the
|
||||
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
|
||||
RESIDUALS`. The plan is
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 8. The engine delta defines only a generic end-of-round extension point;
|
||||
it contains no weather names, immunities, damage formula, trainer identity, or
|
||||
Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Add the guarded Gen 1 hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||
local rows = next(context)
|
||||
rows[#rows + 1] = {
|
||||
side = "enemy",
|
||||
amount = 7,
|
||||
message = context.battlers.enemy.name .. " is buffeted!",
|
||||
}
|
||||
return rows
|
||||
end)
|
||||
```
|
||||
|
||||
The hook runs once during an undecided battle's end-of-round processing, after
|
||||
vanilla status residuals and before field/side token expiry and
|
||||
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
|
||||
changes nothing. Vanilla contributes an empty list.
|
||||
|
||||
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
|
||||
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
|
||||
recursive projection retains raw tables and finite numbers, strings, and
|
||||
booleans under scalar keys. It strips metatables and omits functions, userdata,
|
||||
threads, unsupported keys, and cyclic edges. Thus it exposes neither
|
||||
`field.sides` nor a graph or executable callback back into live engine state.
|
||||
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
|
||||
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
|
||||
change the live battle. `turn` is the current Gen 1 turn counter.
|
||||
|
||||
A result row is `{ side = "player"|"enemy", amount =
|
||||
positive_finite_integer_number, message = optional_string }`. Numeric strings,
|
||||
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
|
||||
messages fail closed. Damage is clamped to current HP. The engine owns
|
||||
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
|
||||
accepted row before scheduling newly fainted battlers. If this hook batch
|
||||
terminally faints the player with no healthy reserve, it queues only the player
|
||||
faint authority, so descriptor order cannot race a blackout against an enemy
|
||||
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
|
||||
player/enemy order. This precedence is scoped to this hook response; native
|
||||
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
|
||||
Wrappers compose by calling `next(context)` and appending their own rows.
|
||||
Callbacks are not part of the descriptor contract and hook functions are never
|
||||
stored in the battle.
|
||||
|
||||
Gold already owns native weather and a generation-specific between-turn order;
|
||||
this first additive call site is Gen 1-only. A future Gold site must keep the
|
||||
same context and descriptor contract and choose its native ordering explicitly.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. No hook name or payload changes. With no wrapper,
|
||||
Gen 1 performs the same residual, token, event, and faint work as before,
|
||||
including native simultaneous-faint resolution, and allocates no context. Gen
|
||||
2 is unchanged. Existing and new checkpoints keep
|
||||
serializing only the data stored in `battle.field`; hook callbacks remain
|
||||
process-local loader state and are never serialized.
|
||||
|
||||
The v1 surface remains unchanged: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
|
||||
`pokemon.before_give` keep their existing behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- The catalog hook parity gate proves null and live-empty buses return the
|
||||
vanilla list unchanged.
|
||||
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
|
||||
detached checkpoint-shaped context, applies damage, and reaches the engine
|
||||
faint pipeline.
|
||||
- Engine validation tests cover strict number validation (including numeric
|
||||
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
|
||||
omission of functions/userdata/threads/cycles and metatables from the public
|
||||
field projection, optional messages, non-table results, clamping, and
|
||||
settled-battle suppression.
|
||||
- Both descriptor orders are driven through queue completion for a simultaneous
|
||||
terminal residual. Each proves player blackout loss with no EXP event,
|
||||
enemy replacement, or replacement UI.
|
||||
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
|
||||
context construction. An ordering probe proves the enabled hook runs after
|
||||
vanilla status residuals and before token expiry and `battle.turn_ended`.
|
||||
- A no-hook native regression proves a simultaneous zero-HP state still enters
|
||||
the pre-existing enemy-faint EXP and win authority outside this hook batch.
|
||||
- Capture/restore/capture evidence proves checkpointed field state round-trips
|
||||
while the enabled process-local hook remains installed and callable.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
|
||||
validation, simultaneous-terminal result, and checkpoint boundary.
|
||||
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
|
||||
No registry or schema changes are involved, so generated registry docs do not
|
||||
change.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The hook is additive.
|
||||
@@ -0,0 +1,102 @@
|
||||
# RFC 0017: Public mod developer-mode signal
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
The loader already derives a boot-time developer-mode flag for its permission
|
||||
diagnostics and headless test seam. Gen 1's `Game` independently derives a
|
||||
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
|
||||
read either one. `mod.commands` can register a diagnostic command but cannot
|
||||
say whether the current boot is a developer boot. `mod.exports` only publishes
|
||||
values to other mods. `game.ready` fires after entry registration and carries
|
||||
only the game. `mod.options` is player configuration, not engine mode, and
|
||||
`mod.log` logs unconditionally. The pre-sandbox compatibility
|
||||
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
|
||||
environment is hidden from mods.
|
||||
|
||||
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
|
||||
Phase H require trainer, boss, Rival, and League diagnostic views plus
|
||||
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
|
||||
public signal, the mod must either ship those registrations in production,
|
||||
misuse a player option, or import loader/Logger internals. All three violate
|
||||
the approved observability boundary or the sandbox/public-API policy.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
|
||||
the engine's developer-mode decision**. The consuming design is tracked in the
|
||||
Adaptive Trainers implementation plan,
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 9. The engine delta is generic and contains no trainer, balancing,
|
||||
diagnostic-layout, seed-label, or Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Every sandboxed mod object adds one field:
|
||||
|
||||
```lua
|
||||
mod.developer -- boolean
|
||||
```
|
||||
|
||||
The loader copies its existing `dev` decision into this field before invoking
|
||||
the mod's entry chunk. It is therefore available for load-time registration:
|
||||
|
||||
```lua
|
||||
if mod.developer then
|
||||
mod.commands:register("my_mod:diagnostics", diagnostics_command)
|
||||
end
|
||||
```
|
||||
|
||||
The value is a plain boolean snapshot, not a loader reference or environment
|
||||
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
|
||||
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
|
||||
separately make `Game`'s own developer flag true for its console and hot
|
||||
reload. The loader's existing injected `opts.dev` test seam changes only the
|
||||
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
|
||||
and does not expose environment variables. The answer is fixed for the life of
|
||||
that loader; changing a field on a mod's own table cannot change engine mode.
|
||||
|
||||
The field is generation-independent and has identical semantics on Red, Blue,
|
||||
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
|
||||
or hot-reload hotkeys from this field.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. `mod.developer` is additive, requires no
|
||||
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
|
||||
entry chunks receive one extra scalar field and retain all prior fields and
|
||||
methods unchanged. No name is removed or shadowed.
|
||||
|
||||
With no mods installed, `Loader:_api` is never called, so the delta allocates no
|
||||
mod object and changes no data, save, options, event, hook, command, or file.
|
||||
With mods installed in a normal boot, the new field is `false` unless an author
|
||||
explicitly reads it. Existing registration and logging behavior is unchanged.
|
||||
|
||||
An adopting mod should gate developer-only registrations and verbose logging
|
||||
directly on `mod.developer`. Player-facing behavior belongs behind
|
||||
`mod.options`, not this signal.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
|
||||
the public SDK with developer mode both on and off. It proves the boolean is
|
||||
available during entry execution and that the same source registers its
|
||||
diagnostic command only for the developer load. It also covers the
|
||||
command-line global path and a Gen 2 load.
|
||||
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
|
||||
parity suite. It proves both developer answers discover no mods, create no
|
||||
files, and leave injected vanilla data unchanged. It also loads an unchanged
|
||||
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
|
||||
- The full ROM-free engine and modkit tiers remain the compatibility proof for
|
||||
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
|
||||
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
|
||||
|
||||
No registry or schema changes are involved, so generated registry documentation
|
||||
is unaffected.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded, or deprecated.
|
||||
@@ -0,0 +1,673 @@
|
||||
# ShaderFX: runtime slang shader presets
|
||||
|
||||
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
|
||||
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
|
||||
a picker: any preset the player drops in a folder, or any preset pulled from
|
||||
the RetroArch buildbot, can be selected and run. Engine:
|
||||
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
|
||||
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
|
||||
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
|
||||
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
|
||||
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
|
||||
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
|
||||
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
|
||||
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
|
||||
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
|
||||
|
||||
This is first-party engine code, not a mod. It calls the native translator
|
||||
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
|
||||
declaration, and no mod boundary. Mods do not get that, and the distinction is
|
||||
deliberate: the upstream maintainer will not accept `native` in mods.
|
||||
|
||||
## What a player sees
|
||||
|
||||
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
|
||||
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
|
||||
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
|
||||
action row at the bottom.
|
||||
|
||||
A preset that has never been translated draws muted with a `CONVERT` hint on
|
||||
the right. `A` on that row translates it in place and stays open: converting is
|
||||
a preparation step, not a selection. `A` on a converted row activates it,
|
||||
persists the choice, and closes. `SELECT` on a converted row opens
|
||||
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
|
||||
declares and lets the player step each one (`A` wraps, Left/Right clamp,
|
||||
`SELECT` resets one row, `START` resets all behind a confirm).
|
||||
|
||||
Presets live in a plain OS folder, `shaders/` under the portable base directory
|
||||
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
|
||||
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
|
||||
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
|
||||
whatever nested layout it shipped with. These are real filesystem paths rather
|
||||
than `love.filesystem` virtual paths on purpose: the native translator does
|
||||
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
|
||||
LUT loads.
|
||||
|
||||
Persisted state, all in `save.options`:
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `shaderfx` | main slot's preset name, or absent for OFF |
|
||||
| `shaderfxSecondary` | secondary slot's preset name |
|
||||
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
|
||||
|
||||
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
|
||||
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
|
||||
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
|
||||
env var can never later override a real player choice, including a real choice
|
||||
of OFF.
|
||||
|
||||
One quiet behavior worth knowing about: if a slot wants a real preset while
|
||||
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
|
||||
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
|
||||
the saved choice was force-deactivated a few lines later on every boot, which
|
||||
looks identical to "the setting does not save" from the player's side. On
|
||||
Android and iOS that is the common case, because AUTO always resolves to
|
||||
`balanced` there. It never touches an already-explicit performance choice and
|
||||
never un-escalates.
|
||||
|
||||
## The five stages
|
||||
|
||||
| Stage | Owner |
|
||||
| --- | --- |
|
||||
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
|
||||
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
|
||||
| Fixup | `src/render/ShaderFixup.lua` |
|
||||
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
|
||||
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
|
||||
|
||||
### Fetch
|
||||
|
||||
Two acquisition paths, and they land in the same place. A player can copy a
|
||||
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
|
||||
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
|
||||
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
|
||||
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
|
||||
mod index already use.
|
||||
|
||||
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
|
||||
mirror and has no notion of this project's cache format, so a downloaded preset
|
||||
goes through the same `CONVERT` row a hand-copied one does. Every platform
|
||||
ships both convert and use; there is no asymmetry to work around.
|
||||
|
||||
Repeat downloads are conditional. The zip itself is deleted right after
|
||||
extraction, so what is cached instead is the buildbot's own ETag
|
||||
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
|
||||
match with 304 and no body, and curl writes no file at all in that case, so
|
||||
`installDownloaded(notModified)` short-circuits before touching the filesystem
|
||||
and reports "already up to date" rather than "FAILED".
|
||||
|
||||
The interesting part is what gets extracted. `handheld/` is not self-contained:
|
||||
its 78 presets carry 101 references that escape the folder (color-mod LUTs
|
||||
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
|
||||
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
|
||||
them. Extracting `handheld/` alone silently breaks about a third of its own
|
||||
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
|
||||
content nobody asked for. So `extractClosure` walks the real file-level
|
||||
dependency closure in Lua before a single file is copied, the same
|
||||
`#reference`-closure idea librashader applies internally, done up front because
|
||||
deciding what to copy has to happen before the translator ever sees these
|
||||
files. Against this zip that is 207 files and about 9.5 MB with zero broken
|
||||
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
|
||||
than all 78, after most of `color-mod/` and `console-border/` turned out either
|
||||
irrelevant (color-only, no LCD effect) or broken for this project. That list
|
||||
is a temporary trim pending wider testing and is expected to change.
|
||||
|
||||
Two mechanical details in that walk that are easy to get wrong a second time.
|
||||
`extractRefs` tries a quoted `key = "path"` match per line first, with an
|
||||
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
|
||||
delimiter and real packs ship paths with spaces and parentheses in them
|
||||
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
|
||||
quoted match falls back to a conservative character class, since an unquoted
|
||||
path has no delimiter to trust past. And `love.filesystem.write` does not
|
||||
create intermediate directories, so each destination directory is created once
|
||||
before anything is written into it; without that, every file under a subfolder
|
||||
`handheld/` never had before was silently dropped while flat writes succeeded.
|
||||
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
|
||||
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
|
||||
it returns false, leaves the zip's handle open, and the following `remove()`
|
||||
silently fails too, so every download used to leave 54 MB on disk forever.
|
||||
|
||||
### Translate: the native bridge
|
||||
|
||||
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
|
||||
named `librashader_bridge`. It wraps `librashader-presets`,
|
||||
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
|
||||
|
||||
```
|
||||
char* librashader_translate_preset(const char* preset_path, int es);
|
||||
void librashader_free_string(char* s);
|
||||
```
|
||||
|
||||
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
|
||||
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
|
||||
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
|
||||
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
|
||||
with resolved absolute paths and filter/wrap settings), its
|
||||
`parameter_overrides`, and an `error` field.
|
||||
|
||||
**What the bridge is not.** No librashader runtime backend is linked in, for
|
||||
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
|
||||
graphics context is ever touched. It is the translation step only. LOVE still
|
||||
owns every draw call, every canvas and every shader object. The bridge hands
|
||||
back text and metadata and nothing else.
|
||||
|
||||
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
|
||||
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
|
||||
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
|
||||
cached artifact and never call the library. An entry with no cached artifact
|
||||
fails `activate()` loudly instead of silently live-translating.
|
||||
|
||||
The classification is done with librashader's real semantics resolution rather
|
||||
than name matching on the Lua side, and that matters for correctness, not just
|
||||
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
|
||||
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
|
||||
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
|
||||
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
|
||||
alias resolves. The per-pass convenience API only registers the alias of the
|
||||
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
|
||||
loop and builds a preset-wide alias map first; without that,
|
||||
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
|
||||
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
|
||||
classification runs the same resolution over the pass's `uniform_semantics`
|
||||
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
|
||||
present preset uses but that the convention allows.
|
||||
|
||||
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
|
||||
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
|
||||
`love.system.getOS()`, and the same function decides which dialect
|
||||
`validateShader` is asked about at run time, so the two always agree. Convert
|
||||
and render always happen on the same device; **artifacts are not portable
|
||||
across platforms**.
|
||||
|
||||
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
|
||||
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
|
||||
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
|
||||
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
|
||||
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
|
||||
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
|
||||
the source first, and only for the ES target:
|
||||
|
||||
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
|
||||
the preset's declared static textures, with dimensions read straight out of
|
||||
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
|
||||
`lod`, is left alone so it fails as loudly as before instead of guessing.
|
||||
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
|
||||
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
|
||||
reciprocal-size uniform. The containing block instance (`params`, `global`,
|
||||
whatever) is discovered by scanning the source's own uniform block bodies,
|
||||
never assumed. When a pass samples another pass purely through these calls it
|
||||
may never declare that `<Tex>Size` uniform at all, so one is injected first,
|
||||
before any byte offsets are computed.
|
||||
|
||||
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
|
||||
mode at out-of-range coordinates, which is not necessarily identical to
|
||||
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
|
||||
discrepancy for passes whose offsets can leave the image is unmeasured.
|
||||
|
||||
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
|
||||
`ShaderFX` looks for the library, most specific first: the
|
||||
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
|
||||
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
|
||||
finally the bare name handed to the system loader. Per-OS names are
|
||||
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
|
||||
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
|
||||
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
|
||||
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
|
||||
without a path. The desktop path is still a developer build sitting in cargo's
|
||||
output directory; nothing packages it next to a shipped game yet.
|
||||
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||
preset never needs any of this.
|
||||
|
||||
### Fixup
|
||||
|
||||
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
|
||||
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
|
||||
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
|
||||
`position()` convention and refuses a raw `main()`-shaped source outright. This
|
||||
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
|
||||
real preset in the corpus failed without it. This list is the least guessable
|
||||
part of the whole feature.
|
||||
|
||||
**`#version` line.** Stripped; LOVE prepends its own.
|
||||
|
||||
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
|
||||
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
|
||||
literals, which validation rejects with "arrayed constructor: not supported for
|
||||
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
|
||||
three real shapes in the corpus and each is handled: a `const` global (declared
|
||||
without an initializer at global scope, with per-element assignments relocated
|
||||
to the top of `main()`), a non-const local declaration with initializer
|
||||
(rewritten in place, since a function body can hold assignment statements where
|
||||
the literal was), and a bare reassignment of an array declared elsewhere (also
|
||||
in place). Splitting the element list needs `splitTopLevelCommas`, because a
|
||||
naive comma split breaks on any element containing its own parentheses
|
||||
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
|
||||
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
|
||||
whole match fails, and the literal passes through completely untouched.
|
||||
|
||||
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
|
||||
a function-parameter array before passing it on, since GLSL array arguments are
|
||||
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
|
||||
declaration plus an element-by-element copy. The size is known from the
|
||||
declaration, so no comma splitting is involved. This one only became reachable
|
||||
once the other array shapes stopped masking it in the same file.
|
||||
|
||||
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
|
||||
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
|
||||
integer operands, so routing through float `mod()` and back is exact for the
|
||||
non-negative operands this shader family uses (rotation and orientation enum
|
||||
indices). Four patterns are tried in order (paren/paren, paren/bare,
|
||||
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
|
||||
operand and leaves a simple one bare, and the balanced form must be tried
|
||||
before a plain identifier can partially match. Seen live on
|
||||
`authentic_gbc`'s subpixel-rotation math.
|
||||
|
||||
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
|
||||
`precision highp int;` pair with no toggle. That is removed and replaced with
|
||||
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
|
||||
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
|
||||
highp, and fall through to the stage default otherwise.
|
||||
|
||||
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
|
||||
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
|
||||
nor sending the whole struct as a table works, both raise "Shader uniform '...'
|
||||
does not exist." librashader emits every pass's `#pragma parameter`s and size
|
||||
uniforms as exactly that kind of struct, so as shipped the output is unusable
|
||||
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
|
||||
instance uniform and re-declares the members at top level. Scalar members are
|
||||
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
|
||||
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
|
||||
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
|
||||
budget unpacked. Non-scalar members keep their own uniform, since packing an
|
||||
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
|
||||
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
|
||||
type was `int` or `bool` gets an explicit cast back on every read, since a
|
||||
packed slot only stores floats. Vertex and fragment declare identically ordered
|
||||
structs for the same parameters, so packing both with the same prefix assigns
|
||||
the same slot and component to the same parameter in both stages, and one
|
||||
`shader:send` reaches whichever stage uses it.
|
||||
|
||||
Two ordering constraints inside that function are load bearing and look
|
||||
arbitrary from the outside. Packing must walk the members in the struct's own
|
||||
declaration order, because that order is what keeps the scalars in one
|
||||
contiguous run; an earlier version sorted them longest-name-first before
|
||||
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
|
||||
budget. Substitution, separately, must go longest-name-first, so a replacement
|
||||
can never land as a substring inside a still-pending member name that shares a
|
||||
prefix. The two orders are separate copies of the list for exactly that reason.
|
||||
|
||||
`Fixup.packValues` turns a flat `{name = value}` table back into the
|
||||
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
|
||||
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
|
||||
slots against that same 16-vector budget, samplers excluded. Its pattern uses
|
||||
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
|
||||
unlike regex `\w`, and every generated name here is full of underscores.
|
||||
|
||||
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
|
||||
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
|
||||
prefix so their groups cannot collide with the push block's numbering. `MVP` is
|
||||
the one special member: it is substituted directly to `transform_projection`
|
||||
instead of becoming a uniform, because "multiply the incoming vertex by it" is
|
||||
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
|
||||
nothing would ever supply a value for it. An earlier version assumed the UBO
|
||||
block only ever carried `MVP` and deleted the whole declaration after
|
||||
substituting it. That is true only for presets that declare their parameters in
|
||||
a push-constant block; presets that use a UBO instead (many real handheld and
|
||||
console-border presets do) had every other member reference left dangling, and
|
||||
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
|
||||
identifier" and "unknown swizzle selection" errors on real Android hardware came
|
||||
from.
|
||||
|
||||
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
|
||||
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
|
||||
precision, because LOVE forward-declares `effect()`'s prototype under its own
|
||||
header's precision default before this source runs, which can mismatch whatever
|
||||
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
|
||||
two variants (`mediump`, then unqualified) and the caller tries them in order
|
||||
against `validateShader`, taking the first that passes.
|
||||
|
||||
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
|
||||
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
|
||||
`gbFragColor`, declared at the top of the function, with a single `return`
|
||||
appended before the closing brace. Rewriting *every* occurrence regardless of
|
||||
the operator that follows is necessary, not just symmetric with the vertex side:
|
||||
an earlier assign-then-return pair assumed one write at the very end of
|
||||
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
|
||||
that write once with `=` and later accumulate with `+=`. The `+=` statement
|
||||
passed through unconverted and collided with LOVE's own `gl_FragColor` write
|
||||
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
|
||||
rewritten to `return gbFragColor;`, which holds the value assigned just before
|
||||
it on every real shape seen.
|
||||
|
||||
**Vertex entry point.** `void main()` becomes `position(mat4
|
||||
transform_projection, vec4 vertex_position)`, the source's own `attribute`
|
||||
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
|
||||
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
|
||||
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
|
||||
appended. The `Position` and `TexCoord` substitutions are frontier-matched
|
||||
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
|
||||
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
|
||||
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
|
||||
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
|
||||
|
||||
### Cache
|
||||
|
||||
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
|
||||
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
|
||||
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
|
||||
path with the extension swapped to `.lua`, so the artifact sits next to the
|
||||
preset it came from. The file is a plain `return { ... }` chunk written by
|
||||
`serializeLua`, which handles the string/number/boolean/nested-table shape
|
||||
`Json.decode` produces. Array detection walks every key rather than trusting
|
||||
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
|
||||
naive length check by accident.
|
||||
|
||||
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
|
||||
failure it says "convert this preset first" rather than falling back to a live
|
||||
translation.
|
||||
|
||||
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
|
||||
explicit, user-initiated action whose result is stable for a given preset and
|
||||
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
|
||||
and the native call off every activation, every boot and every frame. The cost
|
||||
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
|
||||
exist" check with no version or content stamp, and there is no explicit
|
||||
"reconvert" action in the UI. A preset converted by an older build never picks
|
||||
up a later translator fix on its own. This was seen on a real device, where
|
||||
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
|
||||
because that device's cache predated the fix while `pixel_transparency`'s
|
||||
happened to be fresher. Two places compensate by reconverting unconditionally:
|
||||
`ShaderFXScreen`'s explicit selection of an already-converted row, and
|
||||
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
|
||||
CPU-only work with no GPU compile, and neither is on the per-frame path.
|
||||
|
||||
`ShaderSourcePatches.lua` sits just before translation and patches the raw
|
||||
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
|
||||
reading the real files, discovers `#pragma parameter` lines and struct members;
|
||||
nothing downstream can add one. Patches are small, explicit, per-preset literal
|
||||
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
|
||||
pattern magic), re-applied idempotently on every convert so a buildbot
|
||||
re-download that replaces the upstream file wholesale does not quietly undo
|
||||
them. **The patch table ships empty on purpose and nothing registers one.** Its
|
||||
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
|
||||
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
|
||||
gap above: a new pragma can only reach an artifact that gets reconverted, and at
|
||||
the time nothing forced one. The mechanism is kept for a future preset that
|
||||
genuinely needs a new declaration, but an already-wired engine-side channel is
|
||||
preferred whenever one exists.
|
||||
|
||||
### Run: the pass graph
|
||||
|
||||
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
|
||||
player's edited parameters over the artifact's own defaults, loads the preset's
|
||||
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
|
||||
runs the chain each frame.
|
||||
|
||||
`newChainState` builds one instance of chain-local state per loaded preset,
|
||||
never module-global, so switching presets cannot leak a previous preset's
|
||||
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
|
||||
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
|
||||
player's `shaderfxParams` edits.
|
||||
|
||||
Sizes resolve through `resolveScale`, which handles all four slang scale types
|
||||
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
|
||||
input dimensions and the original frame. Size uniforms are packed as
|
||||
`{w, h, 1/w, 1/h}`, the slang convention.
|
||||
|
||||
`runPass` caches two things per `(state, pass index)`. The **shader** is
|
||||
compiled once for the state's lifetime, along with the fragment manifest it was
|
||||
compiled against, since a pass's GLSL depends only on the preset and never on
|
||||
per-frame input. The **canvas** is reallocated only when the pass's resolved
|
||||
size actually changes, a window resize or a different preset. Every harness this
|
||||
runtime was ported from ran the chain once and quit, so allocating a fresh
|
||||
canvas and compiling a fresh shader on every call was invisible there. On a real
|
||||
per-frame render path it is one GPU allocation per pass per frame, and a shader
|
||||
recompile on top. The same discipline applies to the crop canvas in
|
||||
`cropToGbSource`, which is called once per frame and whose size grows with the
|
||||
world canvas as the player zooms out; leaving it uncached was a real cost that
|
||||
scaled with zoom level even with a single preset active.
|
||||
|
||||
Sampler binding is by semantic, from the bridge's classification, never by a
|
||||
hardcoded per-preset name check: `Source` is the previous pass's output (or the
|
||||
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
|
||||
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
|
||||
real name the translation reported. A sampler that resolves to nothing asserts
|
||||
rather than drawing garbage.
|
||||
|
||||
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
|
||||
bytes with plain `io.open` and goes through `love.data.newByteData` and
|
||||
`love.image.newImageData`, because a preset's texture paths are arbitrary
|
||||
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
|
||||
those outright ("Could not open file ... Does not exist") even when the file is
|
||||
real. Wrap modes are mapped from librashader's names to LOVE's
|
||||
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
|
||||
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
|
||||
left nil; the fail-loud point is the sampler assertion in `runPass` that
|
||||
actually needed it, not the loader.
|
||||
|
||||
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
|
||||
history slot reads the current frame, both for the sampler binding and for the
|
||||
size uniform. Real per-frame history rotation has been proven out in a desktop
|
||||
harness but is not wired into this path.
|
||||
|
||||
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
|
||||
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
|
||||
sampler resolves to nothing and trips the binding assertion. No preset in the
|
||||
shipped shortlist uses it.
|
||||
|
||||
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
|
||||
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
|
||||
|
||||
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
|
||||
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
|
||||
composite (world, UI, and any post-process pipeline that already ran); `rect` is
|
||||
this frame's real playfield rectangle in physical framebuffer pixels and
|
||||
`source` is the real pixel size of the content it frames. The sequence is: crop
|
||||
`rect` out of the composite, run whichever slots are active over that crop, draw
|
||||
the untouched composite, then stretch the chain output back over `rect`. UI and
|
||||
letterbox bars outside the playfield pass through untouched. If the chain throws,
|
||||
the frame still shows the unprocessed composite; a broken preset degrades to
|
||||
"shader off", never to a crash or a blank frame.
|
||||
|
||||
Three details in that path are non-obvious:
|
||||
|
||||
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
|
||||
logical units, not raw pixels, so the viewport handed to the pass graph and
|
||||
the final draw-back position are both converted from `rect`'s physical pixels
|
||||
first. On a `dpiscale = 1` desktop the two are numerically identical and the
|
||||
bug is invisible; at dpiscale 3 on real Android hardware the chain output
|
||||
rendered about three times too large and at a pixel-valued offset in unit
|
||||
space.
|
||||
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
|
||||
caller can leave the draw color dirty (a menu's black text leaves it at
|
||||
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
|
||||
and `push("all")` saves state for `pop()` without resetting it. That was the
|
||||
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
|
||||
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
|
||||
`flushBatch()` on the line above is cheap insurance against a read-after-write
|
||||
ordering hazard between this draw and whatever last rendered into the canvas;
|
||||
it was never confirmed to fix anything on its own.
|
||||
- **The final blit stretches.** A slang chain's last pass is not required to
|
||||
land on the viewport size, and most presets (21 of the 78 in the corpus)
|
||||
declare their last pass `scale_type = "source"` and stay at native Game Boy
|
||||
resolution, relying on the frontend's blit exactly as RetroArch does.
|
||||
Requiring an exact size match here used to skip the draw outright for every
|
||||
such preset on every frame, which is a silent total no-op rather than a sizing
|
||||
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
|
||||
nearest or linear.
|
||||
|
||||
### What this engine feeds shaders that a libretro core does not
|
||||
|
||||
A stock libretro core hands its frontend a raw framebuffer and a frame count.
|
||||
This engine has more context available and passes some of it through.
|
||||
|
||||
| Context | How it reaches the shader |
|
||||
| --- | --- |
|
||||
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
|
||||
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
|
||||
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
|
||||
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
|
||||
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
|
||||
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
|
||||
|
||||
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
|
||||
bound to 0 because librashader's own documentation is explicit that it is
|
||||
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
|
||||
oriented arcade core, say), not device orientation. Nothing here ever rotates
|
||||
Game Boy content, so 0 is the correct answer, not a placeholder.
|
||||
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
|
||||
rest" calibration reference, no preset in the corpus reads it, and a fixed
|
||||
placeholder beats an invented value.
|
||||
|
||||
`src/core/Sensors.lua` is what makes the two real motion semantics work.
|
||||
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
|
||||
any platform including Android; it is a LOVE 12 addition. The working path is
|
||||
raw FFI into the SDL2 that LOVE already links, the same technique
|
||||
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
|
||||
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
|
||||
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
|
||||
dead code after a future LOVE 12 upgrade. Loading order matters:
|
||||
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
|
||||
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
|
||||
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
|
||||
device with no sensor is probed once and then permanently reports zeros, so a
|
||||
desktop run does not pay for it every frame.
|
||||
|
||||
SDL keeps sensor readings in the device's fixed chassis frame regardless of
|
||||
screen orientation, so `rotateForScreen` remaps x and y into
|
||||
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
|
||||
compensation is mobile-only: a desktop monitor is legitimately and permanently
|
||||
"landscape" to that query, which says something about the monitor's shape and
|
||||
nothing about how a player is holding anything.
|
||||
|
||||
The accelerometer path in `sizeTable` does three things to the raw reading
|
||||
before it becomes a uniform, all of them driven by real on-device data:
|
||||
|
||||
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
|
||||
actually holding the device in and every later reading is measured relative
|
||||
to that, rather than to an assumed idealized vertical. The shipped tilt maths
|
||||
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
|
||||
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
|
||||
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
|
||||
the effect sat near-saturated all the time instead of starting near neutral.
|
||||
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
|
||||
dominant on Z, the one axis it never reads. This engine's rest pose is
|
||||
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
|
||||
gravity back on the ignored axis.
|
||||
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
|
||||
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
|
||||
quietly damped tilt and noise alike by the same factor; calibration correctly
|
||||
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
|
||||
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
|
||||
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
|
||||
on the ignored axis to keep the denominator stable, but only when there is a
|
||||
genuine live reading to calibrate against. An all-zero raw read is
|
||||
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
|
||||
Earth, and injecting into that case would make the shader believe it had
|
||||
sensor data and silently replace its own static fallback with fake motion.
|
||||
|
||||
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
|
||||
angle, so it only becomes a usable on-screen offset by integrating over time,
|
||||
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
|
||||
is deliberately a decaying spring rather than a true integrated heading:
|
||||
gyro-only integration drifts without a magnetometer to correct it, so this
|
||||
settles back toward neutral and stays bounded by construction. It is folded onto
|
||||
the accelerometer's x component before the shader's own normalize and clamp,
|
||||
because that is the only already-compiled channel the stock upstream maths
|
||||
reads, and reaching an already-converted artifact with no reconvert was worth
|
||||
the tradeoff that the twist reads as an added simulated tilt rather than a
|
||||
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
|
||||
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
|
||||
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
|
||||
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
|
||||
looked worse, because overshooting a comfortable range reads worse than being
|
||||
subtle. Retune in small steps with real device checks, not big jumps.
|
||||
|
||||
## Two slots
|
||||
|
||||
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
|
||||
to its save key. The slots are activated and persisted independently, and the
|
||||
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
|
||||
parameter edits are keyed by *preset name*, not by slot, because a preset's
|
||||
values are a property of the preset the same way its cached artifact is;
|
||||
editing them re-activates every slot currently showing that preset and persists
|
||||
for the next load in either.
|
||||
|
||||
When both slots are active, `render` runs main's chain first and hands its
|
||||
finished output to secondary as secondary's own input frame, along with its
|
||||
dimensions, so a secondary preset that scales off its input sees main's real
|
||||
output size rather than the original crop. Either slot alone behaves exactly as
|
||||
a single-preset path; neither active is a plain passthrough.
|
||||
|
||||
**This is not how RetroArch composes multiple presets.** RetroArch merges
|
||||
presets into a *single* pass list through `#reference` and `Append`, producing
|
||||
one pass graph with one shared semantics map, where a later pass can reference
|
||||
an earlier one's output by alias and the whole thing resolves as one unit. Two
|
||||
slots here are two independent librashader chains run back to back, which is
|
||||
what stacking two separate preset chains would give you, not what merging them
|
||||
gives you. Presets that assume merged semantics will not behave the same way.
|
||||
|
||||
## Test seams
|
||||
|
||||
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
|
||||
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
|
||||
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
|
||||
canvas, which the later unconditional draw-back would otherwise mask),
|
||||
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
|
||||
actually reached the packed uniform, per slot). `Sensors.setOverride`,
|
||||
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
|
||||
readings on a machine with no hardware.
|
||||
|
||||
## Limitations
|
||||
|
||||
None of these are theoretical.
|
||||
|
||||
- **Tested on very little real hardware.** Essentially one Android phone, one
|
||||
desktop, and the automated harnesses. Anything about how a preset actually
|
||||
looks or performs elsewhere is unverified.
|
||||
- **No performance tier is actually tuned.** The chain-resolution multiplier in
|
||||
`Performance.CAPS` is a working mechanism, but every tier that permits
|
||||
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
|
||||
today. Picking a real value for weak hardware needs a device this project does
|
||||
not have.
|
||||
- **The dual-slot design does not match RetroArch.** See above. Two chains in
|
||||
sequence is not one merged pass list.
|
||||
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
|
||||
falls back to "every slot is the current frame" in the live render path, and
|
||||
is unverified there.
|
||||
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
|
||||
error and its sampler trips an assertion.
|
||||
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
|
||||
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
|
||||
player-supplied filename still collides with the row's right-hand hint.
|
||||
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
|
||||
Intentional, for the reason given above, but it means the mechanism has no
|
||||
live coverage.
|
||||
- **Cached artifacts have no staleness detection.** Existence is the only check.
|
||||
The two unconditional reconvert points paper over it; anything that does not
|
||||
go through them can be running a stale translation.
|
||||
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
|
||||
Copying a converted preset folder between a phone and a desktop copies a
|
||||
wrong artifact along with it.
|
||||
- **The bundled bridge is only as good as the build machine.**
|
||||
`scripts/build.sh` bundles the cdylib for mac, win and linux via
|
||||
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
||||
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
||||
package that can run converted presets but cannot CONVERT new ones, and says
|
||||
so rather than failing. Android ships the `.so` via `jniLibs`.
|
||||
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
||||
manual pass over `handheld/` and is expected to change, most likely to shrink.
|
||||
- **Tilt direction is unverified.** Which way forward and back rocking moves the
|
||||
effect was never confirmed on a device; if it feels backwards the fix is a
|
||||
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
|
||||
landscape rotation compensation matches real RetroArch is genuinely unknown:
|
||||
RetroArch's Android input driver computes a screen rotation but does not
|
||||
visibly apply it to the accelerometer values that reach shader uniforms, so
|
||||
this project's compensation may be an improvement over upstream rather than a
|
||||
match to it.
|
||||
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
|
||||
in the bridge turns those calls into `texture()`, which honours the sampler's
|
||||
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
|
||||
implementation defined. Unmeasured.
|
||||
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
|
||||
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
|
||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||
(or copy NRO / `game.love` for loose).
|
||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver>/`
|
||||
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver|crystal>/`
|
||||
path the launcher prints.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
|
||||
@@ -82,6 +82,11 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||
-- version session (save editor or game). closeEditor and returnToLauncher
|
||||
-- both go through SessionLifecycle so neither path forgets a singleton.
|
||||
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||
|
||||
-- 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
|
||||
@@ -157,9 +162,7 @@ local function openEditor(version, slotId)
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -179,10 +182,7 @@ local function openEditor(version, slotId)
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -197,21 +197,14 @@ 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.
|
||||
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
|
||||
-- next Edit or Play does not inherit the editor's dead mod loader.
|
||||
function closeEditor()
|
||||
local version = editorVersion
|
||||
local app = EditorApp
|
||||
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
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = app })
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -294,9 +287,9 @@ end
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
return RomImporter.new(function(version, cartId)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
bootGame(version, cartId)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
@@ -312,45 +305,19 @@ end
|
||||
local function returnToLauncher()
|
||||
if not Game then return end
|
||||
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
end
|
||||
|
||||
SessionLifecycle.endGameSession(Game)
|
||||
Game = nil
|
||||
autopilot = nil
|
||||
driverCo = nil
|
||||
-- Leave the cart's scope behind: the launcher's own settings and slots are
|
||||
-- the base game's, not the cart's. The speed ladder is cart state too, so
|
||||
-- a 1x/2x cart must not pin the launcher or the next game.
|
||||
require("src.core.SaveData").setCart(nil)
|
||||
require("src.core.GameSpeed").setAllowed(nil)
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
SessionLifecycle.endMountedSession(currentVersion)
|
||||
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
@@ -366,7 +333,7 @@ local function returnToLauncher()
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
function bootGame(version, cartId)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||
@@ -379,6 +346,25 @@ function bootGame(version)
|
||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
CacheFs.mountVersion(GameVersion.get())
|
||||
local cartHash, cartSpeeds, cartOptions
|
||||
if cartId then
|
||||
local ok, cart, hash = pcall(function()
|
||||
return require("src.carts.CartStore").get(cartId)
|
||||
end)
|
||||
if ok and cart then
|
||||
cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options
|
||||
else
|
||||
cartId = nil
|
||||
end
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
SaveData.setCart(cartId, cartHash)
|
||||
-- The author's settings land in the cart's own scope the first time only;
|
||||
-- after that the player owns them.
|
||||
if cartOptions then SaveData.seedCartOptions(cartOptions) end
|
||||
-- A cart may narrow or pin the speed ladder; nil restores the full one.
|
||||
require("src.core.GameSpeed").setAllowed(cartSpeeds)
|
||||
if cartId then SaveData.adoptCartSeal(cartId) end
|
||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||
pcall(function()
|
||||
@@ -694,7 +680,7 @@ function love.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.gamepadpressed(joystick, button) end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
@@ -714,7 +700,7 @@ function love.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.gamepadreleased(joystick, button) end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
@@ -734,7 +720,7 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.gamepadaxis(joystick, axis, value) end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
@@ -754,7 +740,7 @@ function love.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickpressed(joystick, button) end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickpressed(joystick, button)
|
||||
@@ -774,7 +760,7 @@ function love.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickreleased(joystick, button) end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickreleased(joystick, button)
|
||||
@@ -794,7 +780,7 @@ function love.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickaxis(joystick, axis, value) end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
@@ -814,7 +800,7 @@ function love.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickhat(joystick, hat, direction) end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
if not Game then return end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
@@ -1153,22 +1139,7 @@ function love.quit()
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
-- LOVE waits for every live love.thread before the process exits, and both
|
||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
||||
-- without this the process outlived the window and the next launch re-entered
|
||||
-- the dead one instead of starting fresh (#339)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
-- The launcher's fetch pool is the same story: its workers idle in
|
||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
||||
-- touched the network would hang the process on exit (#339's shape again).
|
||||
if package.loaded["src.net.Fetch"] then
|
||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
||||
end
|
||||
SessionLifecycle.endProcess()
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<!-- Low latency audio -->
|
||||
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
||||
<!-- love.sensor (accelerometer). No runtime permission needed on Android;
|
||||
this is a Play Store filtering hint only, so required=false like the
|
||||
other optional hardware features above. -->
|
||||
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -12,6 +12,27 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.21",
|
||||
"date": "2026-08-24",
|
||||
"size": 14058979,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.2.20",
|
||||
"date": "2026-08-22",
|
||||
"size": 13812226,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
|
||||
},
|
||||
{
|
||||
"version": "0.2.19",
|
||||
"date": "2026-08-22",
|
||||
"size": 13778435,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
|
||||
},
|
||||
{
|
||||
"version": "0.2.18",
|
||||
"date": "2026-08-21",
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
# LÖVE UWP binaries
|
||||
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `3f51bf0be5f3f86e5934da3893caba2b817f82c1`.
|
||||
|
||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -7,7 +7,7 @@
|
||||
"love": {
|
||||
"version": "11.5",
|
||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
||||
"commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1"
|
||||
},
|
||||
"luajit": {
|
||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/bin/love.dll",
|
||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
||||
"sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6"
|
||||
},
|
||||
{
|
||||
"path": "love/bin/lua51.dll",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/lib/lovestatic.lib",
|
||||
"sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98"
|
||||
"sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972"
|
||||
},
|
||||
{
|
||||
"path": "love/lib/lua51.lib",
|
||||
|
||||
+33
-3
@@ -79,7 +79,7 @@ else
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
fi
|
||||
# Materialize the listing once and grep the file: piping unzip straight into
|
||||
@@ -101,7 +101,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
src/ui/kit/Kit.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json; do
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_crystal.json; do
|
||||
grep -qxF "$required" "$LOVE_LISTING" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
@@ -167,6 +168,31 @@ make_ico() { # $1 = output .ico path
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- macOS
|
||||
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
|
||||
# without it still runs presets that were converted elsewhere.
|
||||
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it.
|
||||
bundle_shader_bridge() {
|
||||
local dest="$1" name="$2"
|
||||
local src="${SHADERFX_BRIDGE:-}"
|
||||
local crate="$ROOT/tools/shaderfx-bridge"
|
||||
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
|
||||
src="$crate/target/release/$name"
|
||||
fi
|
||||
if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then
|
||||
say "building the ShaderFX bridge with cargo"
|
||||
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
|
||||
src="$crate/target/release/$name"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$src" ] && [ -f "$src" ]; then
|
||||
mkdir -p "$dest"
|
||||
cp "$src" "$dest/$name"
|
||||
say "bundled $name for SHADER FX preset conversion"
|
||||
else
|
||||
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
|
||||
fi
|
||||
}
|
||||
|
||||
build_mac() {
|
||||
say "building macOS app"
|
||||
local love_app="${LOVE_APP:-/Applications/love.app}"
|
||||
@@ -179,6 +205,7 @@ build_mac() {
|
||||
# drop any bundled placeholder .love and fuse ours in
|
||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib"
|
||||
|
||||
local plist="$out_app/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
||||
@@ -292,9 +319,11 @@ build_win() {
|
||||
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||
else
|
||||
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
fi
|
||||
|
||||
bundle_shader_bridge "$out_dir" "librashader_bridge.dll"
|
||||
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||
@@ -390,6 +419,7 @@ build_linux() {
|
||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||
|
||||
cp "$LOVE_FILE" "$appdir/game.love"
|
||||
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so"
|
||||
|
||||
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
||||
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||
#
|
||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||
# [--test-application-id ID]
|
||||
#
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
# --test-application-id ID install under a distinct application id (e.g.
|
||||
# com.theboisclub.pokemonred.shaderfxtest) so a
|
||||
# test build installs side by side with a real
|
||||
# played copy instead of overwriting it. App name
|
||||
# gets a " (test)" suffix so it's distinguishable
|
||||
# in the launcher too. Without this flag,
|
||||
# apply_android_branding always writes back the
|
||||
# real shipping identity, which previously had to
|
||||
# be restored by hand in gradle.properties after
|
||||
# every test build.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||
@@ -34,10 +45,13 @@ GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
||||
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
||||
CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json"
|
||||
CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}"
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
RELEASE=false
|
||||
TEST_APPLICATION_ID=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -48,11 +62,12 @@ while [ $# -gt 0 ]; do
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
--test-application-id) TEST_APPLICATION_ID="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
sed -n '2,28p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, --package-only, or --test-application-id ID)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
@@ -84,6 +99,14 @@ if $RELEASE; then
|
||||
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||
fi
|
||||
|
||||
if [ -n "$TEST_APPLICATION_ID" ]; then
|
||||
if ! printf '%s' "$TEST_APPLICATION_ID" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'; then
|
||||
fail "invalid --test-application-id '$TEST_APPLICATION_ID' (expected a dotted package id, e.g. com.theboisclub.pokemonred.shaderfxtest)"
|
||||
fi
|
||||
APPLICATION_ID="$TEST_APPLICATION_ID"
|
||||
APP_NAME="$APP_NAME (test)"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- preconditions
|
||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||
fail "love-android not found at mobile/android/.
|
||||
@@ -246,6 +269,54 @@ ensure_silver_manifest() {
|
||||
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
||||
}
|
||||
|
||||
crystal_manifest_is_valid() {
|
||||
local path="$1"
|
||||
python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
try:
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
|
||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||
"f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_crystal_manifest() {
|
||||
local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE"
|
||||
local staged
|
||||
staged="$(mktemp)"
|
||||
|
||||
if crystal_manifest_is_valid "$manifest"; then
|
||||
rm -f "$staged"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Crystal import manifest is missing or invalid; recovering it before packaging"
|
||||
if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||
&& crystal_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "restored Crystal import manifest from this checkout's Git data"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$CRYSTAL_MANIFEST_URL" \
|
||||
&& crystal_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "downloaded Crystal import manifest from the project repository"
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "$staged"
|
||||
fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_MANIFEST_URL"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -312,6 +383,7 @@ pack_game_love() {
|
||||
ensure_yellow_manifest
|
||||
ensure_gold_manifest
|
||||
ensure_silver_manifest
|
||||
ensure_crystal_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
@@ -326,7 +398,7 @@ pack_game_love() {
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# List once and match against the captured text: piping unzip straight into
|
||||
@@ -350,6 +422,8 @@ pack_game_love() {
|
||||
|| fail "game.love is missing the Gold ROM import manifest"
|
||||
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Silver ROM import manifest"
|
||||
grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Crystal ROM import manifest"
|
||||
# This gate exists because the launcher's UI toolkit once lived outside
|
||||
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
||||
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||
|
||||
@@ -368,6 +368,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
||||
grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_crystal.json"
|
||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||
|
||||
@@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
||||
grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_crystal.json"
|
||||
|
||||
say "Linux arm64 self-test passed"
|
||||
|
||||
@@ -50,7 +50,7 @@ rm -f "$OUTPUT"
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||
@@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_crystal.json \
|
||||
src/ui/kit/Kit.lua \
|
||||
src/import/LauncherView.lua; do
|
||||
grep -qxF "$required" "$LISTING" \
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
|
||||
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then
|
||||
fail "generated data missing, run scripts/setup.sh first"
|
||||
fi
|
||||
|
||||
|
||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
replaces the NRO(s) and these help files.
|
||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
|
||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into:
|
||||
switch/gen1recomp/pokemon-love2d/imports/
|
||||
then use Scan again in the launcher if needed.
|
||||
|
||||
Inboxes (drop files here via MTP / SD / FTP):
|
||||
imports/ — ROM .gb / .gbc
|
||||
imports/mods/ — community mod .zip
|
||||
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
||||
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
|
||||
imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet)
|
||||
exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet)
|
||||
|
||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||
EOF
|
||||
@@ -92,7 +92,7 @@ write_readme() {
|
||||
}
|
||||
|
||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
|
||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher."
|
||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||
"Put community mod .zip files here, then MODS → Scan again."
|
||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||
@@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
||||
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \
|
||||
"Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||
@@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
||||
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/exports/crystal/README.txt" \
|
||||
"Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||
|
||||
rm -f "$OUT_ZIP"
|
||||
(
|
||||
@@ -145,11 +149,13 @@ REQUIRED=(
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||
)
|
||||
for rel in "${REQUIRED[@]}"; do
|
||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||
|
||||
@@ -272,11 +272,13 @@ for rel in \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||
do
|
||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||
done
|
||||
|
||||
+59
-5
@@ -5,8 +5,8 @@
|
||||
# them fails. The tier split is what makes that possible: T1/T2/T4 need
|
||||
# nothing but the committed fixture dataset, so they run anywhere --
|
||||
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
|
||||
# data/generated/, so it is skipped automatically when the ROM has never
|
||||
# been imported rather than failing the run.
|
||||
# data/generated/ or an imported Red cache, so it is skipped automatically
|
||||
# when neither exists rather than failing the run.
|
||||
#
|
||||
# scripts/test.sh every tier this checkout can run
|
||||
# scripts/test.sh --quick skip the slow content tier
|
||||
@@ -16,6 +16,13 @@
|
||||
#
|
||||
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
|
||||
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
|
||||
#
|
||||
# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache
|
||||
# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as
|
||||
# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with:
|
||||
# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION=<version> \
|
||||
# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="<rom>" love .
|
||||
# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -32,7 +39,7 @@ for arg in "$@"; do
|
||||
--bless) BLESS=1 ;;
|
||||
--bless-shots) SHOTS=1; BLESS=1 ;;
|
||||
--quick) QUICK=1 ;;
|
||||
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
--help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
@@ -49,6 +56,36 @@ fi
|
||||
# save-editor suite pins that to the default identity.
|
||||
SANDBOX_IDENTITY="ci-$$"
|
||||
|
||||
# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an
|
||||
# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/).
|
||||
CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches}
|
||||
if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then
|
||||
CACHE_ROOT="$POKEPORT_TEST_CACHES"
|
||||
elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then
|
||||
CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY"
|
||||
else
|
||||
CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY"
|
||||
fi
|
||||
|
||||
# Only a cache with the importer's completion marker is offered: a half-written
|
||||
# one would fail suites that are meant to self-skip.
|
||||
adopt_cache() {
|
||||
local var="$1" dir="$CACHE_ROOT/$2"
|
||||
[ -z "${!var:-}" ] || return 0
|
||||
[ -f "$dir/rom-cache.complete" ] || return 0
|
||||
export "$var=$dir"
|
||||
echo " $var=$dir"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "-- per-version caches under $CACHE_ROOT"
|
||||
adopt_cache RED_CACHE red
|
||||
adopt_cache BLUE_CACHE blue
|
||||
adopt_cache YELLOW_CACHE yellow
|
||||
adopt_cache GOLD_CACHE gold
|
||||
adopt_cache SILVER_CACHE silver
|
||||
adopt_cache CRYSTAL_CACHE crystal
|
||||
|
||||
FAILED=()
|
||||
run_tier() {
|
||||
local label="$1"; shift
|
||||
@@ -77,6 +114,7 @@ fi
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
@@ -87,6 +125,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
||||
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||
# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache),
|
||||
# so it runs here rather than behind the Red content gate below.
|
||||
run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua
|
||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
run_tier "T4 title checkpoint cold restart" \
|
||||
bash tests/integration/title_checkpoint_cold_start.sh
|
||||
@@ -133,7 +174,19 @@ run_content_behavior() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# The Red dataset is the source tree's when tools/build_data.py wrote one, and
|
||||
# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook.
|
||||
HAVE_RED_DATA=0
|
||||
if [ -f data/generated/maps.lua ]; then
|
||||
HAVE_RED_DATA=1
|
||||
elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then
|
||||
HAVE_RED_DATA=1
|
||||
export POKEPORT_DATA_DIR="$RED_CACHE/data/generated"
|
||||
echo ""
|
||||
echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR"
|
||||
fi
|
||||
|
||||
if [ "$HAVE_RED_DATA" = "1" ]; then
|
||||
if [ "$QUICK" = "1" ]; then
|
||||
echo ""
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
@@ -163,8 +216,9 @@ if [ -f data/generated/maps.lua ]; then
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
|
||||
echo " import a ROM to run them; the modded-link cases ran in T4)"
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no"
|
||||
echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran"
|
||||
echo " in T4 and the Gen 2 suites in T2)"
|
||||
fi
|
||||
|
||||
# ------- golden screenshots: needs love + a display
|
||||
|
||||
@@ -348,6 +348,12 @@ local EMITTERS = {
|
||||
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
|
||||
}
|
||||
|
||||
-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame
|
||||
local function tilesetLoadFrames(data, ts)
|
||||
local sheet = data and data.tilesheets and data.tilesheets[ts or 0]
|
||||
return math.floor((sheet and sheet.tiles or 79) / 8) + 1
|
||||
end
|
||||
|
||||
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
|
||||
-- with the subanimation transform applied (DrawFrameBlock).
|
||||
local function placeTile(transform, bc, t, tileset)
|
||||
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local obp0Flip = false
|
||||
|
||||
for _, row in ipairs(anim.seq) do
|
||||
-- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the
|
||||
-- row's sound and its first frame block (#1653)
|
||||
if not row.effect then
|
||||
emit(tilesetLoadFrames(self.data, row.tileset))
|
||||
end
|
||||
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
|
||||
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
||||
-- the row starts (GetMoveSound)
|
||||
|
||||
+146
-6
@@ -638,6 +638,9 @@ local function newBattle(game)
|
||||
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
||||
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
||||
game.partyMenuSavedIndex = nil
|
||||
-- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it
|
||||
-- (init_battle_variables.asm:7-12) #1732
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil
|
||||
self.data = game.data
|
||||
-- ruleset from the merged registry (the requires above are the same
|
||||
-- records on a mod-free boot); an unknown save value falls back to the
|
||||
@@ -1918,6 +1921,8 @@ function BattleState:exit()
|
||||
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
||||
-- field party menu comes back on slot 1 after a battle. #768
|
||||
self.game.partyMenuSavedIndex = nil
|
||||
-- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732
|
||||
self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil
|
||||
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||
@@ -1942,6 +1947,7 @@ local function clearTrapping(battler)
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
battler.trapHitSfx = nil
|
||||
end
|
||||
|
||||
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
||||
@@ -2792,6 +2798,111 @@ function BattleState:queueResidual(b, opp)
|
||||
end
|
||||
end
|
||||
|
||||
local function fieldBattlerView(battler, side)
|
||||
local types = {}
|
||||
for index, typeId in ipairs(battler.curTypes or {}) do
|
||||
types[index] = typeId
|
||||
end
|
||||
local mon = battler.mon or {}
|
||||
return {
|
||||
side = side,
|
||||
name = battler.name,
|
||||
hp = tonumber(mon.hp) or 0,
|
||||
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
|
||||
types = types,
|
||||
vanished = battler.invulnerable and true or false,
|
||||
}
|
||||
end
|
||||
|
||||
local function publicScalar(value)
|
||||
local kind = type(value)
|
||||
if kind == "string" or kind == "boolean" then return value, true end
|
||||
if kind == "number" and value == value
|
||||
and value < math.huge and value > -math.huge then
|
||||
return value, true
|
||||
end
|
||||
return nil, false
|
||||
end
|
||||
|
||||
local function publicDataCopy(value, visiting)
|
||||
local scalar, ok = publicScalar(value)
|
||||
if ok then return scalar, true end
|
||||
if type(value) ~= "table" then return nil, false end
|
||||
|
||||
visiting = visiting or {}
|
||||
if visiting[value] then return nil, false end
|
||||
visiting[value] = true
|
||||
|
||||
local copy = {}
|
||||
for key, child in next, value do
|
||||
local copiedKey, keyOk = publicScalar(key)
|
||||
local copiedChild, childOk = publicDataCopy(child, visiting)
|
||||
if keyOk and childOk then copy[copiedKey] = copiedChild end
|
||||
end
|
||||
visiting[value] = nil
|
||||
return copy, true
|
||||
end
|
||||
|
||||
local function checkpointFieldView(field)
|
||||
field = field or {}
|
||||
local view = publicDataCopy({
|
||||
weather = field.weather,
|
||||
tokens = field.tokens or {},
|
||||
})
|
||||
return view
|
||||
end
|
||||
|
||||
-- Public field residuals are data-only requests. Mods can inspect a detached
|
||||
-- checkpoint-shaped field view and detached battler views, but only the engine
|
||||
-- mutates HP, animates the bar, or enters the faint pipeline.
|
||||
function BattleState:applyFieldResiduals()
|
||||
if not Runtime.wantsHook("battle.field_residual") then return end
|
||||
local views = {
|
||||
player = fieldBattlerView(self.player, "player"),
|
||||
enemy = fieldBattlerView(self.enemy, "enemy"),
|
||||
}
|
||||
local rows = Runtime.call("battle.field_residual", function() return {} end, {
|
||||
field = checkpointFieldView(self.field),
|
||||
battlers = views,
|
||||
turn = self.turnCount or 0,
|
||||
})
|
||||
if type(rows) ~= "table" then return end
|
||||
|
||||
local fainted = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local battler = type(row) == "table" and row.side == "player"
|
||||
and self.player or type(row) == "table" and row.side == "enemy"
|
||||
and self.enemy or nil
|
||||
local amount = type(row) == "table" and row.amount or nil
|
||||
if battler and battler.mon.hp > 0 and type(amount) == "number"
|
||||
and amount > 0
|
||||
and amount < math.huge and amount == math.floor(amount)
|
||||
and (row.message == nil or type(row.message) == "string") then
|
||||
amount = math.min(amount, battler.mon.hp)
|
||||
if type(row.message) == "string" and row.message ~= "" then
|
||||
self:sayNext(row.message)
|
||||
end
|
||||
battler.mon.hp = battler.mon.hp - amount
|
||||
self:drainNext(battler, battler.mon.hp)
|
||||
if battler.mon.hp <= 0 then fainted[battler] = true end
|
||||
end
|
||||
end
|
||||
-- A terminal player faint owns a simultaneous field-residual batch. Queue
|
||||
-- only that authority so its blackout cannot race an enemy EXP/replacement
|
||||
-- path from the same hook response. Native faint paths remain untouched.
|
||||
if fainted[self.player]
|
||||
and not Party.firstHealthy(self:playerPartyView()) then
|
||||
self:onFaint(self.player)
|
||||
return
|
||||
end
|
||||
|
||||
-- Otherwise resolve the two sides in engine order after every accepted
|
||||
-- descriptor has landed. Descriptor order must not decide resolution.
|
||||
for _, battler in ipairs({ self.player, self.enemy }) do
|
||||
if fainted[battler] then self:onFaint(battler) end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:endOfTurn()
|
||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||
@@ -2845,6 +2956,7 @@ function BattleState:endOfTurn()
|
||||
b.trappingTurns = nil
|
||||
end
|
||||
end
|
||||
self:applyFieldResiduals()
|
||||
self:tickTokens()
|
||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||
end
|
||||
@@ -4070,12 +4182,15 @@ function BattleState:continueTrapping(user, target)
|
||||
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
|
||||
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
||||
-- animation replays each locked turn (same damage, animation shown).
|
||||
-- Mirror performMove's anim row (BattleState.lua ~1307), gated on the
|
||||
-- OPTIONS animation toggle.
|
||||
if user.trapMove and self:animationsOn() then
|
||||
-- Mirror performMove's anim row (BattleState.lua ~1307), with the
|
||||
-- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 /
|
||||
-- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653).
|
||||
if user.trapMove then
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer })
|
||||
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer,
|
||||
hit = { animType = user.isPlayer and 5 or 2,
|
||||
sfx = user.trapHitSfx } })
|
||||
end
|
||||
-- the counter can sit at 0 until the END of the turn: the trapping
|
||||
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
|
||||
@@ -4250,7 +4365,7 @@ function BattleState:awardExp()
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, traded)
|
||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||
-- Track level-ups for EvolveAfterBattle (BattleState:finish ->
|
||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
if #levels > 0 then
|
||||
@@ -4627,6 +4742,14 @@ function BattleState:playerMonFainted()
|
||||
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
||||
if not nextMon and self.result ~= "lose" then
|
||||
if self.oppClass == "OPP_RIVAL1" then
|
||||
-- HandlePlayerBlackOut (core.asm:1139-1146): ClearScreenArea, the pic
|
||||
-- scroll-in and DelayFrames 40 all run before Rival1WinText (#1721)
|
||||
self:actNext(function()
|
||||
self.showEnemyTrainer = self.trainerPic ~= nil
|
||||
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 64 })
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = (self.data.text and self.data.text._Rival1WinText)
|
||||
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
|
||||
@@ -5216,6 +5339,14 @@ function BattleState:finish()
|
||||
self.phase = "messages"
|
||||
return
|
||||
end
|
||||
-- EndOfBattle runs EvolutionAfterBattle on the battle screen, before the
|
||||
-- GBPalWhiteOut back to the map (end_of_battle.asm:42-45) (#1656, #213)
|
||||
if not self.evolutionsChecked then
|
||||
self.evolutionsChecked = true
|
||||
require("src.pokemon.Evolution").checkParty(self.game,
|
||||
function() self:finish() end, self.leveledUp)
|
||||
return
|
||||
end
|
||||
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||
@@ -5256,6 +5387,13 @@ function BattleState:finish()
|
||||
local result = self.result or "run"
|
||||
local onFinish = self.onFinish
|
||||
if result == "lose" then
|
||||
-- .battleOccurred skips the faint check in OAKS_LAB and re-enters the map
|
||||
-- through MapEntryAfterBattle (home/overworld.asm:343-352) (#1721)
|
||||
if BattleState.isOaksLabStarterRival(self) then
|
||||
self.game.stack:push(require("src.render.Transition").battleReturn(
|
||||
self.game, function() if onFinish then onFinish(result) end end))
|
||||
return
|
||||
end
|
||||
-- the blackout path warps to the heal point with its own transition
|
||||
if onFinish then onFinish(result) end
|
||||
return
|
||||
@@ -6120,8 +6258,10 @@ function BattleState:drawHUDs(slide)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and
|
||||
-- nothing redraws it until the next SendOutMon (#1721)
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
and slide == 0 then
|
||||
and slide == 0 and not self.player.fainted then
|
||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
||||
-- the tick at (18,10) and the triangle at (9,11)
|
||||
|
||||
@@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
local critPending, ohkoPending = info.crit, info.ohko
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
@@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if dealt > 0 then hitRow.hit = hitFx end
|
||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end
|
||||
-- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing
|
||||
-- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720)
|
||||
if critPending then
|
||||
battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!"))
|
||||
critPending = false
|
||||
end
|
||||
if ohkoPending then
|
||||
battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!"))
|
||||
ohkoPending = false
|
||||
end
|
||||
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
|
||||
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
|
||||
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
|
||||
@@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
|
||||
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
||||
ctx.brokeSub, ctx.hits = brokeSub, hits
|
||||
ctx.hitSfx = hitSfx
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
|
||||
@@ -574,6 +574,8 @@ MoveEffects.full = {
|
||||
local r = ctx.rng(0, 7)
|
||||
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
|
||||
user.trapDamage = ctx.rawDamage
|
||||
-- PlayApplyingAttackSound (animations.asm:2639) replays it each turn
|
||||
user.trapHitSfx = ctx.hitSfx
|
||||
-- remember the move so its animation can replay on each locked
|
||||
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
|
||||
user.trapMove = ctx.move.id
|
||||
|
||||
@@ -161,8 +161,10 @@ local function drawHUDs(battle, slide)
|
||||
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
||||
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
||||
-- drawCommandMenu below like the classic layout's (#540).
|
||||
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721)
|
||||
if showStatus and not battle.safari and battle.player and not battle.demo
|
||||
and not battle.showPlayerBack and slide == 0 then
|
||||
and not battle.showPlayerBack and slide == 0
|
||||
and not battle.player.fainted then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
--
|
||||
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
|
||||
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
|
||||
-- from 1-7 turns.
|
||||
-- from 2-7 turns.
|
||||
|
||||
local Damage = require("src.battle.gen2.Damage")
|
||||
local Ai = require("src.battle.gen2.Ai")
|
||||
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
||||
-- be run from or Roared away.
|
||||
Battle.BATTLETYPE_FORCESHINY = 7
|
||||
Battle.BATTLETYPE_TRAP = 9
|
||||
-- ../pokecrystal/constants/battle_constants.asm:102-103, Crystal-only appends
|
||||
Battle.BATTLETYPE_CELEBI = 11
|
||||
Battle.BATTLETYPE_SUICUNE = 12
|
||||
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
||||
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||
Battle.BATTLETYPE_CANLOSE = 1
|
||||
@@ -247,6 +250,13 @@ function Battle.new(opts)
|
||||
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
|
||||
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
|
||||
self.battleType = opts.battleType
|
||||
-- wInBattleTowerBattle (../pokecrystal/constants/ram_constants.asm:38), set
|
||||
-- around the Tower's own StartBattle (engine/events/battle_tower/
|
||||
-- battle_tower.asm:220-223) and cleared again at :253-254.
|
||||
self.inBattleTowerBattle = opts.battleTower and true or false
|
||||
-- wTimeOfDay: only BattleCommand_TimeBasedHealContinue reads it in battle
|
||||
-- (engine/battle/effect_commands.asm:6401-6404).
|
||||
self.timeOfDay = opts.timeOfDay
|
||||
self.events = {}
|
||||
self.turn = 0
|
||||
self.over = false
|
||||
@@ -765,6 +775,9 @@ end
|
||||
function Battle:battleStat(mon, key)
|
||||
local value = (mon.stats or {})[key] or 1
|
||||
if mon ~= self.player then return value end
|
||||
-- BadgeStatBoosts' second early return (engine/battle/core.asm:6786-6788):
|
||||
-- adventure badges do not follow the player into the standardised Tower.
|
||||
if self.inBattleTowerBattle then return value end
|
||||
local badge = Battle.BADGE_STAT_BOOSTS[key]
|
||||
if badge and self:hasBadge("badges", badge) then
|
||||
return Battle.boostStat(value)
|
||||
@@ -783,6 +796,8 @@ end
|
||||
-- boosts the damage. Each type appears once, so this is a plain scan.
|
||||
function Battle:badgeTypeBoost(attacker, moveType)
|
||||
if attacker ~= self.player or not moveType then return false end
|
||||
-- DoBadgeTypeBoosts' own tower guard (engine/battle/misc.asm:152-154).
|
||||
if self.inBattleTowerBattle then return false end
|
||||
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
|
||||
if row.type == moveType then
|
||||
return self:hasBadge(row.store, row.badge)
|
||||
@@ -2457,7 +2472,7 @@ end
|
||||
-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the
|
||||
-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and
|
||||
-- RegainedHealthText (:6440).
|
||||
for effect in pairs(Effects.SUN_HEAL) do
|
||||
for effect, wants in pairs(Effects.SUN_HEAL) do
|
||||
Battle.MOVE_EFFECTS[effect] = function(self, attacker)
|
||||
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
|
||||
if (attacker.hp or 0) >= maxHp then
|
||||
@@ -2466,7 +2481,9 @@ for effect in pairs(Effects.SUN_HEAL) do
|
||||
text = self:monName(attacker) .. "'s HP is full!" })
|
||||
return
|
||||
end
|
||||
local fraction = Effects.weatherHealFraction(self.weather)
|
||||
-- effect_commands.asm:6396-6417, the time of day and the weather (#1751).
|
||||
local fraction = Effects.timeBasedHealFraction(self.weather, wants,
|
||||
self.timeOfDay)
|
||||
self:heal(attacker, math.max(1, math.floor(maxHp * fraction)))
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " regained health!" })
|
||||
@@ -2913,9 +2930,12 @@ Battle.STATUSES = {
|
||||
id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp",
|
||||
inflictText = " fell asleep!",
|
||||
catchBonus = 10, catchBonusIntended = 10,
|
||||
-- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero.
|
||||
-- BattleCommand_SleepTarget's .random_loop rerolls 0 and SLP_MASK before
|
||||
-- `inc a`, so sleep opens at 2 (effect_commands.asm:3591-3598, #1707).
|
||||
-- Crystal masks the roll to %011 in the Battle Tower, capping it at 4
|
||||
-- (../pokecrystal/engine/battle/effect_commands.asm:3609-3613).
|
||||
onInflict = function(battle, mon)
|
||||
mon.statusTurns = rand(battle.random, 7) + 1
|
||||
mon.statusTurns = rand(battle.random, battle.inBattleTowerBattle and 3 or 6) + 2
|
||||
end,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battle, mon, name)
|
||||
@@ -3965,6 +3985,16 @@ function Battle:usableMoves(mon)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/battle/core.asm:3687-3694 refuses TRAP, CELEBI,
|
||||
-- FORCESHINY and SUICUNE; pokegold's :3476-3479 has only the first and third.
|
||||
function Battle:noEscapeBattleType()
|
||||
local t = self.battleType
|
||||
return t == Battle.BATTLETYPE_FORCESHINY
|
||||
or t == Battle.BATTLETYPE_TRAP
|
||||
or t == Battle.BATTLETYPE_CELEBI
|
||||
or t == Battle.BATTLETYPE_SUICUNE
|
||||
end
|
||||
|
||||
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
||||
-- based on the speed ratio and how many times you have tried this battle.
|
||||
-- Trainers never let you run.
|
||||
@@ -3978,8 +4008,7 @@ function Battle:tryRun(pSpd)
|
||||
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
|
||||
-- trainer check and any speed math. Without this, running from the Red
|
||||
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
|
||||
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
||||
or self.battleType == Battle.BATTLETYPE_TRAP then
|
||||
if self:noEscapeBattleType() then
|
||||
self:emit({ kind = "message", text = "Can't escape!" })
|
||||
self.runRefused = true
|
||||
return false
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
-- names src/battle/BattleState.lua raises on Gen 1, with the same argument
|
||||
-- order and the same payload keys (docs/mod-api-gen2-compat.md).
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local Catching = {}
|
||||
|
||||
@@ -337,6 +338,47 @@ function Catching.statusBonus(status, opts)
|
||||
return bonuses[status] or 0
|
||||
end
|
||||
|
||||
-- constants/landmark_constants.asm:24, the fallback when no cache is passed.
|
||||
Catching.LANDMARK_NATIONAL_PARK = 19
|
||||
|
||||
local function landmarkIndex(opts, id, fallback)
|
||||
local data = opts and (opts.data or (opts.battle and opts.battle.data))
|
||||
local rows = (opts and opts.landmarks)
|
||||
or (data and data.gen2Landmarks and data.gen2Landmarks.landmarks)
|
||||
local row = rows and rows[id]
|
||||
return (row and row.index) or fallback
|
||||
end
|
||||
|
||||
-- GetWorldMapLocation with the POKECENTER_2F backup-map swap
|
||||
-- (engine/pokemon/caught_data.asm:177-193) and the Bug Contest override (:73-81).
|
||||
function Catching.caughtLandmark(opts)
|
||||
opts = opts or {}
|
||||
if opts.bugContest then
|
||||
return landmarkIndex(opts, "LANDMARK_NATIONAL_PARK",
|
||||
Catching.LANDMARK_NATIONAL_PARK)
|
||||
end
|
||||
if opts.landmark then return opts.landmark end
|
||||
local map = opts.map
|
||||
if map and map.id == "POKECENTER_2F" and opts.backupMap then
|
||||
map = opts.backupMap
|
||||
end
|
||||
return (map and map.landmark) or 0
|
||||
end
|
||||
|
||||
-- SetCaughtData (engine/pokemon/caught_data.asm:163-199); no-op off Crystal.
|
||||
function Catching.stampCaughtData(mon, opts)
|
||||
opts = opts or {}
|
||||
if not Mon.hasCaughtData(opts.version) then return mon end
|
||||
local save = opts.save or (opts.battle and opts.battle.save)
|
||||
return Mon.setCaughtData(mon, {
|
||||
level = opts.level or (type(mon) == "table" and mon.level) or 0,
|
||||
timeOfDay = opts.timeOfDay,
|
||||
landmark = Catching.caughtLandmark(opts),
|
||||
playerGender = opts.playerGender
|
||||
or (save and save.player and save.player.gender),
|
||||
})
|
||||
end
|
||||
|
||||
-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate).
|
||||
-- A rate of 255 or a Master Ball is certain.
|
||||
--
|
||||
|
||||
@@ -21,8 +21,13 @@
|
||||
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
|
||||
-- records carry that as `category`.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local Damage = {}
|
||||
|
||||
-- ../pokecrystal/constants/battle_constants.asm:78
|
||||
Damage.MAX_STAT_VALUE = 999
|
||||
|
||||
-- data/battle/critical_hit_chances.asm, as "1 in N".
|
||||
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
|
||||
|
||||
@@ -60,7 +65,23 @@ end
|
||||
function Damage.applyStage(value, stage)
|
||||
local numerator, denominator = Damage.stageMultiplier(stage)
|
||||
local out = math.floor(value * numerator / denominator)
|
||||
return math.max(1, out)
|
||||
-- ../pokecrystal/engine/battle/core.asm:6739
|
||||
return math.max(1, math.min(Damage.MAX_STAT_VALUE, out))
|
||||
end
|
||||
|
||||
-- TruncateHL_BC: ../pokegold/engine/battle/effect_commands.asm:2625 runs one
|
||||
-- pass, ../pokecrystal/engine/battle/effect_commands.asm:2644 loops it.
|
||||
function Damage.truncateStats(attack, defense, fixed)
|
||||
local a = math.max(0, math.floor(attack or 0))
|
||||
local d = math.max(0, math.floor(defense or 0))
|
||||
while a > 255 or d > 255 do
|
||||
d = math.floor(d / 4)
|
||||
if d == 0 then d = 1 end
|
||||
a = math.floor(a / 4)
|
||||
if a == 0 then a = 1 end
|
||||
if not fixed then break end
|
||||
end
|
||||
return a % 256, d % 256
|
||||
end
|
||||
|
||||
-- Is this move physical? `types` is type_chart.lua's `types` table.
|
||||
@@ -169,6 +190,8 @@ end
|
||||
-- random(n) -- 0..n-1, for the variation roll
|
||||
-- screen -- Reflect/Light Screen active on the defender
|
||||
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
|
||||
-- reflectOverflowFixed -- override GameVersion.fixes()'s TruncateHL_BC
|
||||
-- answer; ../pokecrystal/engine/battle/effect_commands.asm:2644
|
||||
--
|
||||
-- Returns damage, info where info carries the pieces a battle message needs:
|
||||
-- effectiveness (x10), critical, physical, variation.
|
||||
@@ -204,6 +227,12 @@ function Damage.calc(opts)
|
||||
defense = defense * 2
|
||||
end
|
||||
|
||||
-- PlayerAttackDamage hands DamageCalc one-byte stats
|
||||
-- (../pokecrystal/engine/battle/effect_commands.asm:2604).
|
||||
local fixed = opts.reflectOverflowFixed
|
||||
if fixed == nil then fixed = GameVersion.fixes().reflectOverflow == true end
|
||||
attack, defense = Damage.truncateStats(attack, defense, fixed)
|
||||
|
||||
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
|
||||
-- Explosion halve the defence, never below 1.
|
||||
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
|
||||
|
||||
+30
-11
@@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = {
|
||||
-- power and the magnitude number the text prints.
|
||||
--
|
||||
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
||||
-- / math.random -- never hard-code 0 (that always yields Magnitude 4).
|
||||
function Effects.magnitudePower(random)
|
||||
local roll
|
||||
if type(random) == "function" then
|
||||
@@ -356,19 +356,38 @@ function Effects.sandstormHits(types)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather
|
||||
-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm
|
||||
-- (BattleCommand_Heal's .Weather block walks a multiplier index).
|
||||
-- BattleCommand_TimeBasedHealContinue's .Multipliers
|
||||
-- (engine/battle/effect_commands.asm:6450-6454).
|
||||
Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 }
|
||||
|
||||
-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1,
|
||||
-- NITE_F 2, DARKNESS_F 3.
|
||||
Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
|
||||
|
||||
-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants
|
||||
-- (engine/battle/effect_commands.asm:6362-6371).
|
||||
Effects.SUN_HEAL = {
|
||||
EFFECT_MORNING_SUN = true,
|
||||
EFFECT_SYNTHESIS = true,
|
||||
EFFECT_MOONLIGHT = true,
|
||||
EFFECT_MORNING_SUN = 0,
|
||||
EFFECT_SYNTHESIS = 1,
|
||||
EFFECT_MOONLIGHT = 2,
|
||||
}
|
||||
|
||||
function Effects.weatherHealFraction(weather)
|
||||
if weather == "sun" then return 2 / 3 end
|
||||
if weather == "rain" or weather == "sandstorm" then return 1 / 4 end
|
||||
return 1 / 2
|
||||
function Effects.timeOfDayIndex(timeOfDay)
|
||||
if type(timeOfDay) == "number" then return math.floor(timeOfDay) end
|
||||
return Effects.TIME_OF_DAY_ID[timeOfDay]
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the
|
||||
-- wrong time of day steps it down, sun steps it up, rain and sandstorm down.
|
||||
function Effects.timeBasedHealFraction(weather, wants, timeOfDay)
|
||||
local index = 3
|
||||
local now = Effects.timeOfDayIndex(timeOfDay)
|
||||
if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end
|
||||
if weather then
|
||||
index = index + 1
|
||||
if weather ~= "sun" then index = index - 2 end
|
||||
end
|
||||
return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))]
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- Perish Song
|
||||
|
||||
+102
-1
@@ -17,6 +17,7 @@
|
||||
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
|
||||
-- GROWTH_* so this needs no hardcoded table.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
|
||||
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
|
||||
@@ -32,6 +33,102 @@ Mon.PARTY_SIZE = 6
|
||||
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
|
||||
Mon.MAX_DV = 15
|
||||
|
||||
-- MON_CAUGHTDATA's two packed bytes and their masks --
|
||||
-- constants/pokemon_data_constants.asm:93-99, :120-130.
|
||||
Mon.CAUGHT_TIME_MASK = 0xc0
|
||||
Mon.CAUGHT_LEVEL_MASK = 0x3f
|
||||
Mon.CAUGHT_GENDER_MASK = 0x80
|
||||
Mon.CAUGHT_LOCATION_MASK = 0x7f
|
||||
Mon.CAUGHT_EGG_LEVEL = 1
|
||||
-- constants/landmark_constants.asm:111-113
|
||||
Mon.LANDMARK_EVENT = 0x7f
|
||||
Mon.LANDMARK_GIFT = 0x7e
|
||||
|
||||
-- Gold spends the same word on `rb_skip 2` and has no SetCaughtData --
|
||||
-- pokegold constants/pokemon_data_constants.asm:93.
|
||||
function Mon.hasCaughtData(version)
|
||||
return GameVersion.engine(version) == "crystal"
|
||||
end
|
||||
|
||||
-- wTimeOfDay is only MORN / DAY / NITE (engine/rtc/rtc.asm:48-55), stored
|
||||
-- `inc a`'d so 0 stays free -- engine/pokemon/caught_data.asm:169-172.
|
||||
local CAUGHT_TIME = { MORN = 1, DAY = 2, NITE = 3, DARK = 3 }
|
||||
|
||||
function Mon.caughtTimeOf(timeOfDay)
|
||||
if type(timeOfDay) == "string" then return CAUGHT_TIME[timeOfDay] or 0 end
|
||||
if type(timeOfDay) ~= "number" then return 0 end
|
||||
local id = math.floor(timeOfDay)
|
||||
if id < 0 or id > 3 then return 0 end
|
||||
if id > 2 then id = 2 end
|
||||
return id + 1
|
||||
end
|
||||
|
||||
-- wPlayerGender's bit 0 is PLAYERGENDER_FEMALE_F (constants/ram_constants.asm:177).
|
||||
local CAUGHT_GENDER = {
|
||||
girl = "girl", female = "girl", boy = "boy", male = "boy",
|
||||
}
|
||||
|
||||
function Mon.caughtGenderOf(gender)
|
||||
if gender == true then return "girl" end
|
||||
if gender == false then return "boy" end
|
||||
if type(gender) ~= "string" then return nil end
|
||||
return CAUGHT_GENDER[gender:lower()]
|
||||
end
|
||||
|
||||
local function landmarkByte(landmark)
|
||||
if type(landmark) ~= "number" then return 0 end
|
||||
return math.floor(landmark) % 0x80
|
||||
end
|
||||
|
||||
-- SetBoxmonOrEggmonCaughtData (engine/pokemon/caught_data.asm:168-199); the
|
||||
-- egg path hands CAUGHT_EGG_LEVEL in as `level` (:239-242).
|
||||
function Mon.setCaughtData(mon, opts)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
opts = opts or {}
|
||||
mon.caughtTime = Mon.caughtTimeOf(opts.timeOfDay)
|
||||
mon.caughtLevel = math.max(0, math.floor(opts.level or mon.level or 0))
|
||||
mon.caughtLocation = landmarkByte(opts.landmark)
|
||||
mon.caughtByGender = Mon.caughtGenderOf(opts.playerGender) or "boy"
|
||||
return mon
|
||||
end
|
||||
|
||||
-- CAUGHT_BY_UNKNOWN / GIRL / BOY, the code SetGiftMonCaughtData takes in `b`
|
||||
-- (constants/pokemon_data_constants.asm:126-128).
|
||||
Mon.CAUGHT_BY = { unknown = 0, girl = 1, boy = 2 }
|
||||
|
||||
-- SetGiftMonCaughtData (engine/pokemon/caught_data.asm:226-233): `rrc b / or
|
||||
-- LANDMARK_GIFT` puts CAUGHT_BY_BOY on $7f, LANDMARK_EVENT, not on a gender bit.
|
||||
function Mon.setGiftCaughtData(mon, caughtBy)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local code = Mon.CAUGHT_BY[tostring(caughtBy):lower()] or 0
|
||||
local rotated = math.floor(code / 2) + (code % 2) * Mon.CAUGHT_GENDER_MASK
|
||||
local unpacked = Mon.unpackCaughtData(0, Mon.LANDMARK_GIFT + rotated)
|
||||
mon.caughtTime, mon.caughtLevel = 0, 0
|
||||
mon.caughtLocation = unpacked.caughtLocation
|
||||
mon.caughtByGender = unpacked.caughtByGender
|
||||
return mon
|
||||
end
|
||||
|
||||
-- The packed pair, as engine/pokemon/caught_data.asm:169-199 stores it.
|
||||
function Mon.packCaughtData(mon)
|
||||
mon = type(mon) == "table" and mon or {}
|
||||
local time = math.floor(tonumber(mon.caughtTime) or 0) % 4
|
||||
local level = math.floor(tonumber(mon.caughtLevel) or 0) % 0x40
|
||||
local location = landmarkByte(tonumber(mon.caughtLocation) or 0)
|
||||
local female = Mon.caughtGenderOf(mon.caughtByGender) == "girl"
|
||||
return time * 0x40 + level, (female and Mon.CAUGHT_GENDER_MASK or 0) + location
|
||||
end
|
||||
|
||||
function Mon.unpackCaughtData(byte0, byte1)
|
||||
byte0, byte1 = math.floor(byte0 or 0) % 256, math.floor(byte1 or 0) % 256
|
||||
return {
|
||||
caughtTime = math.floor(byte0 / 0x40),
|
||||
caughtLevel = byte0 % 0x40,
|
||||
caughtLocation = byte1 % 0x80,
|
||||
caughtByGender = (byte1 >= Mon.CAUGHT_GENDER_MASK) and "girl" or "boy",
|
||||
}
|
||||
end
|
||||
|
||||
local function rand(a, b)
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(a, b)
|
||||
@@ -362,7 +459,11 @@ function Mon.new(data, species, level, opts)
|
||||
status = nil,
|
||||
-- 70 for a caught mon, 120 for a gift/hatched one.
|
||||
happiness = opts.happiness or 70,
|
||||
caughtLevel = level,
|
||||
caughtLevel = opts.caughtLevel or level,
|
||||
-- MON_CAUGHTDATA, absent on Gold -- constants/pokemon_data_constants.asm:93-99.
|
||||
caughtTime = opts.caughtTime,
|
||||
caughtLocation = opts.caughtLocation,
|
||||
caughtByGender = opts.caughtByGender,
|
||||
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
|
||||
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
|
||||
-- overriding the roll rather than a roll to be hooked.
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
local Base64 = require("src.core.Base64")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local StreamMD5 = require("src.mods.StreamMD5")
|
||||
|
||||
local CartManifest = {}
|
||||
|
||||
CartManifest.SCHEMA = 1
|
||||
CartManifest.EXT = ".g1rcart"
|
||||
CartManifest.FORMAT = "g1rcart"
|
||||
CartManifest.DIR = "carts"
|
||||
-- sealed: the pinned set runs exactly as pinned. sealed+: the same fixed set,
|
||||
-- but the player may switch any pinned mod on or off. open: additive.
|
||||
CartManifest.SEALS = { sealed = true, ["sealed+"] = true, open = true }
|
||||
-- Shell and label finishes the launcher cartridge can render.
|
||||
CartManifest.FINISHES = {
|
||||
sparkle = true, holo = true, ["sparkle+holo"] = true,
|
||||
}
|
||||
CartManifest.SOURCES = { github = true, gamebanana = true, ["local"] = true }
|
||||
CartManifest.ART_ENCODINGS = { base64 = true }
|
||||
CartManifest.PNG_SIGNATURE = "\137PNG\r\n\26\10"
|
||||
|
||||
CartManifest.MAX_ID = 64
|
||||
CartManifest.MAX_TITLE = 48
|
||||
CartManifest.MAX_AUTHOR = 64
|
||||
CartManifest.MAX_SUMMARY = 120
|
||||
CartManifest.MAX_LABEL = 128
|
||||
CartManifest.MAX_LABEL_ART = 1024 * 1024
|
||||
CartManifest.MAX_MODS = 64
|
||||
CartManifest.MAX_OPTIONS = 64
|
||||
CartManifest.MAX_OPTION_KEY = 64
|
||||
CartManifest.MAX_OPTION_TEXT = 256
|
||||
|
||||
local function trim(text)
|
||||
return text:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function isId(value)
|
||||
return type(value) == "string" and value ~= "" and #value <= CartManifest.MAX_ID
|
||||
and value:match("^[%w_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function isRepo(value)
|
||||
if type(value) ~= "string" then return false end
|
||||
local owner, name = value:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
||||
return owner ~= nil and name ~= nil
|
||||
end
|
||||
|
||||
local function isHex(value, width)
|
||||
return type(value) == "string" and #value == width
|
||||
and value:match("^[0-9a-f]+$") ~= nil
|
||||
end
|
||||
|
||||
local function isCount(value)
|
||||
return type(value) == "number" and value > 0 and value % 1 == 0
|
||||
end
|
||||
|
||||
local function isSemver(value)
|
||||
return type(value) == "string" and Semver.parse(value) ~= nil
|
||||
end
|
||||
|
||||
local function parseOptions(raw, label)
|
||||
if raw == nil then return nil end
|
||||
if type(raw) ~= "table" then
|
||||
return nil, label .. " options must be a table"
|
||||
end
|
||||
local keys = {}
|
||||
for key in pairs(raw) do
|
||||
if type(key) ~= "string" then
|
||||
return nil, label .. " option keys must be strings"
|
||||
end
|
||||
if key == "" or #key > CartManifest.MAX_OPTION_KEY then
|
||||
return nil, ("%s option keys must be 1 to %d characters")
|
||||
:format(label, CartManifest.MAX_OPTION_KEY)
|
||||
end
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
if #keys > CartManifest.MAX_OPTIONS then
|
||||
return nil, ("%s carries more than %d options")
|
||||
:format(label, CartManifest.MAX_OPTIONS)
|
||||
end
|
||||
local out = {}
|
||||
for _, key in ipairs(keys) do
|
||||
local value = raw[key]
|
||||
local kind = type(value)
|
||||
if kind == "string" then
|
||||
if #value > CartManifest.MAX_OPTION_TEXT then
|
||||
return nil, ("%s option %q must be %d characters or fewer")
|
||||
:format(label, key, CartManifest.MAX_OPTION_TEXT)
|
||||
end
|
||||
elseif kind ~= "number" and kind ~= "boolean" then
|
||||
return nil, ("%s option %q must be a string, number or boolean")
|
||||
:format(label, key)
|
||||
end
|
||||
out[key] = value
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function parseMod(raw, index, seen)
|
||||
local label = ("cart mod #%d"):format(index)
|
||||
if type(raw) ~= "table" then return nil, label .. " must be a table" end
|
||||
if not isId(raw.id) then
|
||||
return nil, ("%s id must be 1 to %d characters of letters, numbers, _ or -")
|
||||
:format(label, CartManifest.MAX_ID)
|
||||
end
|
||||
label = ("cart mod %q"):format(raw.id)
|
||||
if seen[raw.id] then return nil, label .. " is pinned twice" end
|
||||
seen[raw.id] = true
|
||||
|
||||
local source = raw.source
|
||||
if type(source) ~= "string" or not CartManifest.SOURCES[source] then
|
||||
return nil, label .. " source must be github, gamebanana or local"
|
||||
end
|
||||
|
||||
local entry = { id = raw.id, source = source }
|
||||
if source == "github" then
|
||||
if not isRepo(raw.repo) then
|
||||
return nil, label .. " repo must be owner/name"
|
||||
end
|
||||
if not isSemver(raw.version) then
|
||||
return nil, label .. " version must be a semantic version"
|
||||
end
|
||||
if not isHex(raw.sha256, 64) then
|
||||
return nil, label .. " sha256 must be 64 lowercase hex characters"
|
||||
end
|
||||
entry.repo = raw.repo
|
||||
entry.version = trim(raw.version)
|
||||
entry.sha256 = raw.sha256
|
||||
elseif source == "local" then
|
||||
if not isSemver(raw.version) then
|
||||
return nil, label .. " version must be a semantic version"
|
||||
end
|
||||
entry.version = trim(raw.version)
|
||||
else
|
||||
if not isCount(raw.mod) then
|
||||
return nil, label .. " mod must be a positive integer"
|
||||
end
|
||||
if not isCount(raw.file) then
|
||||
return nil, label .. " file must be a positive integer"
|
||||
end
|
||||
if not isHex(raw.md5, 32) then
|
||||
return nil, label .. " md5 must be 32 lowercase hex characters"
|
||||
end
|
||||
entry.mod = raw.mod
|
||||
entry.file = raw.file
|
||||
entry.md5 = raw.md5
|
||||
end
|
||||
|
||||
if raw.enabled ~= nil and type(raw.enabled) ~= "boolean" then
|
||||
return nil, label .. " enabled must be true or false"
|
||||
end
|
||||
-- Only a declared OFF is carried: an absent or explicit true is the default,
|
||||
-- so a cart that says nothing serializes exactly as it did before this field.
|
||||
if raw.enabled == false then entry.enabled = false end
|
||||
|
||||
local options, err = parseOptions(raw.options, label)
|
||||
if err then return nil, err end
|
||||
entry.options = options
|
||||
return entry
|
||||
end
|
||||
|
||||
-- A pin the cart ships switched off is installed and configured but not run.
|
||||
function CartManifest.modEnabled(entry)
|
||||
return type(entry) == "table" and entry.enabled ~= false
|
||||
end
|
||||
|
||||
local function parseOrder(raw, mods)
|
||||
local out = {}
|
||||
if raw == nil then
|
||||
for i, entry in ipairs(mods) do out[i] = entry.id end
|
||||
return out
|
||||
end
|
||||
if type(raw) ~= "table" then return nil, "cart load_order must be an array" end
|
||||
if #raw ~= #mods then
|
||||
return nil, "cart load_order must list every pinned mod exactly once"
|
||||
end
|
||||
local pinned, seen = {}, {}
|
||||
for _, entry in ipairs(mods) do pinned[entry.id] = true end
|
||||
for i = 1, #raw do
|
||||
local id = raw[i]
|
||||
if type(id) ~= "string" or not pinned[id] then
|
||||
return nil, ("cart load_order names %s, which the cart does not pin")
|
||||
:format(tostring(id))
|
||||
end
|
||||
if seen[id] then
|
||||
return nil, ("cart load_order names %s twice"):format(id)
|
||||
end
|
||||
seen[id] = true
|
||||
out[i] = id
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function CartManifest.parse(tbl)
|
||||
if type(tbl) ~= "table" then return nil, "cart must be a table" end
|
||||
|
||||
if not isId(tbl.id) then
|
||||
return nil, ("cart id must be 1 to %d characters of letters, numbers, _ or -")
|
||||
:format(CartManifest.MAX_ID)
|
||||
end
|
||||
|
||||
if type(tbl.title) ~= "string" then return nil, "cart title is required" end
|
||||
local title = trim(tbl.title)
|
||||
if title == "" or #title > CartManifest.MAX_TITLE then
|
||||
return nil, ("cart title must be 1 to %d characters"):format(CartManifest.MAX_TITLE)
|
||||
end
|
||||
|
||||
if not isSemver(tbl.version) then
|
||||
return nil, "cart version must be a semantic version"
|
||||
end
|
||||
|
||||
if type(tbl.author) ~= "string" then return nil, "cart author is required" end
|
||||
local author = trim(tbl.author)
|
||||
if author == "" or #author > CartManifest.MAX_AUTHOR then
|
||||
return nil, ("cart author must be 1 to %d characters"):format(CartManifest.MAX_AUTHOR)
|
||||
end
|
||||
|
||||
local repo = nil
|
||||
if tbl.repo ~= nil then
|
||||
if not isRepo(tbl.repo) then return nil, "cart repo must be owner/name" end
|
||||
repo = tbl.repo
|
||||
end
|
||||
|
||||
local summary = nil
|
||||
if tbl.summary ~= nil then
|
||||
if type(tbl.summary) ~= "string" then
|
||||
return nil, "cart summary must be a string"
|
||||
end
|
||||
summary = trim(tbl.summary)
|
||||
if #summary > CartManifest.MAX_SUMMARY then
|
||||
return nil, ("cart summary must be %d characters or fewer")
|
||||
:format(CartManifest.MAX_SUMMARY)
|
||||
end
|
||||
end
|
||||
|
||||
local shell = type(tbl.shell) == "string" and tbl.shell:match("^#(%x%x%x%x%x%x)$")
|
||||
if not shell then return nil, "cart shell must be a #RRGGBB colour" end
|
||||
shell = "#" .. shell:lower()
|
||||
|
||||
local label = nil
|
||||
if tbl.label ~= nil then
|
||||
if type(tbl.label) ~= "string" or #tbl.label > CartManifest.MAX_LABEL then
|
||||
return nil, ("cart label must be a path of %d characters or fewer")
|
||||
:format(CartManifest.MAX_LABEL)
|
||||
end
|
||||
label = SafePath.safe(tbl.label)
|
||||
if not label then return nil, "cart label must stay inside the cart" end
|
||||
end
|
||||
|
||||
if type(tbl.base) ~= "string" or not GameVersion.VERSIONS[tbl.base] then
|
||||
return nil, "cart base must name a game this engine knows"
|
||||
end
|
||||
|
||||
local engine = nil
|
||||
if tbl.engine ~= nil then
|
||||
if type(tbl.engine) ~= "string" or trim(tbl.engine) == "" then
|
||||
return nil, "cart engine must be a non-empty version range"
|
||||
end
|
||||
engine = trim(tbl.engine)
|
||||
end
|
||||
|
||||
local speeds
|
||||
if tbl.speeds ~= nil then
|
||||
if type(tbl.speeds) ~= "table" or #tbl.speeds == 0 then
|
||||
return nil, "cart speeds must be a non-empty array of multipliers"
|
||||
end
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local valid, seen = {}, {}
|
||||
for _, want in ipairs(GameSpeed.LEVELS) do
|
||||
for _, have in ipairs(tbl.speeds) do
|
||||
if have == want and not seen[want] then
|
||||
seen[want] = true
|
||||
valid[#valid + 1] = want
|
||||
end
|
||||
end
|
||||
end
|
||||
if #valid ~= #tbl.speeds then
|
||||
return nil, "cart speeds must all be GameSpeed levels"
|
||||
end
|
||||
speeds = valid
|
||||
end
|
||||
|
||||
local finish = tbl.finish
|
||||
if finish ~= nil then
|
||||
if type(finish) ~= "string" or not CartManifest.FINISHES[finish] then
|
||||
return nil, "cart finish must be sparkle, holo or sparkle+holo"
|
||||
end
|
||||
end
|
||||
|
||||
local seal = tbl.seal
|
||||
if seal == nil then seal = "sealed" end
|
||||
if type(seal) ~= "string" or not CartManifest.SEALS[seal] then
|
||||
return nil, "cart seal must be sealed, sealed+ or open"
|
||||
end
|
||||
|
||||
-- The author's preferred game settings, seeded once per cart
|
||||
-- (SaveData.CART_OPTION_KEYS); unknown keys are kept but ignored.
|
||||
local cartOptions, cartOptErr = parseOptions(tbl.options, "cart")
|
||||
if cartOptErr then return nil, cartOptErr end
|
||||
|
||||
if type(tbl.mods) ~= "table" then return nil, "cart mods must be an array" end
|
||||
local count = #tbl.mods
|
||||
if count < 1 or count > CartManifest.MAX_MODS then
|
||||
return nil, ("cart must pin 1 to %d mods"):format(CartManifest.MAX_MODS)
|
||||
end
|
||||
local mods, seen = {}, {}
|
||||
for i = 1, count do
|
||||
local entry, err = parseMod(tbl.mods[i], i, seen)
|
||||
if not entry then return nil, err end
|
||||
mods[i] = entry
|
||||
end
|
||||
|
||||
local order, orderErr = parseOrder(tbl.load_order, mods)
|
||||
if not order then return nil, orderErr end
|
||||
|
||||
return {
|
||||
id = tbl.id,
|
||||
title = title,
|
||||
version = trim(tbl.version),
|
||||
author = author,
|
||||
repo = repo,
|
||||
summary = summary,
|
||||
shell = shell,
|
||||
finish = finish,
|
||||
speeds = speeds,
|
||||
label = label,
|
||||
base = tbl.base,
|
||||
engine = engine,
|
||||
seal = seal,
|
||||
options = cartOptions,
|
||||
mods = mods,
|
||||
load_order = order,
|
||||
}
|
||||
end
|
||||
|
||||
function CartManifest.parseLabelArt(raw)
|
||||
if raw == nil then return nil, "cart carries no label art" end
|
||||
if type(raw) ~= "table" then return nil, "cart label art must be a table" end
|
||||
|
||||
if type(raw.encoding) ~= "string" or not CartManifest.ART_ENCODINGS[raw.encoding] then
|
||||
return nil, "cart label art encoding must be base64"
|
||||
end
|
||||
if type(raw.data) ~= "string" or raw.data == "" then
|
||||
return nil, "cart label art data must be a base64 string"
|
||||
end
|
||||
|
||||
local tooBig = ("cart label art must be %d bytes or fewer")
|
||||
:format(CartManifest.MAX_LABEL_ART)
|
||||
if #raw.data > math.ceil(CartManifest.MAX_LABEL_ART / 3) * 4 then
|
||||
return nil, tooBig
|
||||
end
|
||||
|
||||
local bytes, err = Base64.decode(raw.data)
|
||||
if not bytes then return nil, "cart label art " .. err end
|
||||
if #bytes > CartManifest.MAX_LABEL_ART then return nil, tooBig end
|
||||
if not isCount(raw.bytes) or raw.bytes ~= #bytes then
|
||||
return nil, ("cart label art declares %s bytes but decodes to %d")
|
||||
:format(tostring(raw.bytes), #bytes)
|
||||
end
|
||||
if bytes:sub(1, #CartManifest.PNG_SIGNATURE) ~= CartManifest.PNG_SIGNATURE then
|
||||
return nil, "cart label art must be a PNG"
|
||||
end
|
||||
|
||||
local name = nil
|
||||
if raw.name ~= nil then
|
||||
if type(raw.name) ~= "string" or #raw.name > CartManifest.MAX_LABEL then
|
||||
return nil, ("cart label art name must be a path of %d characters or fewer")
|
||||
:format(CartManifest.MAX_LABEL)
|
||||
end
|
||||
name = SafePath.safe(raw.name)
|
||||
if not name then return nil, "cart label art name must stay inside the cart" end
|
||||
end
|
||||
|
||||
return { name = name, encoding = raw.encoding, bytes = raw.bytes, data = raw.data },
|
||||
nil, bytes
|
||||
end
|
||||
|
||||
function CartManifest.labelArtBytes(cart)
|
||||
if type(cart) ~= "table" then return nil, "cart must be a table" end
|
||||
local art, err, bytes = CartManifest.parseLabelArt(cart.labelArt)
|
||||
if not art then return nil, err end
|
||||
return bytes, art.name
|
||||
end
|
||||
|
||||
function CartManifest.publishable(cart)
|
||||
if type(cart) ~= "table" or type(cart.mods) ~= "table" then
|
||||
return false, "a cart must be parsed before it can be published"
|
||||
end
|
||||
local unpinned = {}
|
||||
for _, entry in ipairs(cart.mods) do
|
||||
if type(entry) == "table" and entry.source == "local" then
|
||||
unpinned[#unpinned + 1] = tostring(entry.id)
|
||||
end
|
||||
end
|
||||
if #unpinned == 0 then return true end
|
||||
table.sort(unpinned)
|
||||
return false, ("%s %s pinned to this install only, so nobody else can fetch %s: publish needs a repo and an archive hash for %s")
|
||||
:format(table.concat(unpinned, ", "),
|
||||
#unpinned == 1 and "is" or "are",
|
||||
#unpinned == 1 and "it" or "them",
|
||||
#unpinned == 1 and "it" or "each")
|
||||
end
|
||||
|
||||
local function number(value)
|
||||
return ("%.17g"):format(value)
|
||||
end
|
||||
|
||||
local function writeText(out, prefix, text)
|
||||
out[#out + 1] = ("%s%d:%s"):format(prefix, #text, text)
|
||||
end
|
||||
|
||||
local function writeValue(out, value)
|
||||
local kind = type(value)
|
||||
if kind == "number" then
|
||||
out[#out + 1] = "#" .. number(value)
|
||||
elseif kind == "boolean" then
|
||||
out[#out + 1] = value and "T" or "F"
|
||||
elseif kind == "table" then
|
||||
-- Length-prefixed so a list can never collide with another field's text.
|
||||
out[#out + 1] = "*" .. tostring(#value)
|
||||
for _, item in ipairs(value) do writeValue(out, item) end
|
||||
else
|
||||
writeText(out, "$", tostring(value))
|
||||
end
|
||||
end
|
||||
|
||||
local function writeField(out, name, value)
|
||||
if value == nil then return end
|
||||
writeText(out, ".", name)
|
||||
writeValue(out, value)
|
||||
end
|
||||
|
||||
local CART_FIELDS = { "author", "base", "engine", "finish", "id", "label",
|
||||
"repo", "seal", "shell", "speeds", "summary", "title",
|
||||
"version" }
|
||||
local MOD_FIELDS = { "enabled", "file", "id", "md5", "mod", "repo", "sha256",
|
||||
"source", "version" }
|
||||
|
||||
local function writeOptions(out, marker, options)
|
||||
out[#out + 1] = marker
|
||||
local keys = {}
|
||||
for key in pairs(options or {}) do keys[#keys + 1] = key end
|
||||
table.sort(keys)
|
||||
for _, key in ipairs(keys) do writeField(out, key, options[key]) end
|
||||
end
|
||||
|
||||
function CartManifest.canonical(cart)
|
||||
local out = { "[cart]" }
|
||||
for _, field in ipairs(CART_FIELDS) do writeField(out, field, cart[field]) end
|
||||
-- Absent when the cart ships no settings, so a cart written before this
|
||||
-- field hashes byte for byte as it did.
|
||||
if cart.options ~= nil then writeOptions(out, "[cart_options]", cart.options) end
|
||||
out[#out + 1] = "[mods]"
|
||||
for _, entry in ipairs(cart.mods or {}) do
|
||||
writeText(out, "@", tostring(entry.id))
|
||||
for _, field in ipairs(MOD_FIELDS) do writeField(out, field, entry[field]) end
|
||||
writeOptions(out, "[options]", entry.options)
|
||||
end
|
||||
out[#out + 1] = "[order]"
|
||||
for _, id in ipairs(cart.load_order or {}) do writeText(out, "@", tostring(id)) end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function CartManifest.hash(cart)
|
||||
return StreamMD5.new():update(CartManifest.canonical(cart)):final()
|
||||
end
|
||||
|
||||
function CartManifest.encode(cart)
|
||||
return SaveSerializer.encode({
|
||||
format = CartManifest.FORMAT,
|
||||
formatVersion = CartManifest.SCHEMA,
|
||||
labelArt = CartManifest.parseLabelArt(cart.labelArt),
|
||||
-- Every field parse() keeps: a name missing here is a field the file
|
||||
-- round trip silently drops, as finish and speeds were.
|
||||
cart = { id = cart.id, title = cart.title, version = cart.version,
|
||||
author = cart.author, repo = cart.repo, summary = cart.summary,
|
||||
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||
label = cart.label, base = cart.base,
|
||||
engine = cart.engine, seal = cart.seal, options = cart.options,
|
||||
mods = cart.mods, load_order = cart.load_order },
|
||||
})
|
||||
end
|
||||
|
||||
function CartManifest.decode(str)
|
||||
if type(str) ~= "string" or str == "" then return nil, "EMPTY FILE" end
|
||||
local data = SaveSerializer.decode(str)
|
||||
if type(data) ~= "table" then return nil, "BAD FILE" end
|
||||
if data.format ~= CartManifest.FORMAT then return nil, "NOT A CART" end
|
||||
if data.formatVersion ~= CartManifest.SCHEMA then
|
||||
return nil, ("unknown cart schema %s"):format(tostring(data.formatVersion))
|
||||
end
|
||||
local cart, err = CartManifest.parse(data.cart)
|
||||
if not cart then return nil, err end
|
||||
cart.labelArt = CartManifest.parseLabelArt(data.labelArt)
|
||||
return cart
|
||||
end
|
||||
|
||||
return CartManifest
|
||||
@@ -0,0 +1,355 @@
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Semver = require("src.mods.Semver")
|
||||
|
||||
local CartStore = {}
|
||||
|
||||
CartStore.DIR = CartManifest.DIR
|
||||
CartStore.EXT = CartManifest.EXT
|
||||
CartStore.OPTIONS_KEY = "carts"
|
||||
CartStore.UNPINNED_VERSION = "0.0.0"
|
||||
|
||||
local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal",
|
||||
"shell", "finish", "speeds", "summary", "hash", "file" }
|
||||
|
||||
local function fsOr(fs)
|
||||
return fs or (love and love.filesystem) or nil
|
||||
end
|
||||
|
||||
local function safeId(id)
|
||||
return type(id) == "string" and id ~= "" and #id <= CartManifest.MAX_ID
|
||||
and id:match("^[%w_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function fileFor(id)
|
||||
return CartStore.DIR .. "/" .. id .. CartStore.EXT
|
||||
end
|
||||
CartStore.fileFor = fileFor
|
||||
|
||||
local function readOptions(fs)
|
||||
local ok, opts = pcall(SaveData.loadOptions, fs)
|
||||
if not ok or type(opts) ~= "table" then return {} end
|
||||
return opts
|
||||
end
|
||||
|
||||
local function writeOptions(opts, fs)
|
||||
local ok = pcall(SaveData.saveOptions, opts, fs)
|
||||
return ok and true or false
|
||||
end
|
||||
|
||||
local function registry(opts)
|
||||
local reg = opts[CartStore.OPTIONS_KEY]
|
||||
return type(reg) == "table" and reg or nil
|
||||
end
|
||||
|
||||
local function ensureRegistry(opts)
|
||||
if type(opts[CartStore.OPTIONS_KEY]) ~= "table" then
|
||||
opts[CartStore.OPTIONS_KEY] = {}
|
||||
end
|
||||
return opts[CartStore.OPTIONS_KEY]
|
||||
end
|
||||
|
||||
local function recordFor(cart, hash)
|
||||
return { id = cart.id, title = cart.title, version = cart.version,
|
||||
author = cart.author, base = cart.base, seal = cart.seal,
|
||||
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||
summary = cart.summary,
|
||||
hash = hash, file = fileFor(cart.id) }
|
||||
end
|
||||
|
||||
local function sameValue(a, b)
|
||||
if type(a) == "table" and type(b) == "table" then
|
||||
if #a ~= #b then return false end
|
||||
for i = 1, #a do
|
||||
if a[i] ~= b[i] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
return a == b
|
||||
end
|
||||
|
||||
local function sameRecord(a, b)
|
||||
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||
for _, field in ipairs(RECORD_FIELDS) do
|
||||
if not sameValue(a[field], b[field]) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function entryFor(cart, hash)
|
||||
return { id = cart.id, title = cart.title, version = cart.version,
|
||||
author = cart.author, base = cart.base, seal = cart.seal,
|
||||
shell = cart.shell, finish = cart.finish, speeds = cart.speeds,
|
||||
summary = cart.summary, label = cart.label,
|
||||
cart = cart, cartHash = hash, file = fileFor(cart.id) }
|
||||
end
|
||||
|
||||
local function readCart(fs, id)
|
||||
if not safeId(id) then return nil, "unknown cart id" end
|
||||
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
|
||||
local path = fileFor(id)
|
||||
if fs.getInfo and not fs.getInfo(path) then
|
||||
return nil, ("cart %q is not installed"):format(id)
|
||||
end
|
||||
local body = fs.read(path)
|
||||
if type(body) ~= "string" or body == "" then
|
||||
return nil, ("cart %q is not installed"):format(id)
|
||||
end
|
||||
local ok, cart, err = pcall(CartManifest.decode, body)
|
||||
if not ok then return nil, "BAD CART" end
|
||||
if not cart then return nil, err or "BAD CART" end
|
||||
if cart.id ~= id then
|
||||
return nil, ("cart file %s names %q"):format(path, tostring(cart.id))
|
||||
end
|
||||
local hashed, hash = pcall(CartManifest.hash, cart)
|
||||
if not hashed then return nil, "BAD CART" end
|
||||
return cart, hash
|
||||
end
|
||||
|
||||
local function strayIds(fs, seen)
|
||||
local out = {}
|
||||
if not (fs and fs.getDirectoryItems) then return out end
|
||||
if fs.getInfo and not fs.getInfo(CartStore.DIR) then return out end
|
||||
local ok, items = pcall(fs.getDirectoryItems, CartStore.DIR)
|
||||
if not ok then return out end
|
||||
for _, name in ipairs(items or {}) do
|
||||
if type(name) == "string" and name:sub(-#CartStore.EXT) == CartStore.EXT then
|
||||
local id = name:sub(1, #name - #CartStore.EXT)
|
||||
if safeId(id) and not seen[id] then
|
||||
seen[id] = true
|
||||
out[#out + 1] = id
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
function CartStore.index(fs)
|
||||
local opts = readOptions(fsOr(fs))
|
||||
local reg = registry(opts) or {}
|
||||
local out = {}
|
||||
for id, record in pairs(reg) do
|
||||
if safeId(id) and type(record) == "table" then
|
||||
local row = { id = id, file = fileFor(id) }
|
||||
for _, field in ipairs(RECORD_FIELDS) do
|
||||
if record[field] ~= nil then row[field] = record[field] end
|
||||
end
|
||||
row.id, row.title = id, record.title or id
|
||||
out[#out + 1] = row
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
|
||||
if at ~= bt then return at < bt end
|
||||
return a.id < b.id
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
function CartStore.list(fs)
|
||||
fs = fsOr(fs)
|
||||
local rows = {}
|
||||
if not fs then return rows end
|
||||
local opts = readOptions(fs)
|
||||
local reg = registry(opts) or {}
|
||||
local ids, seen = {}, {}
|
||||
for id in pairs(reg) do
|
||||
if safeId(id) and not seen[id] then
|
||||
seen[id] = true
|
||||
ids[#ids + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(ids)
|
||||
for _, id in ipairs(strayIds(fs, seen)) do ids[#ids + 1] = id end
|
||||
for _, id in ipairs(ids) do
|
||||
local cart, hash = readCart(fs, id)
|
||||
if cart then rows[#rows + 1] = entryFor(cart, hash) end
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
|
||||
if at ~= bt then return at < bt end
|
||||
return a.id < b.id
|
||||
end)
|
||||
local healed, changed = {}, false
|
||||
for _, row in ipairs(rows) do
|
||||
healed[row.id] = recordFor(row.cart, row.cartHash)
|
||||
if not sameRecord(reg[row.id], healed[row.id]) then changed = true end
|
||||
end
|
||||
for id in pairs(reg) do
|
||||
if healed[id] == nil then changed = true end
|
||||
end
|
||||
if changed then
|
||||
opts[CartStore.OPTIONS_KEY] = healed
|
||||
writeOptions(opts, fs)
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function CartStore.listFor(version, fs)
|
||||
local out = {}
|
||||
for _, row in ipairs(CartStore.list(fs)) do
|
||||
if row.base == version then out[#out + 1] = row end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function CartStore.get(id, fs)
|
||||
return readCart(fsOr(fs), id)
|
||||
end
|
||||
|
||||
function CartStore.labelArt(id, fs)
|
||||
local cart, err = readCart(fsOr(fs), id)
|
||||
if not cart then return nil, err end
|
||||
return CartManifest.labelArtBytes(cart)
|
||||
end
|
||||
|
||||
function CartStore.export(id, fs)
|
||||
local cart, err = readCart(fsOr(fs), id)
|
||||
if not cart then return nil, err end
|
||||
return CartManifest.encode(cart), CartManifest.hash(cart)
|
||||
end
|
||||
|
||||
function CartStore.install(bytes, fs)
|
||||
fs = fsOr(fs)
|
||||
if not (fs and fs.write) then return nil, "NO FILESYSTEM" end
|
||||
local ok, cart, err = pcall(CartManifest.decode, bytes)
|
||||
if not ok then return nil, "BAD CART" end
|
||||
if not cart then return nil, err or "BAD CART" end
|
||||
local existing = readCart(fs, cart.id)
|
||||
if existing then
|
||||
local order = Semver.compare(cart.version, existing.version)
|
||||
if order and order < 0 then
|
||||
return nil, ("%s %s is older than the installed %s")
|
||||
:format(cart.title, cart.version, existing.version)
|
||||
end
|
||||
end
|
||||
if fs.createDirectory then fs.createDirectory(CartStore.DIR) end
|
||||
local wrote, writeErr = fs.write(fileFor(cart.id), CartManifest.encode(cart))
|
||||
if not wrote then return nil, tostring(writeErr) end
|
||||
local hash = CartManifest.hash(cart)
|
||||
local opts = readOptions(fs)
|
||||
ensureRegistry(opts)[cart.id] = recordFor(cart, hash)
|
||||
writeOptions(opts, fs)
|
||||
return cart, hash
|
||||
end
|
||||
|
||||
function CartStore.uninstall(id, fs)
|
||||
fs = fsOr(fs)
|
||||
if not safeId(id) then return nil, "unknown cart id" end
|
||||
if not fs then return nil, "NO FILESYSTEM" end
|
||||
local path = fileFor(id)
|
||||
local present = not fs.getInfo or fs.getInfo(path) ~= nil
|
||||
if present and fs.read and fs.read(path) == nil then present = false end
|
||||
local opts = readOptions(fs)
|
||||
local reg = registry(opts)
|
||||
local known = reg ~= nil and reg[id] ~= nil
|
||||
if not present and not known then
|
||||
return nil, ("cart %q is not installed"):format(id)
|
||||
end
|
||||
if present and fs.remove then fs.remove(path) end
|
||||
if known then
|
||||
reg[id] = nil
|
||||
writeOptions(opts, fs)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function manifestOf(row)
|
||||
return type(row.manifest) == "table" and row.manifest or nil
|
||||
end
|
||||
|
||||
local function textOf(...)
|
||||
for i = 1, select("#", ...) do
|
||||
local value = select(i, ...)
|
||||
if type(value) == "string" and value ~= "" then return value end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pinRepo(row)
|
||||
local m = manifestOf(row)
|
||||
return textOf(row.github, m and m.github)
|
||||
end
|
||||
|
||||
local function pinHash(row)
|
||||
local m = manifestOf(row)
|
||||
local hash = textOf(row.sha256, row.archiveSha256,
|
||||
m and m.sha256, m and m.archiveSha256)
|
||||
if hash and #hash == 64 and hash:match("^[0-9a-f]+$") then return hash end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function frozenOptions(bucket)
|
||||
if type(bucket) ~= "table" then return nil end
|
||||
local out, any = {}, false
|
||||
for key, value in pairs(bucket) do
|
||||
local kind = type(value)
|
||||
if type(key) == "string" and key ~= ""
|
||||
and (kind == "string" or kind == "number" or kind == "boolean") then
|
||||
out[key] = value
|
||||
any = true
|
||||
end
|
||||
end
|
||||
return any and out or nil
|
||||
end
|
||||
|
||||
local function pinReason(repo, version, semver, hash)
|
||||
local why = {}
|
||||
if not repo then why[#why + 1] = "no GitHub repo is recorded" end
|
||||
if not semver then
|
||||
why[#why + 1] = ("version %s is not a semantic version, so it pins as %s")
|
||||
:format(version and ("%q"):format(version) or "is missing",
|
||||
CartStore.UNPINNED_VERSION)
|
||||
end
|
||||
if repo and semver and not hash then
|
||||
why[#why + 1] = "no archive hash is known yet"
|
||||
end
|
||||
return table.concat(why, " and ")
|
||||
end
|
||||
|
||||
function CartStore.capture(identity, available, modOptions)
|
||||
if type(identity) ~= "table" then return nil, "cart identity is required" end
|
||||
local mods, order, unresolved = {}, {}, {}
|
||||
for _, row in ipairs(available or {}) do
|
||||
if type(row) == "table" and safeId(row.id) then
|
||||
local m = manifestOf(row)
|
||||
local version = textOf(row.version, m and m.version)
|
||||
local semver = version and Semver.parse(version) and version or nil
|
||||
local repo = pinRepo(row)
|
||||
local hash = pinHash(row)
|
||||
local entry
|
||||
if repo and semver and hash then
|
||||
entry = { id = row.id, source = "github", repo = repo,
|
||||
version = semver, sha256 = hash }
|
||||
else
|
||||
entry = { id = row.id, source = "local",
|
||||
version = semver or CartStore.UNPINNED_VERSION }
|
||||
unresolved[#unresolved + 1] = {
|
||||
id = row.id,
|
||||
name = textOf(row.name, m and m.name) or row.id,
|
||||
version = version,
|
||||
reason = pinReason(repo, version, semver, hash),
|
||||
}
|
||||
end
|
||||
-- A switched-off mod is pinned OFF rather than dropped, so the cart
|
||||
-- still ships it with the author's settings.
|
||||
if not row.enabled then entry.enabled = false end
|
||||
entry.options = frozenOptions(modOptions and modOptions[row.id])
|
||||
mods[#mods + 1] = entry
|
||||
order[#order + 1] = row.id
|
||||
end
|
||||
end
|
||||
local cart, err = CartManifest.parse({
|
||||
id = identity.id, title = identity.title, version = identity.version,
|
||||
author = identity.author, repo = identity.repo, summary = identity.summary,
|
||||
shell = identity.shell, finish = identity.finish, speeds = identity.speeds,
|
||||
label = identity.label, base = identity.base,
|
||||
engine = identity.engine, seal = identity.seal, options = identity.options,
|
||||
mods = mods, load_order = order,
|
||||
})
|
||||
if not cart then return nil, err end
|
||||
return cart, unresolved
|
||||
end
|
||||
|
||||
return CartStore
|
||||
@@ -0,0 +1,75 @@
|
||||
local Base64 = {}
|
||||
|
||||
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local PAD = 61
|
||||
|
||||
local ENC, DEC = {}, {}
|
||||
for i = 0, 63 do
|
||||
local c = ALPHABET:sub(i + 1, i + 1)
|
||||
ENC[i] = c
|
||||
DEC[c:byte()] = i
|
||||
end
|
||||
|
||||
local floor = math.floor
|
||||
local char = string.char
|
||||
local concat = table.concat
|
||||
|
||||
function Base64.encode(bytes)
|
||||
if type(bytes) ~= "string" then return nil, "base64 input must be a string" end
|
||||
local out, n, i = {}, #bytes, 1
|
||||
while i + 2 <= n do
|
||||
local a, b, c = bytes:byte(i, i + 2)
|
||||
local word = a * 65536 + b * 256 + c
|
||||
out[#out + 1] = ENC[floor(word / 262144)] .. ENC[floor(word / 4096) % 64]
|
||||
.. ENC[floor(word / 64) % 64] .. ENC[word % 64]
|
||||
i = i + 3
|
||||
end
|
||||
local rest = n - i + 1
|
||||
if rest == 1 then
|
||||
local a = bytes:byte(i)
|
||||
out[#out + 1] = ENC[floor(a / 4)] .. ENC[(a % 4) * 16] .. "=="
|
||||
elseif rest == 2 then
|
||||
local a, b = bytes:byte(i, i + 1)
|
||||
local word = a * 256 + b
|
||||
out[#out + 1] = ENC[floor(word / 1024)] .. ENC[floor(word / 16) % 64]
|
||||
.. ENC[(word % 16) * 4] .. "="
|
||||
end
|
||||
return concat(out)
|
||||
end
|
||||
|
||||
function Base64.decode(text)
|
||||
if type(text) ~= "string" then return nil, "base64 input must be a string" end
|
||||
local n = #text
|
||||
if n % 4 ~= 0 then return nil, "base64 length must be a multiple of four" end
|
||||
if n == 0 then return "" end
|
||||
local out = {}
|
||||
local last = n - 3
|
||||
for i = 1, n, 4 do
|
||||
local b1, b2, b3, b4 = text:byte(i, i + 3)
|
||||
local v1, v2 = DEC[b1], DEC[b2]
|
||||
if not v1 or not v2 then
|
||||
return nil, "base64 holds a character outside the alphabet"
|
||||
end
|
||||
if i == last and b3 == PAD then
|
||||
if b4 ~= PAD then return nil, "base64 padding is malformed" end
|
||||
if v2 % 16 ~= 0 then return nil, "base64 padding carries data bits" end
|
||||
out[#out + 1] = char(v1 * 4 + floor(v2 / 16))
|
||||
elseif i == last and b4 == PAD then
|
||||
local v3 = DEC[b3]
|
||||
if not v3 then return nil, "base64 holds a character outside the alphabet" end
|
||||
if v3 % 4 ~= 0 then return nil, "base64 padding carries data bits" end
|
||||
local word = v1 * 1024 + v2 * 16 + floor(v3 / 4)
|
||||
out[#out + 1] = char(floor(word / 256), word % 256)
|
||||
else
|
||||
local v3, v4 = DEC[b3], DEC[b4]
|
||||
if not v3 or not v4 then
|
||||
return nil, "base64 holds a character outside the alphabet"
|
||||
end
|
||||
local word = v1 * 262144 + v2 * 4096 + v3 * 64 + v4
|
||||
out[#out + 1] = char(floor(word / 65536), floor(word / 256) % 256, word % 256)
|
||||
end
|
||||
end
|
||||
return concat(out)
|
||||
end
|
||||
|
||||
return Base64
|
||||
@@ -371,7 +371,7 @@ function ChipAudio.shutdown()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = false
|
||||
workerReady = nil
|
||||
end
|
||||
|
||||
function ChipAudio.currentSource()
|
||||
@@ -498,6 +498,8 @@ end
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch)
|
||||
ticks = ticks + duration
|
||||
end
|
||||
end
|
||||
extendDrumEnvelope(segments)
|
||||
self.noiseInstruments[key] = segments
|
||||
return segments
|
||||
end
|
||||
|
||||
+72
-31
@@ -147,29 +147,42 @@ function Game:bootConfig()
|
||||
return boot
|
||||
end
|
||||
|
||||
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
|
||||
-- can reshape it), the overworld at the skeleton's spawn, and the intro
|
||||
-- screen on top. The title menu's NEW GAME row is this; a mod that starts a
|
||||
-- game on its own terms -- a match, a challenge mode -- calls it directly.
|
||||
--
|
||||
-- opts.intro = false skips the newGame screen (Oak's speech) so the player
|
||||
-- lands straight in the world; the skeleton then has to carry a name and a
|
||||
-- party, which is the caller's job via save.new_game.
|
||||
function Game:startNewGame(opts)
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
self.sessionStartedAt = os.time()
|
||||
self.save = SaveData.newGame(self:bootConfig())
|
||||
-- no bucket carry-over: mod state from an abandoned session must
|
||||
-- not leak into a fresh slot; mods seed via save.created instead
|
||||
self:adoptSave(self.save)
|
||||
ModRuntime.emit("save.created", { save = self.save })
|
||||
self:applyOptions(self.save.options)
|
||||
self.stack:push(OverworldState, self.save.player.map,
|
||||
self.save.player.x, self.save.player.y,
|
||||
self.save.player.facing,
|
||||
{ via = "boot", freshBoot = true })
|
||||
if not (opts and opts.intro == false) then
|
||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||
function() end)
|
||||
end
|
||||
end
|
||||
|
||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||
-- and by the START-menu QUIT confirmation
|
||||
function Game:makeTitleState()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||
local title = factory.new(self, {
|
||||
onNewGame = function()
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
self.sessionStartedAt = os.time()
|
||||
self.save = SaveData.newGame(self:bootConfig())
|
||||
-- no bucket carry-over: mod state from an abandoned session must
|
||||
-- not leak into a fresh slot; mods seed via save.created instead
|
||||
self:adoptSave(self.save)
|
||||
ModRuntime.emit("save.created", { save = self.save })
|
||||
self:applyOptions(self.save.options)
|
||||
self.stack:push(OverworldState, self.save.player.map,
|
||||
self.save.player.x, self.save.player.y,
|
||||
self.save.player.facing,
|
||||
{ via = "boot", freshBoot = true })
|
||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||
function() end)
|
||||
end,
|
||||
onNewGame = function() self:startNewGame() end,
|
||||
onContinue = function()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
@@ -817,14 +830,6 @@ function Game:keypressed(key)
|
||||
self:writeOptions()
|
||||
end
|
||||
return
|
||||
elseif key == "5" then
|
||||
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
||||
-- desktop. Mobile refuses the present shader (issue #136).
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if not GBCFX.isSupported() then return end
|
||||
self.save.options.gbcfx = GBCFX.cycle()
|
||||
self:writeOptions()
|
||||
return
|
||||
end
|
||||
-- Mod render pipelines claim their hotkeys last, so one can never shadow
|
||||
-- an engine display key however a mod declares it (12 §rendering
|
||||
@@ -1258,8 +1263,10 @@ function Game:applyOptions(opts)
|
||||
require("src.render.Pipelines").applyOptions(opts)
|
||||
require("src.render.Zoom").applyOptions(opts)
|
||||
require("src.render.TileRenderer").applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
-- returns true when a persisted preset name no longer resolves (deleted
|
||||
-- from the drop-in folder, or failed to (re)translate) and had to be
|
||||
-- cleared back to OFF -- "sanitized, please persist" contract
|
||||
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- Android orientation lock (#592); no-op everywhere else
|
||||
require("src.core.Orientation").applyOptions(opts)
|
||||
@@ -1274,12 +1281,12 @@ function Game:applyOptions(opts)
|
||||
-- tier. Every heavy feature was just applied from the stored options
|
||||
-- above; here we clamp the *live* state down for a weaker device without
|
||||
-- rewriting what the player saved, so raising the tier later restores
|
||||
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||
-- their exact TILT / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||
-- default on a normal desktop, and every options.lua predating this
|
||||
-- option) clamps nothing, so it is a no-op for the common case.
|
||||
local caps = require("src.core.Performance").applyOptions(opts)
|
||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
||||
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
@@ -1289,8 +1296,7 @@ function Game:applyOptions(opts)
|
||||
end
|
||||
Input:applyBindings(opts.bindings)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
if shaderfxCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered, opts)
|
||||
@@ -1381,4 +1387,39 @@ function Game:restoreCheckpointBattle(battle)
|
||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||
end
|
||||
|
||||
-- Drop every session-owned field so the next Game:load() starts clean when
|
||||
-- the process returns to the launcher in-place (Android / intent_game).
|
||||
-- main.lua must not guess field names: new systems (Game.network, …) are
|
||||
-- cleared automatically because only functions (methods) are kept.
|
||||
function Game:reset()
|
||||
if self.stack and self.stack.clear then
|
||||
pcall(function() self.stack:clear() end)
|
||||
end
|
||||
if self.world and self.world.release then
|
||||
pcall(function() self.world:release() end)
|
||||
end
|
||||
if self._canvases then
|
||||
for _, canvas in pairs(self._canvases) do
|
||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||
end
|
||||
end
|
||||
if self.renderer and self.renderer.releaseCanvases then
|
||||
pcall(function() self.renderer:releaseCanvases() end)
|
||||
end
|
||||
local keys = {}
|
||||
for key, value in pairs(self) do
|
||||
if type(value) ~= "function" then
|
||||
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
|
||||
if type(value) == "table" and value.release then
|
||||
pcall(value.release, value)
|
||||
end
|
||||
end
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
for _, key in ipairs(keys) do
|
||||
self[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
+171
-53
@@ -19,6 +19,7 @@ local Clock = require("src.core.gen2.Clock")
|
||||
local FixedStep = require("src.core.FixedStep")
|
||||
local Font = require("src.render.Font")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Input = require("src.core.Input")
|
||||
local Music = require("src.core.Music")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
@@ -288,8 +289,6 @@ function Game2:continueGame(save)
|
||||
self:startWorld()
|
||||
-- After the adopt and after the world is standing, which is where Gen 1
|
||||
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
|
||||
-- `meta` stays absent on a Gold save, which stamps no meta block; modsDiff
|
||||
-- is derived from it and so comes back empty rather than missing.
|
||||
if modsDiff then
|
||||
local notice = SaveData.modsDiffNotice(modsDiff, save.meta)
|
||||
if notice then require("src.core.Logger").warn("%s", notice) end
|
||||
@@ -354,24 +353,36 @@ function Game2:showTitle()
|
||||
})
|
||||
end
|
||||
|
||||
-- ../pokegold/engine/movie/intro.asm:1 GoldSilverIntro, and Crystal's own
|
||||
-- program at ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro.
|
||||
function Game2:showIntro()
|
||||
self.stack:clear()
|
||||
self.phase = "boot"
|
||||
Screens.push(self, "Gen2GoldSilverIntro", {
|
||||
local id = (GameVersion.engine() == "crystal")
|
||||
and "Gen2CrystalIntro" or "Gen2GoldSilverIntro"
|
||||
Screens.push(self, id, {
|
||||
onDone = function()
|
||||
self:showTitle()
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ../pokegold/engine/menus/intro_menu.asm:848-851 IntroSequence, and Crystal's
|
||||
-- at ../pokecrystal/engine/menus/intro_menu.asm:964-967: a skip means the title.
|
||||
function Game2:showGameFreak()
|
||||
self.stack:clear()
|
||||
self.phase = "boot"
|
||||
Screens.push(self, "Gen2GameFreakPresents", {
|
||||
local id = (GameVersion.engine() == "crystal")
|
||||
and "Gen2CrystalSplash" or "Gen2GameFreakPresents"
|
||||
Screens.push(self, id, {
|
||||
title = self.titleData or {},
|
||||
oakSpeech = self.oakSpeechData or {},
|
||||
onDone = function()
|
||||
self:showIntro()
|
||||
onDone = function(skipped)
|
||||
if skipped then
|
||||
self:showTitle()
|
||||
else
|
||||
self:showIntro()
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -943,6 +954,12 @@ function Game2:load()
|
||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
||||
-- The engine's own strings, keyed by the disassembly's label. gen2Text
|
||||
-- above is the script text and is keyed by bank:address for the overworld
|
||||
-- VM, so the two are different tables and both are loaded. This one is
|
||||
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
|
||||
-- helper is shared with Gen 1 and looks up data.text[label].
|
||||
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
|
||||
-- data/generated/events.lua: the side tables a script command NAMES rather
|
||||
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
||||
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
||||
@@ -1089,18 +1106,26 @@ function Game2:load()
|
||||
-- The play clock only runs in the overworld, the way wGameTimerPaused is
|
||||
-- set while the intro menu is up.
|
||||
Save.tickPlayTime(self.save)
|
||||
-- START and SELECT are read only at the tail of OWPlayerInput, which
|
||||
-- PlayerEvents never reaches while a script is running or the player is
|
||||
-- mid-step (World:acceptsMenuInput transcribes the three gates). A press
|
||||
-- that arrives under one of them is dropped, not queued -- and the frame
|
||||
-- still runs, so world:step must not be skipped on the swallowed press.
|
||||
if self.input:wasPressed("start") and self.world:acceptsMenuInput() then
|
||||
self:openStartMenu()
|
||||
return
|
||||
end
|
||||
if self.input:wasPressed("select") and self.world:acceptsMenuInput() then
|
||||
self:useSelectItem()
|
||||
return
|
||||
-- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown
|
||||
-- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718)
|
||||
local accepts = self.world:acceptsMenuInput()
|
||||
local latch = self.joyLatch
|
||||
if accepts then
|
||||
self.joyLatch = nil
|
||||
if self.input:wasPressed("start")
|
||||
or (latch and latch.start and self.input:isDown("start")) then
|
||||
self:openStartMenu()
|
||||
return
|
||||
end
|
||||
if self.input:wasPressed("select")
|
||||
or (latch and latch.select and self.input:isDown("select")) then
|
||||
self:useSelectItem()
|
||||
return
|
||||
end
|
||||
else
|
||||
if not latch then latch = {}; self.joyLatch = latch end
|
||||
if self.input:wasPressed("start") then latch.start = true end
|
||||
if self.input:wasPressed("select") then latch.select = true end
|
||||
end
|
||||
self.world:pollInput(self.input)
|
||||
if self.input:wasPressed("a") then
|
||||
@@ -1152,8 +1177,8 @@ end
|
||||
|
||||
-- The screen-pixels-per-GB-pixel scale the post passes need so their grid and
|
||||
-- shadow offsets stay window-size independent. Always the plain letterbox
|
||||
-- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is
|
||||
-- being shown on, and the panel does not resize when the player zooms the map
|
||||
-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture
|
||||
-- is being shown on, and the panel does not resize when the player zooms the map
|
||||
-- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason
|
||||
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
|
||||
-- one screen pixel a cell out at survey range.
|
||||
@@ -1350,25 +1375,29 @@ end
|
||||
-- Gold's frame, and then the passes that run over it.
|
||||
--
|
||||
-- The Gen 1 path gets these for free because everything it draws goes through
|
||||
-- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there.
|
||||
-- Gold draws straight to the screen instead, which is why its GBC FX row used
|
||||
-- to change a number and nothing else: nothing ever presented a canvas for the
|
||||
-- shader to read. So compose into one here when a pass wants it, and skip the
|
||||
-- canvas entirely when none does -- the common case, and one less full-screen
|
||||
-- blit than the old path would have paid.
|
||||
-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX
|
||||
-- there. Gold draws straight to the screen instead, which is why its SHADER
|
||||
-- FX row used to change a number and nothing else (back when it was GBC FX):
|
||||
-- nothing ever presented a canvas for the shader to read. So compose into
|
||||
-- one here when a pass wants it, and skip the canvas entirely when none does
|
||||
-- -- the common case, and one less full-screen blit than the old path would
|
||||
-- have paid.
|
||||
--
|
||||
-- CLASSIC runs first and GBC FX second, matching the Gen 1 order: the palette
|
||||
-- IS the picture, and the screen effects are simulating the panel that picture
|
||||
-- is being shown on. Mod post-processes fold in between the two, where
|
||||
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
||||
-- is then drawn over, rather than something that smears the grid itself.
|
||||
-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the
|
||||
-- palette IS the picture, and the screen effects are simulating the panel
|
||||
-- that picture is being shown on. Mod post-processes fold in between the
|
||||
-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is
|
||||
-- what the LCD grid is then drawn over, rather than something that smears
|
||||
-- the grid itself.
|
||||
function Game2:drawViewportFrame()
|
||||
local G = love.graphics
|
||||
local w, h = GameViewport.dimensions()
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local fx = GBCFX.active()
|
||||
-- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1
|
||||
-- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed).
|
||||
local shaderfx = ShaderFX.active()
|
||||
|
||||
-- render.zones, at the instant Gen 1 raises it: the palette list is settled
|
||||
-- and the blit has not happened yet. Gen 1's list is the SGB packet zones
|
||||
@@ -1391,14 +1420,14 @@ function Game2:drawViewportFrame()
|
||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||
|
||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
||||
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
||||
-- SHADER FX, a mod post-process, render.compose, or an enabled render.output
|
||||
-- subscriber. With none of them the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local composing = ModRuntime.wantsHook("render.compose")
|
||||
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||
local scene = nil
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||
if zoned or shaderfx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||
scene = self:presentCanvas(1, w, h)
|
||||
end
|
||||
if not scene then
|
||||
@@ -1426,11 +1455,11 @@ function Game2:drawViewportFrame()
|
||||
end
|
||||
|
||||
-- The zone pass has to land in a texture whenever anything still reads one
|
||||
-- after it: GBC FX and a post-process both sample the tinted image, not the
|
||||
-- untinted one. On its own the tint rides the final blit and no second
|
||||
-- after it: SHADER FX and a post-process both sample the tinted image, not
|
||||
-- the untinted one. On its own the tint rides the final blit and no second
|
||||
-- canvas is paid for.
|
||||
local source = scene
|
||||
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||
local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook
|
||||
if zoned and reread then
|
||||
local tinted = self:presentCanvas(2, w, h)
|
||||
if tinted then
|
||||
@@ -1450,7 +1479,7 @@ function Game2:drawViewportFrame()
|
||||
-- Post-process pipelines run over the finished composite and before GBC
|
||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
||||
local scale, ox, oy, dpi = self:frameFit(w, h)
|
||||
local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h)
|
||||
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||
local outputHandled = hasOutputHook
|
||||
@@ -1464,8 +1493,30 @@ function Game2:drawViewportFrame()
|
||||
if not outputHandled then
|
||||
local cx, cy, cw, ch = Playfield.cutout(w, h)
|
||||
if cx then G.setScissor(cx, cy, cw, ch) end
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
if shaderfx then
|
||||
-- rect is physical framebuffer pixels and source is the un-scaled
|
||||
-- size, matching Renderer.lua's fxRectPx / fxSrc contract.
|
||||
-- A live overworld draws edge to edge at World:zoomScale, so the
|
||||
-- faithful 160*scale box would leave the rest of the map unshaded.
|
||||
local rect, srcW, srcH
|
||||
if self.frameWorldActive and self.world then
|
||||
local s = self.world:zoomScale() * dpi
|
||||
srcW = self.world.viewW or 160
|
||||
srcH = self.world.viewH or 144
|
||||
local rw, rh = srcW * s, srcH * s
|
||||
rect = {
|
||||
x = math.floor((pw - rw) / 2), y = math.floor((ph - rh) / 2),
|
||||
w = rw, h = rh, scale = s,
|
||||
}
|
||||
else
|
||||
srcW, srcH = 160, 144
|
||||
rect = {
|
||||
x = ox * dpi, y = oy * dpi,
|
||||
w = 160 * scale * dpi, h = 144 * scale * dpi,
|
||||
scale = scale * dpi,
|
||||
}
|
||||
end
|
||||
ShaderFX.render(source, rect, { w = srcW, h = srcH }, dpi, dpi)
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
@@ -1509,6 +1560,28 @@ function Game2:drawContained(w, h)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- BATTLE BG (#1709): the surround around the centred GB screen, taken from
|
||||
-- whichever state in the stack owns a battle.
|
||||
function Game2:paintBattleSurround(w, h)
|
||||
local states = self.stack and self.stack.states or {}
|
||||
local mode
|
||||
for i = #states, 1, -1 do
|
||||
local state = states[i]
|
||||
if state and state.bgMode then mode = state:bgMode() break end
|
||||
end
|
||||
if mode ~= "black" then return end
|
||||
local G = love.graphics
|
||||
local scale = Chrome.fitScale(w, h)
|
||||
local ox, oy = Chrome.fitOrigin(w, h, scale)
|
||||
local pw, ph = 160 * scale, 144 * scale
|
||||
G.setColor(0, 0, 0, 1)
|
||||
if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end
|
||||
if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end
|
||||
if ox > 0 then G.rectangle("fill", 0, oy, ox, ph) end
|
||||
if ox + pw < w then G.rectangle("fill", ox + pw, oy, w - ox - pw, ph) end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Game2:drawScene(w, h)
|
||||
local G = love.graphics
|
||||
-- render.compose reads this after the scene is drawn; the plain overworld
|
||||
@@ -1578,6 +1651,7 @@ function Game2:drawScene(w, h)
|
||||
and base.drawWidescreen and base)
|
||||
if wide then
|
||||
wide:drawWidescreen(w, h)
|
||||
self:paintBattleSurround(w, h)
|
||||
self:letterbox(w, h, false)
|
||||
if wide ~= top then
|
||||
-- The pushed box blits at the same integer fit the widescreen layer
|
||||
@@ -1682,7 +1756,6 @@ end
|
||||
-- F1/F2 write / reload the save 1 GAME SPEED
|
||||
-- - = zoom one step out / in 2 COLOR
|
||||
-- 4 cycle ZOOM 3 TILT (mnemonic: 3D)
|
||||
-- 5 GBC FX
|
||||
--
|
||||
-- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB
|
||||
-- palette packs, which a CGB-native game has no use for; what it cycles here
|
||||
@@ -1717,13 +1790,6 @@ function Game2:hotkey(key)
|
||||
options.tilt = Tilt.cycle()
|
||||
persist()
|
||||
return true
|
||||
elseif key == "5" then
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.isSupported() then
|
||||
options.gbcfx = GBCFX.cycle()
|
||||
persist()
|
||||
end
|
||||
return true
|
||||
end
|
||||
if not (self.world and self.world.map) then
|
||||
return self:pipelineHotkey(key, options, persist)
|
||||
@@ -1958,7 +2024,8 @@ end
|
||||
|
||||
-- Push the saved display options into the modules that own them. Called
|
||||
-- whenever the options table changes hands (boot, CONTINUE, the OPTION
|
||||
-- screen), so a reload comes back at the zoom, tilt and GBC FX the player left.
|
||||
-- screen), so a reload comes back at the zoom, tilt and SHADER FX the player
|
||||
-- left.
|
||||
function Game2:applyOptions()
|
||||
local options = self.options or {}
|
||||
Music.applyOptions(options)
|
||||
@@ -1985,9 +2052,26 @@ function Game2:applyOptions()
|
||||
require("src.core.ScreenPosition").applyOptions(options)
|
||||
require("src.core.FrameCap").applyOptions(options)
|
||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.applyOptions(options) and self.save then
|
||||
-- applyOptions returns true when it had to clear an unsupported level.
|
||||
-- returns true when a persisted preset name no longer resolves (deleted
|
||||
-- from the drop-in folder, or failed to (re)translate) and had to be
|
||||
-- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for
|
||||
-- Gen 1 (SHADER FX reaches Gen 2 too)
|
||||
local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options)
|
||||
-- Scale the optional presentation extras to the device's performance
|
||||
-- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see
|
||||
-- that site's comment for the full rationale.
|
||||
local caps = require("src.core.Performance").applyOptions(options)
|
||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||
if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
if caps.fpsMax then
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
||||
end
|
||||
if shaderfxCleared and self.save then
|
||||
-- applyOptions returns true when it had to clear an unresolved preset.
|
||||
self.save.options = options
|
||||
end
|
||||
end
|
||||
@@ -2117,4 +2201,38 @@ function Game2:joystickremoved()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
-- In-process return-to-launcher (Android / intent_game): drop session fields
|
||||
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
|
||||
-- Methods live on the class table; pairs(self) only sees instance state.
|
||||
function Game2:reset()
|
||||
if self.stack and self.stack.clear then
|
||||
pcall(function() self.stack:clear() end)
|
||||
end
|
||||
if self.world and self.world.release then
|
||||
pcall(function() self.world:release() end)
|
||||
end
|
||||
if self._canvases then
|
||||
for _, canvas in pairs(self._canvases) do
|
||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||
end
|
||||
end
|
||||
if self.renderer and self.renderer.releaseCanvases then
|
||||
pcall(function() self.renderer:releaseCanvases() end)
|
||||
end
|
||||
local keys = {}
|
||||
for key, value in pairs(self) do
|
||||
if type(value) ~= "function" then
|
||||
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
|
||||
if type(value) == "table" and value.release then
|
||||
pcall(value.release, value)
|
||||
end
|
||||
end
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
for _, key in ipairs(keys) do
|
||||
self[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Game2
|
||||
|
||||
+31
-4
@@ -29,11 +29,38 @@ end
|
||||
|
||||
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
||||
-- a --speed argument), so a bad number degrades to something sane
|
||||
-- A cart may narrow the levels a player can reach (CartManifest's `speeds`).
|
||||
-- nil restores the full ladder; a one-entry list pins the speed outright.
|
||||
local allowed
|
||||
|
||||
function GameSpeed.setAllowed(levels)
|
||||
if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end
|
||||
local valid, seen = {}, {}
|
||||
for _, want in ipairs(GameSpeed.LEVELS) do
|
||||
for _, have in ipairs(levels) do
|
||||
if have == want and not seen[want] then
|
||||
seen[want] = true
|
||||
valid[#valid + 1] = want
|
||||
end
|
||||
end
|
||||
end
|
||||
allowed = (#valid > 0) and valid or nil
|
||||
end
|
||||
|
||||
function GameSpeed.allowed()
|
||||
return allowed or GameSpeed.LEVELS
|
||||
end
|
||||
|
||||
function GameSpeed.isLocked()
|
||||
return allowed ~= nil and #allowed <= 1
|
||||
end
|
||||
|
||||
function GameSpeed.clamp(v)
|
||||
v = tonumber(v)
|
||||
if not v then return GameSpeed.DEFAULT end
|
||||
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(GameSpeed.LEVELS) do
|
||||
local levels = GameSpeed.allowed()
|
||||
if not v then return levels[1] or GameSpeed.DEFAULT end
|
||||
local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(levels) do
|
||||
local diff = math.abs(level - v)
|
||||
if diff < bestDiff then best, bestDiff = level, diff end
|
||||
end
|
||||
@@ -42,7 +69,7 @@ end
|
||||
|
||||
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||
function GameSpeed.cycle(v, dir)
|
||||
local levels = GameSpeed.LEVELS
|
||||
local levels = GameSpeed.allowed()
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == GameSpeed.clamp(v) then cur = i break end
|
||||
|
||||
+75
-12
@@ -1,6 +1,6 @@
|
||||
-- Which game this process is running: Red (the historical default), Blue,
|
||||
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
||||
-- version -- the accepted ROM hash, the import manifest, where the
|
||||
-- Yellow, Gold, Silver, or Crystal. One source of truth for everything that
|
||||
-- differs by version -- the accepted ROM hash, the import manifest, where the
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
@@ -8,8 +8,9 @@
|
||||
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||
-- and selected side by side. Gold and Silver are Gen 2 (see
|
||||
-- docs/gold-phase1.md).
|
||||
-- and selected side by side. Gold, Silver and Crystal are Gen 2 (see
|
||||
-- docs/gold-phase1.md); `generation` splits Gen 1 from Gen 2 and `engine`
|
||||
-- splits Gold/Silver from Crystal within Gen 2.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
-- and tests. The active version is a process-global set once at boot from
|
||||
@@ -27,6 +28,8 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
-- Absent reads as "gen1" (GameVersion.engine)
|
||||
engine = "gen1",
|
||||
},
|
||||
blue = {
|
||||
id = "blue",
|
||||
@@ -37,6 +40,7 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest_blue.json",
|
||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||
engine = "gen1",
|
||||
},
|
||||
yellow = {
|
||||
id = "yellow",
|
||||
@@ -47,6 +51,7 @@ GameVersion.VERSIONS = {
|
||||
manifest = "tools/rom_manifest_yellow.json",
|
||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
||||
engine = "gen1",
|
||||
},
|
||||
-- Gen 2, Phase 1 (docs/gold-phase1.md): a 2 MiB cart, twice the size of
|
||||
-- the Gen 1 ROMs above, imported through RomExtractorGen2 instead of
|
||||
@@ -55,15 +60,14 @@ GameVersion.VERSIONS = {
|
||||
id = "gold",
|
||||
label = "Gold",
|
||||
displayName = "Pokemon Gold",
|
||||
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
|
||||
-- players do not treat it like the shipped Gen 1 columns.
|
||||
launcherName = "Gold (Beta)",
|
||||
launcherName = "Gold",
|
||||
sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94",
|
||||
manifest = "tools/rom_manifest_gold.json",
|
||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||
-- Absent reads as 1 (GameVersion.generation)
|
||||
generation = 2,
|
||||
engine = "gs",
|
||||
},
|
||||
-- Gold's engine with edition-selected data; the manifest is derived from
|
||||
-- Gold's by tools/make_silver_manifest.py.
|
||||
@@ -71,17 +75,46 @@ GameVersion.VERSIONS = {
|
||||
id = "silver",
|
||||
label = "Silver",
|
||||
displayName = "Pokemon Silver",
|
||||
launcherName = "Silver (Beta)",
|
||||
launcherName = "Silver",
|
||||
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
||||
manifest = "tools/rom_manifest_silver.json",
|
||||
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
||||
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
||||
generation = 2,
|
||||
engine = "gs",
|
||||
},
|
||||
crystal = {
|
||||
id = "crystal",
|
||||
label = "Crystal",
|
||||
displayName = "Pokemon Crystal",
|
||||
-- Still Gen 2 Phase work; the launcher panel / Play button say Beta so
|
||||
-- players do not treat it like the shipped Gold and Silver columns.
|
||||
launcherName = "Crystal (Beta)",
|
||||
sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
||||
manifest = "tools/rom_manifest_crystal.json",
|
||||
cachePrefix = "crystal/", -- crystal/data/generated, crystal/assets/generated
|
||||
saveSuffix = "_crystal", -- save_crystal.lua / .bak / .tmp
|
||||
generation = 2,
|
||||
engine = "crystal",
|
||||
revisions = {
|
||||
{ sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", label = "1.0" },
|
||||
{ sha1 = "f2f52230b536214ef7c9924f483392993e226cfb", label = "1.1" },
|
||||
},
|
||||
fixes = {
|
||||
-- pokegold/docs/bugs_and_glitches.md:61
|
||||
luckyNumberBoxes = true,
|
||||
-- pokegold/docs/bugs_and_glitches.md:88
|
||||
surfOntoNpc = true,
|
||||
-- pokecrystal/engine/battle/effect_commands.asm:2614
|
||||
reflectOverflow = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Launcher column order.
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
||||
local NO_FIXES = {}
|
||||
|
||||
-- Launcher column order. Append only (src/mods/ModProfile.lua encodes by index).
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
@@ -115,6 +148,17 @@ function GameVersion.generation(id)
|
||||
return GameVersion.info(id).generation or 1
|
||||
end
|
||||
|
||||
-- "gen1" | "gs" | "crystal": lineage within a generation.
|
||||
function GameVersion.engine(id)
|
||||
return GameVersion.info(id).engine or "gen1"
|
||||
end
|
||||
|
||||
-- Cart bugs a version FIXED, by fix name; an absent row reads {} and stays bugged.
|
||||
function GameVersion.fixes(id)
|
||||
local info = GameVersion.info(id)
|
||||
return (info and info.fixes) or NO_FIXES
|
||||
end
|
||||
|
||||
-- Metadata for a version id, defaulting to the active one.
|
||||
function GameVersion.info(id)
|
||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||
@@ -128,10 +172,29 @@ function GameVersion.cachePrefix(id)
|
||||
return GameVersion.info(id).cachePrefix
|
||||
end
|
||||
|
||||
function GameVersion.revisions(id)
|
||||
local info = GameVersion.info(id)
|
||||
return info.revisions or { { sha1 = info.sha1 } }
|
||||
end
|
||||
|
||||
function GameVersion.acceptsSha1(id, sha1)
|
||||
for _, revision in ipairs(GameVersion.revisions(id)) do
|
||||
if revision.sha1 == sha1 then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function GameVersion.revisionLabel(id, sha1)
|
||||
for _, revision in ipairs(GameVersion.revisions(id)) do
|
||||
if revision.sha1 == sha1 then return revision.label end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
||||
function GameVersion.forSha1(sha1)
|
||||
for id, info in pairs(GameVersion.VERSIONS) do
|
||||
if info.sha1 == sha1 then return id end
|
||||
for id in pairs(GameVersion.VERSIONS) do
|
||||
if GameVersion.acceptsSha1(id, sha1) then return id end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
+24
-1
@@ -376,7 +376,19 @@ end
|
||||
-- notice a quit command until curl returns. With the launcher's default 300s
|
||||
-- ceiling, closing the window during a mod download hung the process for
|
||||
-- minutes. Callers on the interactive fetch pool pass something short.
|
||||
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
--
|
||||
-- `etagPath` (optional, absolute host path) turns this into a conditional
|
||||
-- GET: curl sends `If-None-Match` from whatever ETag is already saved there
|
||||
-- (`--etag-compare`) and overwrites it with the response's own ETag on
|
||||
-- success (`--etag-save`), same file for both so a first call with no prior
|
||||
-- ETag degrades to a plain unconditional download. A server that replies 304
|
||||
-- Not Modified is reported as a third return value (`notModified`), and --
|
||||
-- confirmed empirically against the real buildbot.libretro.com endpoint
|
||||
-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT
|
||||
-- write `absPath` at all in that case (not even an empty file), matching
|
||||
-- HTTP: a 304 carries no body. A caller must not treat a missing file as an
|
||||
-- error when `notModified` comes back true.
|
||||
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
@@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
end
|
||||
if type(etagPath) == "string" and etagPath ~= "" then
|
||||
cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " "
|
||||
.. "--etag-save " .. HostShell.quote(etagPath) .. " "
|
||||
end
|
||||
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
@@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
-- status is here purely so the failure can NAME itself: "download failed"
|
||||
-- with no URL and no code is the report this whole change exists to fix.
|
||||
local body, status, noise = splitCurlOutput(readOk and out or "")
|
||||
if status == 304 then
|
||||
return true, nil, true
|
||||
end
|
||||
if status and (status < 200 or status >= 300) then
|
||||
return nil, fetchError(url, status, body)
|
||||
end
|
||||
@@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
-- The Android/iOS bridge has no conditional-GET support -- etagPath is
|
||||
-- silently ignored here, so a downloaded preset re-fetches in full every
|
||||
-- time on those platforms. No transport-level ETag/If-None-Match hook
|
||||
-- exists on the bridge to hang one off, and it's low-priority for now.
|
||||
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
|
||||
if ok and done then return true end
|
||||
return nil, "download failed for " .. url
|
||||
|
||||
@@ -40,6 +40,7 @@ local function normalizeVersion(v)
|
||||
y = "yellow", yellow = "yellow",
|
||||
g = "gold", gold = "gold",
|
||||
s = "silver", silver = "silver",
|
||||
c = "crystal", crystal = "crystal",
|
||||
}
|
||||
v = alias[v] or v
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||
|
||||
+29
-12
@@ -3,9 +3,12 @@
|
||||
--
|
||||
-- The heavy extras are all things the Game Boy never had and this port
|
||||
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||
-- ground plane), survey ZOOM (zooming out renders the connected
|
||||
-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live-
|
||||
-- translated libretro slang-shader chain, reasoned -- not yet measured --
|
||||
-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced).
|
||||
-- A hard FPS ceiling caps present cost on
|
||||
-- top of that.
|
||||
-- None of this touches game logic, which is fixed-step off dt
|
||||
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||
@@ -13,14 +16,15 @@
|
||||
-- The tier is persisted as save.options.performance:
|
||||
-- auto pick a default from the device (see detect())
|
||||
-- high everything on -- the historical behavior
|
||||
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||
-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no survey zoom, no SHADER FX, FPS capped
|
||||
--
|
||||
-- AUTO only chooses the *default* for the current device; every tier is
|
||||
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||
-- against the stored options without rewriting them, so raising the tier
|
||||
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||
-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without
|
||||
-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX
|
||||
-- / ZOOM / FPS the player had. Wired into both generations.
|
||||
--
|
||||
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||
-- tests, the same way src/core/GameVersion.lua does.
|
||||
@@ -40,10 +44,24 @@ Performance.LABELS = {
|
||||
-- What each concrete tier permits. `auto` is resolved to one of these
|
||||
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||
-- extra ceiling (the player's own MAX FPS still applies).
|
||||
--
|
||||
-- `shaderfx` is `false` (fully off) or a positive number: an internal
|
||||
-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole
|
||||
-- SHADER FX pass chain at half resolution, then stretch up for display --
|
||||
-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its
|
||||
-- final output to the real display rect regardless of the chain's own
|
||||
-- internal size, so shrinking the chain's working
|
||||
-- resolution costs nothing extra at that final blit). Every existing
|
||||
-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works
|
||||
-- unchanged: `false` stays falsy, any positive number stays truthy -- only
|
||||
-- `ShaderFX.render` itself reads the actual number. No tier is
|
||||
-- set below 1.0 yet -- tuning the actual per-tier value for real low-end
|
||||
-- hardware needs a device this project doesn't have to hand right now; this
|
||||
-- is the mechanism, not the tuning.
|
||||
Performance.CAPS = {
|
||||
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||
high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false },
|
||||
balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false },
|
||||
low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 },
|
||||
}
|
||||
|
||||
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||
@@ -93,8 +111,7 @@ function Performance.detect()
|
||||
if isArm and os == "Linux" then
|
||||
return "low"
|
||||
end
|
||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||
-- Phones and tablets: balanced drops the 3D tilt, the heaviest extra.
|
||||
if os == "Android" or os == "iOS" then
|
||||
return "balanced"
|
||||
end
|
||||
|
||||
+523
-124
@@ -272,10 +272,9 @@ function SaveData.defaultOptions()
|
||||
speedOverworld = 1,
|
||||
speedBattle = 1,
|
||||
speedMenu = 1,
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/4)
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
gbcfx = 0,
|
||||
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
|
||||
zoom = 0,
|
||||
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||
@@ -290,8 +289,8 @@ function SaveData.defaultOptions()
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
-- picks a default from the device (ARM handhelds/phones drop the heavy
|
||||
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
|
||||
-- logic. See src/core/Performance.lua.
|
||||
-- extras); scales TILT / survey ZOOM / FPS but never game logic. See
|
||||
-- src/core/Performance.lua.
|
||||
performance = "auto",
|
||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||
@@ -360,9 +359,24 @@ function SaveData.defaultOptions()
|
||||
timeFormat = "device", -- device | 24h | 12h
|
||||
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
|
||||
pendingConflicts = {} },
|
||||
-- Cart-scoped settings, beside cartSlots: cartOptions[cartId][key] layers
|
||||
-- over the global value for CART_OPTION_KEYS while that cart is playing.
|
||||
cartOptions = {},
|
||||
-- The player's own answer for a mod a cart pins, kept out of the per-game
|
||||
-- flags: cartMods[cartId][modId], absent while the cart's switch stands.
|
||||
cartMods = {},
|
||||
-- The carts whose shipped defaults were already seeded, so a player's
|
||||
-- later change is never overwritten (compare modProfilesSeeded).
|
||||
cartOptionsSeeded = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Settings that belong to a playthrough and follow the cart: pacing and the
|
||||
-- battle rules. Everything else -- audio, video, bindings, touch, language,
|
||||
-- launcher state -- belongs to the machine and stays global.
|
||||
SaveData.CART_OPTION_KEYS = { "animations", "battleStyle", "ruleset",
|
||||
"speedBattle", "speedMenu", "speedOverworld", "textSpeed" }
|
||||
|
||||
-- Merge loaded keys over defaults (shallow). Unknown keys are kept so
|
||||
-- future options aren't dropped by older builds writing the file back.
|
||||
function SaveData.mergeOptions(loaded)
|
||||
@@ -437,6 +451,60 @@ end
|
||||
|
||||
-- ------- options
|
||||
|
||||
-- The cart being played, if any; SaveData.setCart owns it. Declared here
|
||||
-- because the options overlay below reads it.
|
||||
local activeCart, activeCartHash
|
||||
|
||||
local function cartBucket(opts, cartId)
|
||||
local root = type(opts) == "table" and opts.cartOptions or nil
|
||||
local bucket = type(root) == "table" and root[cartId] or nil
|
||||
return type(bucket) == "table" and bucket or nil
|
||||
end
|
||||
|
||||
-- Lay the active cart's values over the globals for the cart-scoped keys; a
|
||||
-- key the cart never set falls through to the global value.
|
||||
local function applyCartOverlay(opts)
|
||||
if not (activeCart and type(opts) == "table") then return opts end
|
||||
local bucket = cartBucket(opts, activeCart)
|
||||
if not bucket then return opts end
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
|
||||
if bucket[key] ~= nil then opts[key] = bucket[key] end
|
||||
end
|
||||
return opts
|
||||
end
|
||||
|
||||
local function cartGlobals(onDisk)
|
||||
local out = {}
|
||||
if type(onDisk) ~= "table" then return out end
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do out[key] = onDisk[key] end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The inverse, run just before a write: the cart-scoped keys go into the
|
||||
-- active cart's bucket and the globals keep whatever the file already held.
|
||||
local function splitCartOverlay(opts, globals, onDisk)
|
||||
if not (activeCart and type(opts) == "table") then return opts end
|
||||
local root = {}
|
||||
local prior = type(onDisk) == "table" and onDisk.cartOptions or nil
|
||||
if type(prior) == "table" then
|
||||
for id, bucket in pairs(prior) do root[id] = deepCopy(bucket) end
|
||||
end
|
||||
for id, bucket in pairs(type(opts.cartOptions) == "table" and opts.cartOptions or {}) do
|
||||
root[id] = bucket
|
||||
end
|
||||
local bucket = type(root[activeCart]) == "table" and root[activeCart] or {}
|
||||
local defaults = SaveData.defaultOptions()
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
|
||||
if opts[key] ~= nil then bucket[key] = opts[key] end
|
||||
local global = globals and globals[key]
|
||||
if global == nil then global = defaults[key] end
|
||||
opts[key] = global
|
||||
end
|
||||
root[activeCart] = bucket
|
||||
opts.cartOptions = root
|
||||
return opts
|
||||
end
|
||||
|
||||
-- Both take an optional fs (write/getInfo/read) defaulting to
|
||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||
-- options round-trip headless (no love global).
|
||||
@@ -458,6 +526,10 @@ function SaveData.saveOptions(opts, fs)
|
||||
-- activeProfile -- not defaultOptions members -- can be deleted by their
|
||||
-- sites precisely because those sites always write full tables.
|
||||
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
||||
-- Fold from the cart's view of the file, not the globals underneath it, or
|
||||
-- a partial write would push the global value into the cart's bucket.
|
||||
local cartGlobalValues = cartGlobals(onDisk)
|
||||
applyCartOverlay(onDisk)
|
||||
local isFull = type(opts) == "table"
|
||||
if isFull then
|
||||
for k in pairs(SaveData.defaultOptions()) do
|
||||
@@ -497,6 +569,7 @@ function SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
opts.modOptions = merged
|
||||
end
|
||||
splitCartOverlay(opts, cartGlobalValues, onDisk)
|
||||
local encoded = SaveSerializer.encode(opts)
|
||||
-- Stage the new bytes and roll the last good file aside BEFORE the main
|
||||
-- write truncates it, the same tmp/bak dance SaveData.save uses for
|
||||
@@ -544,7 +617,8 @@ function SaveData.saveOptions(opts, fs)
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
|
||||
-- the staged witness has served its purpose; the main file is verified
|
||||
remove(fs, OPTIONS_TMP_FILENAME)
|
||||
return opts
|
||||
-- hand back the cart's view, matching what loadOptions would answer
|
||||
return applyCartOverlay(opts)
|
||||
end
|
||||
|
||||
function SaveData.loadOptions(fs)
|
||||
@@ -572,11 +646,11 @@ function SaveData.loadOptions(fs)
|
||||
if fs.write then
|
||||
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
|
||||
end
|
||||
return SaveData.mergeOptions(recovered)
|
||||
return applyCartOverlay(SaveData.mergeOptions(recovered))
|
||||
end
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
return SaveData.mergeOptions(data)
|
||||
return applyCartOverlay(SaveData.mergeOptions(data))
|
||||
end
|
||||
|
||||
-- ------- per-game mod enablement
|
||||
@@ -759,25 +833,40 @@ end
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
|
||||
-- is used, which keeps a brand-new install and every pre-slots caller
|
||||
-- working unchanged.
|
||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- version -> true once resolved this process
|
||||
local activeSlotCache = {} -- scope key -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- scope key -> true once resolved this process
|
||||
-- At most one New Game can be the live candidate for a first public tool
|
||||
-- request. A single strong reference models that runtime fact without adding
|
||||
-- marker data to the save or retaining abandoned playthrough tables.
|
||||
local freshPlaythrough
|
||||
|
||||
local function slotDir(version) return "saves/" .. version end
|
||||
local CART_PREFIX = "cart_"
|
||||
|
||||
local function slotNames(version, id)
|
||||
local main = slotDir(version) .. "/" .. id .. ".lua"
|
||||
local sealBroken = false
|
||||
|
||||
local function cartKey(cartId)
|
||||
if type(cartId) ~= "string" or #cartId > 64 then return nil end
|
||||
if not cartId:match("^%w[%w%._%-]*$") then return nil end
|
||||
return CART_PREFIX .. cartId
|
||||
end
|
||||
|
||||
local function isCartKey(key)
|
||||
return key:sub(1, #CART_PREFIX) == CART_PREFIX
|
||||
end
|
||||
|
||||
local function slotDir(key) return "saves/" .. key end
|
||||
|
||||
local function slotNames(key, id)
|
||||
local main = slotDir(key) .. "/" .. id .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||
-- save_blue.lua / save_yellow.lua for the others); still the destination
|
||||
-- before any slot exists
|
||||
local function legacyNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
-- the pre-slots flat names a scope uses before any slot exists (save.lua for
|
||||
-- Red, save_blue.lua / save_yellow.lua for the other versions, and
|
||||
-- save_cart_<id>.lua for a cart)
|
||||
local function legacyNames(key)
|
||||
local suffix = isCartKey(key) and ("_" .. key) or GameVersion.saveSuffix(key)
|
||||
local main = "save" .. suffix .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
@@ -790,6 +879,37 @@ local function knownVersion(version)
|
||||
return GameVersion.info(version) ~= nil
|
||||
end
|
||||
|
||||
local function knownScope(key)
|
||||
if type(key) ~= "string" or key == "" then return false end
|
||||
if isCartKey(key) then return true end
|
||||
return knownVersion(key)
|
||||
end
|
||||
|
||||
local function activeScopeKey(version)
|
||||
if activeCart then return CART_PREFIX .. activeCart end
|
||||
return version or GameVersion.get()
|
||||
end
|
||||
|
||||
local function registryOf(opts, key)
|
||||
local root, id = opts.saveSlots, key
|
||||
if isCartKey(key) then
|
||||
root, id = opts.cartSlots, key:sub(#CART_PREFIX + 1)
|
||||
end
|
||||
if type(root) ~= "table" then return nil end
|
||||
local reg = root[id]
|
||||
return type(reg) == "table" and reg or nil
|
||||
end
|
||||
|
||||
local function putRegistry(opts, key, reg)
|
||||
if isCartKey(key) then
|
||||
opts.cartSlots = type(opts.cartSlots) == "table" and opts.cartSlots or {}
|
||||
opts.cartSlots[key:sub(#CART_PREFIX + 1)] = reg
|
||||
else
|
||||
opts.saveSlots = type(opts.saveSlots) == "table" and opts.saveSlots or {}
|
||||
opts.saveSlots[key] = reg
|
||||
end
|
||||
end
|
||||
|
||||
-- Create the parent directory of a slot path when the fs supports it.
|
||||
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
|
||||
-- stub keys files by full path and exposes no such method, so this is a
|
||||
@@ -802,8 +922,8 @@ end
|
||||
-- Decode a slot's save using the same recovery order load() uses -- main,
|
||||
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
|
||||
-- summarizes. nil when nothing readable is present.
|
||||
local function decodeSlot(fs, version, id)
|
||||
local main, bak, tmp = slotNames(version, id)
|
||||
local function decodeSlot(fs, key, id)
|
||||
local main, bak, tmp = slotNames(key, id)
|
||||
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
|
||||
if data then return data end
|
||||
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
|
||||
@@ -818,30 +938,29 @@ end
|
||||
-- active slot. Returns the new slot id, or nil when there is nothing to
|
||||
-- migrate or the copy could not be verified (originals left in place so no
|
||||
-- data is ever lost to a failed move).
|
||||
local function tryMigrateLegacy(version, fs)
|
||||
local lmain, lbak, ltmp = legacyNames(version)
|
||||
local function tryMigrateLegacy(key, fs)
|
||||
local lmain, lbak, ltmp = legacyNames(key)
|
||||
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
|
||||
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
|
||||
if not (mainBody or bakBody) then return nil end
|
||||
local id = "slot1"
|
||||
local dmain, dbak = slotNames(version, id)
|
||||
local dmain, dbak = slotNames(key, id)
|
||||
ensureParentDir(fs, dmain)
|
||||
if mainBody then fs.write(dmain, mainBody) end
|
||||
if bakBody then fs.write(dbak, bakBody) end
|
||||
-- refuse to delete the originals unless the new slot is loadable (from
|
||||
-- the main copy or, failing that, the backup)
|
||||
if not decodeSlot(fs, version, id) then return nil end
|
||||
if not decodeSlot(fs, key, id) then return nil end
|
||||
remove(fs, lmain)
|
||||
remove(fs, lbak)
|
||||
remove(fs, ltmp)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = { id }, active = id }
|
||||
putRegistry(opts, key, { list = { id }, active = id })
|
||||
-- A tool may have allocated the legacy scope before the player made their
|
||||
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
|
||||
-- same opaque identity; otherwise title-selected mod storage becomes
|
||||
-- unreachable after the migration even though every durable record exists.
|
||||
local ids = opts.playthroughIds and opts.playthroughIds[version]
|
||||
local ids = opts.playthroughIds and opts.playthroughIds[key]
|
||||
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
|
||||
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
|
||||
ids.legacy = nil
|
||||
@@ -852,9 +971,9 @@ end
|
||||
|
||||
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
|
||||
-- has no registered slots for this version (e.g. options.lua was reset or lost).
|
||||
local function scanDiskSlots(version, fs)
|
||||
local function scanDiskSlots(key, fs)
|
||||
if not fs then return nil end
|
||||
local dir = "saves/" .. version
|
||||
local dir = slotDir(key)
|
||||
local slots = {}
|
||||
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
|
||||
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
|
||||
@@ -882,49 +1001,49 @@ local function scanDiskSlots(version, fs)
|
||||
return #slots > 0 and slots or nil
|
||||
end
|
||||
|
||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||
-- Resolve (once per scope per process) which slot in-game saves use: an
|
||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
|
||||
local function ensureVersionSlots(version, fs)
|
||||
if slotsChecked[version] then return end
|
||||
slotsChecked[version] = true
|
||||
if not knownVersion(version) then
|
||||
activeSlotCache[version] = false
|
||||
local function ensureSlots(key, fs)
|
||||
if slotsChecked[key] then return end
|
||||
slotsChecked[key] = true
|
||||
if not knownScope(key) then
|
||||
activeSlotCache[key] = false
|
||||
return
|
||||
end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||
local reg = registryOf(opts, key)
|
||||
if reg and type(reg.list) == "table" and #reg.list > 0 then
|
||||
activeSlotCache[version] = reg.active or reg.list[1]
|
||||
activeSlotCache[key] = reg.active or reg.list[1]
|
||||
return
|
||||
end
|
||||
local migrated = tryMigrateLegacy(version, fs)
|
||||
local migrated = tryMigrateLegacy(key, fs)
|
||||
if migrated then
|
||||
activeSlotCache[version] = migrated
|
||||
activeSlotCache[key] = migrated
|
||||
return
|
||||
end
|
||||
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
|
||||
local recovered = scanDiskSlots(version, fs)
|
||||
local recovered = scanDiskSlots(key, fs)
|
||||
if recovered and #recovered > 0 then
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
|
||||
putRegistry(opts, key, { list = recovered, active = recovered[1] })
|
||||
SaveData.saveOptions(opts, fs)
|
||||
activeSlotCache[version] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
|
||||
activeSlotCache[key] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, key)
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = false
|
||||
activeSlotCache[key] = false
|
||||
end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||
-- the scope -- the active cart when one is set, otherwise the version --
|
||||
-- falling back to the flat legacy names when no slot is in use.
|
||||
function saveNames(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local key = activeScopeKey(version)
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
local slot = activeSlotCache[version]
|
||||
if slot then return slotNames(version, slot) end
|
||||
return legacyNames(version)
|
||||
ensureSlots(key, fs)
|
||||
local slot = activeSlotCache[key]
|
||||
if slot then return slotNames(key, slot) end
|
||||
return legacyNames(key)
|
||||
end
|
||||
|
||||
-- Pure extraction of the launcher's per-slot summary from a decoded save,
|
||||
@@ -1005,26 +1124,33 @@ function SaveData.slotDiskPath(version, slotId)
|
||||
return base .. sep .. rel:gsub("/", sep)
|
||||
end
|
||||
|
||||
-- Slots visible to the launcher: every registered slot for a version, each
|
||||
-- Slots visible to the launcher: every registered slot for a scope, 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
|
||||
-- is migrated to slot1 first.
|
||||
local function listSlotsIn(key)
|
||||
local fs = persistFs(nil)
|
||||
ensureSlots(key, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
local list = (reg and type(reg.list) == "table" and reg.list) or {}
|
||||
local out = {}
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, key, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||
label = reg.names and reg.names[id] or nil,
|
||||
cartHash = reg.hashes and reg.hashes[id] or nil,
|
||||
sealBroken = (reg.broken and reg.broken[id] == true)
|
||||
or false }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SaveData.listSlots(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return {} end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = opts.saveSlots and opts.saveSlots[version]
|
||||
local list = (reg and reg.list) or {}
|
||||
local out = {}
|
||||
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,
|
||||
label = reg.names and reg.names[id] or nil }
|
||||
end
|
||||
return out
|
||||
return listSlotsIn(version)
|
||||
end
|
||||
|
||||
function SaveData.readSlotSource(version, slotId, injectedFs)
|
||||
@@ -1049,17 +1175,14 @@ end
|
||||
-- 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
|
||||
local function renameSlotIn(key, slotId, name)
|
||||
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 reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" 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
|
||||
@@ -1070,47 +1193,55 @@ function SaveData.renameSlot(version, slotId, name)
|
||||
reg.names = reg.names or {}
|
||||
reg.names[slotId] = label
|
||||
if next(reg.names) == nil then reg.names = nil end
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.renameSlot(version, slotId, name)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
return renameSlotIn(version, slotId, name)
|
||||
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.
|
||||
function SaveData.setActiveSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local function setActiveSlotIn(key, slotId)
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||
local reg = registryOf(opts, key) or { list = {}, active = nil }
|
||||
reg.list = type(reg.list) == "table" and reg.list or {}
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then reg.list[#reg.list + 1] = slotId end
|
||||
reg.active = slotId
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = slotId
|
||||
slotsChecked[key] = true
|
||||
activeSlotCache[key] = slotId
|
||||
return slotId
|
||||
end
|
||||
|
||||
-- Register a new empty slot for the version and return its id. Does NOT
|
||||
function SaveData.setActiveSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
return setActiveSlotIn(version, slotId)
|
||||
end
|
||||
|
||||
-- Register a new empty slot for the scope and return its id. Does NOT
|
||||
-- write a save file and does NOT change the active slot: an empty slot
|
||||
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
|
||||
-- allocated one past the highest existing number so a reused id can never
|
||||
-- collide with a lingering file.
|
||||
function SaveData.createSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local function createSlotIn(key)
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
ensureSlots(key, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version] or { list = {}, active = nil }
|
||||
local reg = registryOf(opts, key) or { list = {}, active = nil }
|
||||
reg.list = type(reg.list) == "table" and reg.list or {}
|
||||
local maxN = 0
|
||||
for _, id in ipairs(reg.list) do
|
||||
local n = tonumber(tostring(id):match("^slot(%d+)$"))
|
||||
@@ -1118,21 +1249,31 @@ function SaveData.createSlot(version)
|
||||
end
|
||||
local id = "slot" .. (maxN + 1)
|
||||
reg.list[#reg.list + 1] = id
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- The active slot id in use for a version (resolved once per process like
|
||||
function SaveData.createSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
return createSlotIn(version)
|
||||
end
|
||||
|
||||
-- The active slot id in use for a scope (resolved once per process like
|
||||
-- saveNames does), or nil when none is registered and the flat legacy path is
|
||||
-- in use. Public so the launcher's save Import/Export glue can name an export
|
||||
-- after the slot it came from without reaching into the private cache.
|
||||
local function activeSlotIn(key)
|
||||
local fs = persistFs(nil)
|
||||
ensureSlots(key, fs)
|
||||
return activeSlotCache[key] or nil
|
||||
end
|
||||
|
||||
function SaveData.activeSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return nil end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or nil
|
||||
return activeSlotIn(version)
|
||||
end
|
||||
|
||||
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
|
||||
@@ -1142,12 +1283,10 @@ end
|
||||
-- already registered the slot via createSlot but written no bytes yet; unlike
|
||||
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
|
||||
-- options. Returns true, or false + an error string on a failed write.
|
||||
function SaveData.writeSlot(version, slotId, saveTable)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
local function writeSlotIn(key, slotId, saveTable)
|
||||
if type(slotId) ~= "string" then return false, "missing slot id" end
|
||||
if type(saveTable) ~= "table" then return false, "missing save table" end
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
local main, bak, tmp = slotNames(key, slotId)
|
||||
local encoded = SaveSerializer.encode(saveTable)
|
||||
local fs = persistFs(nil)
|
||||
ensureParentDir(fs, main)
|
||||
@@ -1164,52 +1303,300 @@ function SaveData.writeSlot(version, slotId, saveTable)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.writeSlot(version, slotId, saveTable)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
return writeSlotIn(version, slotId, saveTable)
|
||||
end
|
||||
|
||||
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
|
||||
-- options registry, and if it was active point active at another remaining
|
||||
-- slot (or clear active when the list is empty). Returns true, or false +
|
||||
-- an error string when the id is unknown / not registered.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
local function deleteSlotIn(key, slotId)
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
ensureSlots(key, fs)
|
||||
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 reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
|
||||
local found, idx = false, nil
|
||||
for i, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true; idx = i; break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
local main, bak, tmp = slotNames(key, slotId)
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.names then reg.names[slotId] = nil end
|
||||
if reg.hashes then
|
||||
reg.hashes[slotId] = nil
|
||||
if next(reg.hashes) == nil then reg.hashes = nil end
|
||||
end
|
||||
if reg.broken then
|
||||
reg.broken[slotId] = nil
|
||||
if next(reg.broken) == nil then reg.broken = nil end
|
||||
end
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
opts.saveSlots[version] = reg
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = reg.active or false
|
||||
slotsChecked[key] = true
|
||||
activeSlotCache[key] = reg.active or false
|
||||
return true
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||
-- the game, which resolves each version exactly once per boot.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
return deleteSlotIn(version, slotId)
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache (and the active cart) so a
|
||||
-- suite can exercise migration/resolution against a freshly injected
|
||||
-- filesystem. Unused by the game, which resolves each scope exactly once per
|
||||
-- boot.
|
||||
function SaveData.resetSlotState()
|
||||
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
||||
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||
freshPlaythrough = nil
|
||||
activeCart, activeCartHash = nil, nil
|
||||
sealBroken = false
|
||||
end
|
||||
|
||||
-- ------- custom carts
|
||||
|
||||
function SaveData.setCart(cartId, cartHash)
|
||||
if cartId ~= nil and cartKey(cartId) then
|
||||
activeCart = cartId
|
||||
activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
|
||||
else
|
||||
activeCart, activeCartHash = nil, nil
|
||||
end
|
||||
return activeCart
|
||||
end
|
||||
|
||||
function SaveData.getCart()
|
||||
return activeCart
|
||||
end
|
||||
|
||||
-- The player's answer for one mod a cart pins, or nil while they have not
|
||||
-- given one and the cart's own switch stands.
|
||||
function SaveData.cartModEnabled(options, cartId, modId)
|
||||
if not cartKey(cartId) or type(modId) ~= "string" then return nil end
|
||||
local root = type(options) == "table" and options.cartMods or nil
|
||||
local bucket = type(root) == "table" and root[cartId] or nil
|
||||
if type(bucket) == "table" and type(bucket[modId]) == "boolean" then
|
||||
return bucket[modId]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Kept apart from options.mods so switching a cart's mod never changes what
|
||||
-- the base game runs, and vice versa.
|
||||
function SaveData.setCartModEnabled(cartId, modId, enabled, fs)
|
||||
if not cartKey(cartId) or type(modId) ~= "string" or modId == "" then
|
||||
return false
|
||||
end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local root = type(opts.cartMods) == "table" and opts.cartMods or {}
|
||||
local bucket = type(root[cartId]) == "table" and root[cartId] or {}
|
||||
bucket[modId] = enabled and true or false
|
||||
root[cartId], opts.cartMods = bucket, root
|
||||
return SaveData.saveOptions(opts, fs) ~= nil
|
||||
end
|
||||
|
||||
-- Seed the active cart's shipped settings into its overlay, once ever. Keys
|
||||
-- outside CART_OPTION_KEYS are ignored; a later player change is never redone.
|
||||
function SaveData.seedCartOptions(defaults, fs)
|
||||
if not (activeCart and type(defaults) == "table") then return false end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local seeded = type(opts.cartOptionsSeeded) == "table"
|
||||
and opts.cartOptionsSeeded or {}
|
||||
if seeded[activeCart] then return false end
|
||||
local root = type(opts.cartOptions) == "table" and opts.cartOptions or {}
|
||||
local bucket = type(root[activeCart]) == "table" and root[activeCart] or {}
|
||||
for _, key in ipairs(SaveData.CART_OPTION_KEYS) do
|
||||
if defaults[key] ~= nil and bucket[key] == nil then
|
||||
bucket[key] = defaults[key]
|
||||
opts[key] = defaults[key]
|
||||
end
|
||||
end
|
||||
root[activeCart], opts.cartOptions = bucket, root
|
||||
seeded[activeCart], opts.cartOptionsSeeded = true, seeded
|
||||
return SaveData.saveOptions(opts, fs) ~= nil
|
||||
end
|
||||
|
||||
function SaveData.setCartHash(cartHash)
|
||||
activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
|
||||
return activeCartHash
|
||||
end
|
||||
|
||||
function SaveData.getCartHash()
|
||||
return activeCartHash
|
||||
end
|
||||
|
||||
function SaveData.breakSeal(save)
|
||||
sealBroken = true
|
||||
if type(save) == "table" then
|
||||
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||
save.meta.sealBroken = true
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.isSealBroken(save)
|
||||
if type(save) == "table" then
|
||||
return type(save.meta) == "table" and save.meta.sealBroken == true
|
||||
end
|
||||
return sealBroken
|
||||
end
|
||||
|
||||
function SaveData.listCartSlots(cartId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return {} end
|
||||
return listSlotsIn(key)
|
||||
end
|
||||
|
||||
function SaveData.createCartSlot(cartId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return nil end
|
||||
return createSlotIn(key)
|
||||
end
|
||||
|
||||
function SaveData.setActiveCartSlot(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return nil end
|
||||
if type(slotId) ~= "string" or slotId == "" then return nil end
|
||||
return setActiveSlotIn(key, slotId)
|
||||
end
|
||||
|
||||
function SaveData.activeCartSlot(cartId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return nil end
|
||||
return activeSlotIn(key)
|
||||
end
|
||||
|
||||
function SaveData.renameCartSlot(cartId, slotId, name)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
return renameSlotIn(key, slotId, name)
|
||||
end
|
||||
|
||||
function SaveData.deleteCartSlot(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
return deleteSlotIn(key, slotId)
|
||||
end
|
||||
|
||||
function SaveData.writeCartSlot(cartId, slotId, saveTable)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
local ok, err = writeSlotIn(key, slotId, saveTable)
|
||||
if not ok then return ok, err end
|
||||
local hash = type(saveTable.meta) == "table" and saveTable.meta.cartHash or nil
|
||||
if type(hash) == "string" and hash ~= "" then
|
||||
SaveData.setSlotCartHash(cartId or activeCart, slotId, hash)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.slotCartHash(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key or type(slotId) ~= "string" then return nil end
|
||||
local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key)
|
||||
local hash = reg and type(reg.hashes) == "table" and reg.hashes[slotId] or nil
|
||||
if type(hash) ~= "string" or hash == "" then return nil end
|
||||
return hash
|
||||
end
|
||||
|
||||
function SaveData.setSlotCartHash(cartId, slotId, cartHash)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" 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 hash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
|
||||
reg.hashes = type(reg.hashes) == "table" and reg.hashes or {}
|
||||
if reg.hashes[slotId] == hash then return true end
|
||||
reg.hashes[slotId] = hash
|
||||
if next(reg.hashes) == nil then reg.hashes = nil end
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.slotSealBroken(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key or type(slotId) ~= "string" then return false end
|
||||
local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key)
|
||||
local broken = reg and type(reg.broken) == "table" and reg.broken[slotId]
|
||||
return broken == true
|
||||
end
|
||||
|
||||
function SaveData.markSlotSealBroken(cartId, slotId)
|
||||
local key = cartKey(cartId or activeCart)
|
||||
if not key then return false, "unknown cart" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" 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
|
||||
reg.broken = type(reg.broken) == "table" and reg.broken or {}
|
||||
if reg.broken[slotId] == true then return true end
|
||||
reg.broken[slotId] = true
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.adoptCartSeal(cartId)
|
||||
local id = cartId or activeCart
|
||||
if not cartKey(id) then return false end
|
||||
local slot = SaveData.activeCartSlot(id)
|
||||
if type(slot) ~= "string" or not SaveData.slotSealBroken(id, slot) then
|
||||
return false
|
||||
end
|
||||
SaveData.breakSeal()
|
||||
return true
|
||||
end
|
||||
|
||||
local function stampActiveCartHash(fs)
|
||||
if not (activeCart and activeCartHash) then return end
|
||||
local key = CART_PREFIX .. activeCart
|
||||
local slot = activeSlotCache[key]
|
||||
if type(slot) ~= "string" then return end
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
local reg = registryOf(opts, key)
|
||||
if not reg or type(reg.list) ~= "table" then return end
|
||||
reg.hashes = type(reg.hashes) == "table" and reg.hashes or {}
|
||||
if reg.hashes[slot] == activeCartHash then return end
|
||||
reg.hashes[slot] = activeCartHash
|
||||
putRegistry(opts, key, reg)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
|
||||
-- ------- opaque playthrough identity
|
||||
@@ -1235,10 +1622,10 @@ function SaveData.newPlaythroughId()
|
||||
end
|
||||
|
||||
local function playthroughScope(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local key = activeScopeKey(version)
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or "legacy"
|
||||
ensureSlots(key, fs)
|
||||
return activeSlotCache[key] or "legacy", key
|
||||
end
|
||||
|
||||
local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
@@ -1246,7 +1633,7 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
local id = type(meta) == "table" and meta.playthroughId
|
||||
if type(id) ~= "string" or id == "" then return opts, false end
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local scope, key = playthroughScope(version, injectedFs)
|
||||
local persisted = SaveData.loadOptions(injectedFs)
|
||||
if opts then
|
||||
-- Slot selection and opaque playthrough routing are engine-owned launcher
|
||||
@@ -1254,14 +1641,15 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
-- save was promoted to slot1; writing that stale snapshot must not erase
|
||||
-- the freshly persisted routing and strand tool storage on next boot.
|
||||
opts.saveSlots = deepCopy(persisted.saveSlots)
|
||||
opts.cartSlots = deepCopy(persisted.cartSlots)
|
||||
opts.playthroughIds = deepCopy(persisted.playthroughIds)
|
||||
else
|
||||
opts = persisted
|
||||
end
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
local changed = opts.playthroughIds[version][scope] ~= id
|
||||
opts.playthroughIds[version][scope] = id
|
||||
opts.playthroughIds[key] = opts.playthroughIds[key] or {}
|
||||
local changed = opts.playthroughIds[key][scope] ~= id
|
||||
opts.playthroughIds[key][scope] = id
|
||||
return opts, changed
|
||||
end
|
||||
|
||||
@@ -1275,11 +1663,11 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local scope, key = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local isFresh = save == freshPlaythrough
|
||||
if isFresh then freshPlaythrough = nil end
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[key]
|
||||
local existing = byVersion and byVersion[scope]
|
||||
id = not isFresh and existing or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
@@ -1296,8 +1684,8 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
-- storage and repeating on every launch.
|
||||
if not (isFresh and type(existing) == "string" and existing ~= "") then
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
opts.playthroughIds[version][scope] = id
|
||||
opts.playthroughIds[key] = opts.playthroughIds[key] or {}
|
||||
opts.playthroughIds[key][scope] = id
|
||||
SaveData.saveOptions(opts, injectedFs)
|
||||
end
|
||||
end
|
||||
@@ -1325,9 +1713,9 @@ function SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
-- Resolve the selected scope first. That may perform the one-time legacy
|
||||
-- save-to-slot migration, which also moves the opaque identity mapping; only
|
||||
-- then read options so this lookup never observes the pre-migration table.
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local scope, key = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[key]
|
||||
id = byVersion and byVersion[scope] or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "no_selected_playthrough",
|
||||
@@ -1394,6 +1782,8 @@ function SaveData.buildMeta(mods, previous, sessionStart)
|
||||
savedAt = savedAt,
|
||||
sessionStart = started,
|
||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||
cartHash = type(previous) == "table" and previous.cartHash or nil,
|
||||
sealBroken = (type(previous) == "table" and previous.sealBroken == true) or nil,
|
||||
mods = list,
|
||||
}
|
||||
end
|
||||
@@ -1486,8 +1876,10 @@ function SaveData.runMigrations(save, modChains, activeMods)
|
||||
-- every step whose from-format the save has not passed yet runs, in
|
||||
-- (from, registration) order; a save at the current format runs none
|
||||
local fmt = (save.meta and save.meta.format) or 1
|
||||
for _, m in ipairs(coreMigrations) do
|
||||
if m.from >= fmt then m.fn(save) end
|
||||
if GameVersion.generation() == 1 then
|
||||
for _, m in ipairs(coreMigrations) do
|
||||
if m.from >= fmt then m.fn(save) end
|
||||
end
|
||||
end
|
||||
-- a save that predates meta records an empty mod set: an old vanilla
|
||||
-- save becomes a v2 vanilla save
|
||||
@@ -1617,6 +2009,12 @@ function SaveData.save(data, mods)
|
||||
if mods ~= nil or data.meta == nil then
|
||||
data.meta = SaveData.buildMeta(mods, data.meta)
|
||||
end
|
||||
if activeCart and activeCartHash and type(data.meta) == "table" then
|
||||
data.meta.cartHash = activeCartHash
|
||||
end
|
||||
if sealBroken and type(data.meta) == "table" then
|
||||
data.meta.sealBroken = true
|
||||
end
|
||||
local gameOnly = {}
|
||||
for k, v in pairs(data) do
|
||||
if k ~= "options" then gameOnly[k] = v end
|
||||
@@ -1644,6 +2042,7 @@ function SaveData.save(data, mods)
|
||||
return false
|
||||
end
|
||||
remove(fs, TMP_FILENAME)
|
||||
stampActiveCartHash(fs)
|
||||
Logger.info("saved game")
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus
|
||||
-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any
|
||||
-- platform, so the working path is raw FFI into the SDL2 LOVE already links,
|
||||
-- the same technique src/core/Orientation.lua uses. What is and is not verified
|
||||
-- on real hardware is written down in docs/shaderfx.md.
|
||||
|
||||
local Sensors = {}
|
||||
|
||||
-- Test seams: a no-device harness injects synthetic readings.
|
||||
local overrides = {}
|
||||
local orientationOverride = nil
|
||||
|
||||
function Sensors.setOverride(kind, x, y, z)
|
||||
overrides[kind] = { x, y, z }
|
||||
end
|
||||
|
||||
function Sensors.clearOverride(kind)
|
||||
overrides[kind] = nil
|
||||
end
|
||||
|
||||
function Sensors.clearAllOverrides()
|
||||
overrides = {}
|
||||
orientationOverride = nil
|
||||
end
|
||||
|
||||
-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil
|
||||
-- (nil = ask SDL for real). Test seam for rotateForScreen below.
|
||||
function Sensors.setOrientationOverride(o)
|
||||
orientationOverride = o
|
||||
end
|
||||
|
||||
-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today).
|
||||
local function safeHasSensor(kind)
|
||||
if not love or not love.sensor then return false end
|
||||
local ok, has = pcall(love.sensor.hasSensor, kind)
|
||||
return ok and has == true
|
||||
end
|
||||
|
||||
local function loveSensorRead(kind)
|
||||
if not safeHasSensor(kind) then return nil end
|
||||
local ok, x, y, z = pcall(love.sensor.getData, kind)
|
||||
if not ok then return nil end
|
||||
return x or 0, y or 0, z or 0
|
||||
end
|
||||
|
||||
-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5).
|
||||
local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2
|
||||
local SDL_INIT_SENSOR = 0x00008000
|
||||
local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO }
|
||||
|
||||
local sdlCdefOk = nil
|
||||
local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent)
|
||||
local sdlSensorTried = {}
|
||||
|
||||
-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library,
|
||||
-- then bare ffi.C, needed on Android where love-android links SDL2 statically
|
||||
-- into libmain.so and there is no standalone libSDL2.so to attach to by name
|
||||
-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716).
|
||||
local sdlLib = nil
|
||||
local function sdlFfi()
|
||||
local okFfi, ffi = pcall(require, "ffi")
|
||||
if not okFfi then return nil end
|
||||
if sdlCdefOk == nil then
|
||||
sdlCdefOk = pcall(ffi.cdef, [[
|
||||
typedef struct SDL_Window SDL_Window;
|
||||
typedef struct SDL_Sensor SDL_Sensor;
|
||||
int SDL_InitSubSystem(unsigned int flags);
|
||||
int SDL_NumSensors(void);
|
||||
int SDL_SensorGetDeviceType(int device_index);
|
||||
SDL_Sensor *SDL_SensorOpen(int device_index);
|
||||
void SDL_SensorUpdate(void);
|
||||
int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values);
|
||||
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||
int SDL_GetWindowDisplayIndex(SDL_Window *window);
|
||||
int SDL_GetDisplayOrientation(int displayIndex);
|
||||
]])
|
||||
if sdlCdefOk then
|
||||
local okLoad, lib = pcall(ffi.load, "SDL2")
|
||||
sdlLib = okLoad and lib or ffi.C
|
||||
end
|
||||
end
|
||||
if not sdlCdefOk or not sdlLib then return nil end
|
||||
return sdlLib
|
||||
end
|
||||
|
||||
-- Opens and caches once, and returns nil permanently once a real attempt finds
|
||||
-- none, so a no-hardware desktop run pays this cost once rather than per frame.
|
||||
local function sdlSensorHandle(lib, kind)
|
||||
if sdlSensorTried[kind] then return sdlSensorHandles[kind] end
|
||||
sdlSensorTried[kind] = true
|
||||
local wantType = KIND_TO_SDL_TYPE[kind]
|
||||
if not wantType then return nil end
|
||||
local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR)
|
||||
if not okInit then return nil end
|
||||
local okCount, count = pcall(lib.SDL_NumSensors)
|
||||
if not okCount or count <= 0 then return nil end
|
||||
for i = 0, count - 1 do
|
||||
local okType, t = pcall(lib.SDL_SensorGetDeviceType, i)
|
||||
if okType and t == wantType then
|
||||
local okOpen, handle = pcall(lib.SDL_SensorOpen, i)
|
||||
if okOpen and handle ~= nil then
|
||||
sdlSensorHandles[kind] = handle
|
||||
return handle
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function sdlSensorRead(kind)
|
||||
local lib = sdlFfi()
|
||||
if not lib then return nil end
|
||||
local handle = sdlSensorHandle(lib, kind)
|
||||
if not handle then return nil end
|
||||
pcall(lib.SDL_SensorUpdate)
|
||||
local data = require("ffi").new("float[3]")
|
||||
local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3)
|
||||
if not okGet or n ~= 0 then return nil end
|
||||
return data[0], data[1], data[2]
|
||||
end
|
||||
|
||||
-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen
|
||||
-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is
|
||||
-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation.
|
||||
local function isMobile()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local os = love.system.getOS()
|
||||
return os == "Android" or os == "iOS"
|
||||
end
|
||||
|
||||
local function sdlOrientation()
|
||||
if not isMobile() then return nil end
|
||||
local lib = sdlFfi()
|
||||
if not lib then return nil end
|
||||
local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow)
|
||||
if not okWin or win == nil then return nil end
|
||||
local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win)
|
||||
if not okIdx or idx < 0 then return nil end
|
||||
local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx)
|
||||
if not okOr then return nil end
|
||||
-- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED,
|
||||
-- 3=PORTRAIT, 4=PORTRAIT_FLIPPED.
|
||||
if o == 1 then return "landscape"
|
||||
elseif o == 2 then return "landscapeFlipped"
|
||||
elseif o == 3 then return "portrait"
|
||||
elseif o == 4 then return "portraitFlipped"
|
||||
else return nil end
|
||||
end
|
||||
|
||||
-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait,
|
||||
-- and unknown/undetectable (desktop always lands here), is a no-op.
|
||||
local function rotateForScreen(x, y)
|
||||
local o = orientationOverride or sdlOrientation()
|
||||
if o == "landscape" then return -y, x
|
||||
elseif o == "landscapeFlipped" then return y, -x
|
||||
elseif o == "portraitFlipped" then return -x, -y
|
||||
else return x, y end
|
||||
end
|
||||
|
||||
-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden
|
||||
-- that way, no hardware/module is available, or an underlying call errors.
|
||||
-- x/y are rotated to be screen-relative; z passes through unchanged.
|
||||
function Sensors.read(kind)
|
||||
local override = overrides[kind]
|
||||
if override then
|
||||
local x, y = rotateForScreen(override[1], override[2])
|
||||
return x, y, override[3]
|
||||
end
|
||||
|
||||
local x, y, z = loveSensorRead(kind)
|
||||
if not x then x, y, z = sdlSensorRead(kind) end
|
||||
if not x then return 0, 0, 0 end
|
||||
|
||||
x, y = rotateForScreen(x, y)
|
||||
return x, y, z
|
||||
end
|
||||
|
||||
return Sensors
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Central session lifecycle orchestrator. Subsystems register teardown hooks
|
||||
-- at module load (Assets release/invalidate bus, process shutdown below); the
|
||||
-- host only calls phase entry points.
|
||||
--
|
||||
-- Three tiers:
|
||||
-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets
|
||||
-- game endGameSession — audio + game:reset; workers stay alive
|
||||
-- process endProcess — worker shutdown on real app exit (love.quit)
|
||||
--
|
||||
-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever.
|
||||
|
||||
local SessionLifecycle = {}
|
||||
|
||||
local processShutdowns = {}
|
||||
|
||||
function SessionLifecycle.registerProcessShutdown(fn)
|
||||
processShutdowns[#processShutdowns + 1] = fn
|
||||
end
|
||||
|
||||
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||
-- version session (save editor or game). GPU release runs before soft
|
||||
-- invalidate via installLoader(nil).
|
||||
function SessionLifecycle.endMountedSession(version)
|
||||
local Assets = require("src.render.Assets")
|
||||
if Assets.releaseSession then Assets.releaseSession() end
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then Runtime.reset() end
|
||||
if Assets.installLoader then Assets.installLoader(nil) end
|
||||
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||
end
|
||||
|
||||
-- Evict every save-editor module from package.loaded without a hardcoded
|
||||
-- panel whitelist. Flat require names (App, Party, …) resolve under
|
||||
-- tools/save-editor/; path-style keys may also appear.
|
||||
local function flushEditorPackageLoaded()
|
||||
local fs = love and love.filesystem
|
||||
local function isEditorFlat(name)
|
||||
if not (fs and fs.getInfo) then return false end
|
||||
if name:find("[./]") then return false end
|
||||
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
||||
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string"
|
||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SessionLifecycle.endEditorSession(opts)
|
||||
opts = opts or {}
|
||||
if opts.app and opts.app.unload then pcall(opts.app.unload) end
|
||||
flushEditorPackageLoaded()
|
||||
if opts.version then
|
||||
SessionLifecycle.endMountedSession(opts.version)
|
||||
end
|
||||
end
|
||||
|
||||
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||
-- live game instance so map/GPU holders are gone before endMountedSession.
|
||||
function SessionLifecycle.endGameSession(game)
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
if game and game.reset then
|
||||
pcall(function() game:reset() end)
|
||||
end
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
end
|
||||
|
||||
function SessionLifecycle.endProcess()
|
||||
for _, fn in ipairs(processShutdowns) do pcall(fn) end
|
||||
end
|
||||
|
||||
return SessionLifecycle
|
||||
@@ -363,6 +363,14 @@ function Sound.sfxBusy()
|
||||
return true
|
||||
end
|
||||
|
||||
-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound`
|
||||
-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483).
|
||||
function Sound.waitSfxDone()
|
||||
if not curSfx then return end
|
||||
pcall(curSfx.src.stop, curSfx.src)
|
||||
curSfx = nil
|
||||
end
|
||||
|
||||
local function startSfx(data, name, def)
|
||||
local src = playPath(data, name, def)
|
||||
if not src then return end
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1 and
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27.
|
||||
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local HallOfFame = require("src.core.gen2.HallOfFame")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
local BattleTower = {}
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:1-7
|
||||
BattleTower.PARTY_LENGTH = 3
|
||||
BattleTower.STREAK_LENGTH = 7
|
||||
BattleTower.NUM_UNIQUE_MON = 21
|
||||
BattleTower.NUM_UNIQUE_TRAINERS = 70
|
||||
BattleTower.TRAINERDATALENGTH = 36
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:47 GS_BALL_AVAILABLE
|
||||
BattleTower.GS_BALL_AVAILABLE = 0x0b
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:55-61
|
||||
BattleTower.NO_CHALLENGE = 0
|
||||
BattleTower.SAVED_AND_LEFT = 1
|
||||
BattleTower.CHALLENGE_IN_PROGRESS = 2
|
||||
BattleTower.WON_CHALLENGE = 3
|
||||
BattleTower.RECEIVED_REWARD = 4
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:63-67
|
||||
BattleTower.REWARD_QUANTITY = 5
|
||||
BattleTower.MIN_REWARD = "HP_UP"
|
||||
BattleTower.MAX_REWARD = "CALCIUM"
|
||||
BattleTower.SKIPPED_REWARD = "LUCKY_PUNCH"
|
||||
BattleTower.FALLBACK_REWARD = "POTION"
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1492 bit 0,
|
||||
-- :978-1008 bit 1 of sBattleTowerSaveFileFlags.
|
||||
BattleTower.SAVEFILE_REGISTERED = 1
|
||||
BattleTower.SAVEFILE_EXPLANATION = 2
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:11-43
|
||||
BattleTower.ACTIONS = {
|
||||
CHECK_EXPLANATION_READ = 0,
|
||||
SET_EXPLANATION_READ = 1,
|
||||
GET_CHALLENGE_STATE = 2,
|
||||
SAVE_AND_QUIT = 3,
|
||||
CHALLENGECANCELED = 4,
|
||||
ACTION_05 = 5,
|
||||
ACTION_06 = 6,
|
||||
SAVELEVELGROUP = 7,
|
||||
LOADLEVELGROUP = 8,
|
||||
CHECKSAVEFILEISYOURS = 9,
|
||||
ACTION_0A = 10,
|
||||
GSBALL = 11,
|
||||
ACTION_0C = 12,
|
||||
ACTION_0D = 13,
|
||||
EGGTICKET = 14,
|
||||
ACTION_0F = 15,
|
||||
ACTION_10 = 16,
|
||||
ACTION_11 = 17,
|
||||
ACTION_12 = 18,
|
||||
ACTION_13 = 19,
|
||||
ACTION_14 = 20,
|
||||
ACTION_15 = 21,
|
||||
ACTION_16 = 22,
|
||||
ACTION_17 = 23,
|
||||
LEVEL_CHECK = 24,
|
||||
UBERS_CHECK = 25,
|
||||
RESETDATA = 26,
|
||||
GIVEREWARD = 27,
|
||||
ACTION_1C = 28,
|
||||
ACTION_1D = 29,
|
||||
CHOOSEREWARD = 30,
|
||||
SAVEOPTIONS = 31,
|
||||
}
|
||||
BattleTower.NUM_ACTIONS = 32
|
||||
|
||||
-- ../pokecrystal/ram/wram.asm:1703 wNrOfBeatenBattleTowerTrainers, which
|
||||
-- ../pokecrystal/maps/BattleTowerBattleRoom.asm:38 reads back with `readmem`.
|
||||
BattleTower.WRAM_NR_BEATEN = 0xcf64
|
||||
|
||||
-- ../pokecrystal/constants/battle_tower_constants.asm:49-53 `battletowertext`.
|
||||
BattleTower.TEXT_INTRO = 1
|
||||
BattleTower.TEXT_WIN = 2
|
||||
BattleTower.TEXT_LOSS = 3
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:39-55, in the order
|
||||
-- BattleTower_ExecuteJumptable prints them.
|
||||
BattleTower.RULE_HEADER_TEXT = "_ExcuseMeYoureNotReadyText"
|
||||
BattleTower.RULE_FAIL_TEXTS = {
|
||||
"_OnlyThreeMonMayBeEnteredText",
|
||||
"_TheMonMustAllBeDifferentKindsText",
|
||||
"_TheMonMustNotHoldTheSameItemsText",
|
||||
"_YouCantTakeAnEggText",
|
||||
}
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:61-68
|
||||
BattleTower.RULE_TAIL_TEXT = "_BattleTowerReturnWhenReadyText"
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:28-31 wStringBuffer2.
|
||||
BattleTower.RULE_PARTY_COUNT_TEXT = "3"
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3936-3958 BattleTower_UbersCheck.
|
||||
BattleTower.UBERS = {
|
||||
MEWTWO = true, MEW = true, LUGIA = true, HO_OH = true, CELEBI = true,
|
||||
}
|
||||
BattleTower.UBER_MIN_LEVEL = 70
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3869-3887 Strings_L10ToL100 and
|
||||
-- Strings_Ll0ToL40.
|
||||
BattleTower.MAX_LEVEL_GROUP = 10
|
||||
BattleTower.PRE_HOF_LEVEL_GROUPS = 4
|
||||
|
||||
local function counter(value)
|
||||
return math.max(0, math.floor(tonumber(value) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:147-172, on top of Save.battleTowerState.
|
||||
function BattleTower.state(save)
|
||||
local tower = Save.battleTowerState(save)
|
||||
-- ../pokecrystal/ram/sram.asm:156 sBTChoiceOfLevelGroup
|
||||
tower.levelGroup = counter(tower.levelGroup)
|
||||
-- ../pokecrystal/ram/sram.asm:159 sBattleTowerSaveFileFlags
|
||||
tower.saveFileFlags = counter(tower.saveFileFlags) % 256
|
||||
-- ../pokecrystal/ram/sram.asm:158 sBTTrainers, $ff for an unused slot
|
||||
if type(tower.trainers) ~= "table" then tower.trainers = {} end
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1449-1469 s5_aa8d
|
||||
if tower.reentry == nil then tower.reentry = false end
|
||||
return tower
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1471-1483 and
|
||||
-- :984-989: `and 1` / `and 2`, so the answer is the MASKED byte, not a boolean.
|
||||
function BattleTower.saveFileFlag(save, mask)
|
||||
local tower = BattleTower.state(save)
|
||||
return (math.floor(tower.saveFileFlags / mask) % 2 == 1) and mask or 0
|
||||
end
|
||||
|
||||
function BattleTower.setSaveFileFlag(save, mask)
|
||||
local tower = BattleTower.state(save)
|
||||
if math.floor(tower.saveFileFlags / mask) % 2 == 0 then
|
||||
tower.saveFileFlags = tower.saveFileFlags + mask
|
||||
end
|
||||
return tower.saveFileFlags
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:890-904
|
||||
function BattleTower.resetTrainers(save)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.trainers = {}
|
||||
tower.streak = 0
|
||||
return tower
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:1016-1022
|
||||
function BattleTower.setChallengeState(save, state)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.challenge = counter(state)
|
||||
return tower.challenge
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:206-211
|
||||
function BattleTower.partyCountOk(party)
|
||||
return #party == BattleTower.PARTY_LENGTH
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:218-277
|
||||
-- CheckPartyValueIsUnique: eggs are skipped on both sides and a zero value
|
||||
-- never collides, because `ld a, [hl] / and a / jr z, .next` drops it first.
|
||||
local function valuesUnique(party, get)
|
||||
local count = #party
|
||||
for i = 1, count - 1 do
|
||||
local mon = party[i]
|
||||
if not Breeding.isEgg(mon) then
|
||||
local value = get(mon)
|
||||
if value ~= nil and value ~= 0 then
|
||||
for j = i + 1, count do
|
||||
local other = party[j]
|
||||
if not Breeding.isEgg(other) and get(other) == value then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function speciesOf(mon) return mon and mon.species or nil end
|
||||
local function itemOf(mon) return mon and mon.item or nil end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:213-216
|
||||
function BattleTower.speciesUnique(party)
|
||||
return valuesUnique(party, speciesOf)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:279-282
|
||||
function BattleTower.itemsUnique(party)
|
||||
return valuesUnique(party, itemOf)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:284-299
|
||||
function BattleTower.partyHasEgg(party)
|
||||
for _, mon in ipairs(party) do
|
||||
if Breeding.isEgg(mon) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/rules.asm:27-37 and :94-103: every
|
||||
-- check runs, the first failure prints the header first, and a tail line
|
||||
-- follows any failure at all. Returns the text labels in printing order.
|
||||
function BattleTower.checkRules(party)
|
||||
party = party or {}
|
||||
local failed = {
|
||||
not BattleTower.partyCountOk(party),
|
||||
not BattleTower.speciesUnique(party),
|
||||
not BattleTower.itemsUnique(party),
|
||||
BattleTower.partyHasEgg(party),
|
||||
}
|
||||
local lines, any = {}, false
|
||||
for index = 1, #BattleTower.RULE_FAIL_TEXTS do
|
||||
if failed[index] then
|
||||
if not any then
|
||||
any = true
|
||||
lines[#lines + 1] = BattleTower.RULE_HEADER_TEXT
|
||||
end
|
||||
lines[#lines + 1] = BattleTower.RULE_FAIL_TEXTS[index]
|
||||
end
|
||||
end
|
||||
if any then lines[#lines + 1] = BattleTower.RULE_TAIL_TEXT end
|
||||
return lines, any
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1156-1166: the Hall of Fame flag is what
|
||||
-- opens rooms above L40, and the last row is always CANCEL.
|
||||
function BattleTower.levelGroupCount(save)
|
||||
if HallOfFame.hasEntered(save) then return BattleTower.MAX_LEVEL_GROUP end
|
||||
return BattleTower.PRE_HOF_LEVEL_GROUPS
|
||||
end
|
||||
|
||||
function BattleTower.levelGroupRows(save)
|
||||
local rows = {}
|
||||
for group = 1, BattleTower.levelGroupCount(save) do
|
||||
rows[group] = { group = group, level = group * 10 }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:1264-1267 `dec a / and $fe / srl a`, the
|
||||
-- BATTLE ROOM pair the hallway walks to (../pokecrystal/maps/BattleTowerHallway.asm:40-47).
|
||||
function BattleTower.roomOf(group)
|
||||
return math.floor((math.max(1, counter(group)) - 1) / 2)
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3892-3934 BattleTower_LevelCheck
|
||||
function BattleTower.levelCheck(party, group)
|
||||
local cap = counter(group) * 10
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if (tonumber(mon.level) or 0) > cap then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ../pokecrystal/mobile/mobile_46.asm:3936-3989 BattleTower_UbersCheck: below
|
||||
-- the L70 rooms an uber under L70 is refused, and its name goes in wcd49.
|
||||
function BattleTower.ubersCheck(party, group)
|
||||
if counter(group) >= BattleTower.UBER_MIN_LEVEL / 10 then return nil end
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if BattleTower.UBERS[mon.species]
|
||||
and (tonumber(mon.level) or 0) < BattleTower.UBER_MIN_LEVEL then
|
||||
return mon.species
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:955-976: a
|
||||
-- `maskbits` roll over HP_UP..CALCIUM that folds the overshoot back to the
|
||||
-- bottom of the range and rerolls LUCKY_PUNCH.
|
||||
function BattleTower.rollReward(order, random)
|
||||
if type(order) ~= "table" then return nil end
|
||||
local low, high
|
||||
for index, name in pairs(order) do
|
||||
if name == BattleTower.MIN_REWARD then low = index end
|
||||
if name == BattleTower.MAX_REWARD then high = index end
|
||||
end
|
||||
if not (low and high) then return nil end
|
||||
local span = high - low + 1
|
||||
local mask = 1
|
||||
while mask < span do mask = mask * 2 end
|
||||
for _ = 1, 64 do
|
||||
local roll = random(mask) % mask
|
||||
if roll >= span then roll = roll - span end
|
||||
local item = order[low + roll]
|
||||
if item ~= BattleTower.SKIPPED_REWARD then return item end
|
||||
end
|
||||
return order[low]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:906-933: five of
|
||||
-- the reward only fit when the ITEM pocket has a free slot, or already holds
|
||||
-- the reward with room for five more. Otherwise the desk hands over a POTION,
|
||||
-- which the script reads as "your PACK is stuffed full".
|
||||
function BattleTower.rewardFits(slots, capacity, held)
|
||||
if counter(slots) < counter(capacity) then return true end
|
||||
if held == nil then return false end
|
||||
return counter(held) < 99 - BattleTower.REWARD_QUANTITY + 1
|
||||
end
|
||||
|
||||
-- The opponent draw, all of it ../pokecrystal/engine/events/battle_tower/
|
||||
-- load_trainer.asm, over RomExtractorGen2's Crystal-only `battleTower` block.
|
||||
|
||||
-- data/generated/trainers.lua `battleTower`, absent on Gold and Silver.
|
||||
function BattleTower.roster(data)
|
||||
local trainers = data and (data.gen2Trainers or data.trainers)
|
||||
local roster = trainers and trainers.battleTower
|
||||
if type(roster) ~= "table" then return nil end
|
||||
if type(roster.trainers) ~= "table" or type(roster.groups) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
return roster
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:162-173 sBTMonOfTrainers: the last team's three
|
||||
-- species and the one before that's, which the draw refuses to repeat.
|
||||
function BattleTower.prevTeams(save)
|
||||
local tower = BattleTower.state(save)
|
||||
local teams = tower.prevTeams
|
||||
if type(teams) ~= "table" then
|
||||
teams = {}
|
||||
tower.prevTeams = teams
|
||||
end
|
||||
if type(teams.prev) ~= "table" then teams.prev = {} end
|
||||
if type(teams.prevPrev) ~= "table" then teams.prevPrev = {} end
|
||||
return teams
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:104-105 reads the
|
||||
-- room back as `ld a, [wBTChoiceOfLvlGroup] / dec a`, so group 0 indexes
|
||||
-- BEFORE the table; battle_tower.asm:1129-1141 is what can only save 1..10.
|
||||
function BattleTower.opponentGroup(save, roster)
|
||||
local groups = (roster and roster.levelGroups) or BattleTower.MAX_LEVEL_GROUP
|
||||
local group = counter(BattleTower.state(save).levelGroup)
|
||||
if group < 1 then return 1 end
|
||||
if group > groups then return groups end
|
||||
return group
|
||||
end
|
||||
|
||||
-- load_trainer.asm:24-38 and :101-166 reroll until the roll passes; one draw
|
||||
-- from the survivors is the same distribution and cannot spin. An empty
|
||||
-- survivor set is where the cart's own loop would hang, so it draws unfiltered.
|
||||
local function drawFiltered(count, random, accept)
|
||||
local pool = {}
|
||||
for index = 1, count do
|
||||
if accept(index) then pool[#pool + 1] = index end
|
||||
end
|
||||
if #pool == 0 then return random(count) end
|
||||
return pool[random(#pool)]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:22-60. The roll
|
||||
-- is refused while it names anybody already in sBTTrainers, and the winner is
|
||||
-- written into the slot sNrOfBeatenBattleTowerTrainers points at.
|
||||
function BattleTower.chooseTrainer(save, roster, random)
|
||||
local tower = BattleTower.state(save)
|
||||
-- :29-37, the ceiling read out of the cart: Crystal 1.0 masks with
|
||||
-- BATTLETOWER_NUM_UNIQUE_MON and can only ever draw the first 21 rows.
|
||||
local ceiling = counter(roster.sampleTrainers)
|
||||
if ceiling < 1 or ceiling > #roster.trainers then ceiling = #roster.trainers end
|
||||
local seen = {}
|
||||
for slot = 1, BattleTower.STREAK_LENGTH do
|
||||
local held = tonumber(tower.trainers[slot])
|
||||
if held then seen[held] = true end
|
||||
end
|
||||
local index = drawFiltered(ceiling, random, function(row)
|
||||
return not seen[row - 1]
|
||||
end)
|
||||
tower.trainers[math.min(counter(tower.streak), BattleTower.STREAK_LENGTH - 1)
|
||||
+ 1] = index - 1
|
||||
return roster.trainers[index]
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:94-208. Three
|
||||
-- draws out of the chosen level group, each refusing a species this team
|
||||
-- already holds, an ITEM this team already holds, and any species from the
|
||||
-- last two teams. wBT_OTTrainer was zero-filled and its three item slots set
|
||||
-- to $ff first (:7-17), which is why an unfilled slot collides with nothing.
|
||||
function BattleTower.chooseTeam(save, roster, group, random)
|
||||
local rows = roster.groups[group] or {}
|
||||
local teams = BattleTower.prevTeams(save)
|
||||
local picked, species, items = {}, {}, {}
|
||||
for _ = 1, BattleTower.PARTY_LENGTH do
|
||||
local index = drawFiltered(#rows, random, function(row)
|
||||
local mon = rows[row]
|
||||
if not mon then return false end
|
||||
if species[mon.species] then return false end
|
||||
if mon.item ~= nil and items[mon.item] then return false end
|
||||
for _, seen in ipairs(teams.prev) do
|
||||
if seen == mon.species then return false end
|
||||
end
|
||||
for _, seen in ipairs(teams.prevPrev) do
|
||||
if seen == mon.species then return false end
|
||||
end
|
||||
return true
|
||||
end)
|
||||
local mon = rows[index]
|
||||
if not mon then break end
|
||||
picked[#picked + 1] = mon
|
||||
species[mon.species] = true
|
||||
if mon.item ~= nil then items[mon.item] = true end
|
||||
end
|
||||
-- :195-206, after all three: this team becomes sBTMonPrevTrainer and the
|
||||
-- one it displaces becomes sBTMonPrevPrevTrainer.
|
||||
local prev = {}
|
||||
for slot, mon in ipairs(picked) do prev[slot] = mon.species end
|
||||
teams.prevPrev = teams.prev
|
||||
teams.prev = prev
|
||||
return picked
|
||||
end
|
||||
|
||||
-- The whole of `special LoadOpponentTrainerAndPokemon`, as one record. The
|
||||
-- cart keeps it in wBT_OTTrainer, which is WRAM bank 3 and is NOT saved --
|
||||
-- only the sBTTrainers slot and the two previous teams this walk writes are.
|
||||
function BattleTower.drawOpponent(data, save, random)
|
||||
local roster = BattleTower.roster(data)
|
||||
if not roster then return nil end
|
||||
local group = BattleTower.opponentGroup(save, roster)
|
||||
local trainer = BattleTower.chooseTrainer(save, roster, random)
|
||||
if not trainer then return nil end
|
||||
local rows = BattleTower.chooseTeam(save, roster, group, random)
|
||||
return {
|
||||
index = trainer.index,
|
||||
name = trainer.name,
|
||||
class = trainer.class,
|
||||
classId = trainer.classId,
|
||||
sprite = roster.classSprites and roster.classSprites[trainer.classId],
|
||||
group = group,
|
||||
rows = rows,
|
||||
}
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:549-570
|
||||
-- CopyBTTrainer_FromBT_OT_TowBT_OTTemp, which is the first thing
|
||||
-- ReadBTTrainerParty does: the challenge is now in progress and the streak
|
||||
-- counter steps BEFORE the battle, which is why beating the seventh opponent
|
||||
-- leaves the counter on BATTLETOWER_STREAK_LENGTH.
|
||||
function BattleTower.beginBattle(save)
|
||||
local tower = BattleTower.state(save)
|
||||
tower.challenge = BattleTower.CHALLENGE_IN_PROGRESS
|
||||
tower.streak = counter(tower.streak) + 1
|
||||
return tower.streak
|
||||
end
|
||||
|
||||
-- ReadBTTrainerParty's .otpartymon_loop (battle_tower.asm:349-376) copies the
|
||||
-- whole party_struct into wOTPartyMon, so nothing here is rolled: the stored
|
||||
-- stats go on top of Mon.new's, which agree with them anyway.
|
||||
-- The ROM nicknames never show: load_trainer.asm:171-190 overwrites each one
|
||||
-- with GetPokemonName, so the rows carry none.
|
||||
function BattleTower.battleParty(data, rows)
|
||||
local party = {}
|
||||
for _, row in ipairs(rows or {}) do
|
||||
local moves = nil
|
||||
if row.moves and #row.moves > 0 then
|
||||
moves = {}
|
||||
for slot, id in ipairs(row.moves) do
|
||||
local def = data and data.moves and data.moves[id]
|
||||
local max = (def and def.pp) or 0
|
||||
moves[slot] = {
|
||||
id = id,
|
||||
pp = (row.pp and row.pp[slot]) or max,
|
||||
maxPp = max,
|
||||
}
|
||||
end
|
||||
end
|
||||
local dvs = row.dvs or {}
|
||||
local statExp = row.statExp or {}
|
||||
local mon = Mon.new(data, row.species, row.level, {
|
||||
moves = moves,
|
||||
item = row.item,
|
||||
dvs = { attack = dvs.attack, defense = dvs.defense,
|
||||
speed = dvs.speed, special = dvs.special },
|
||||
statExp = { hp = statExp.hp, attack = statExp.attack,
|
||||
defense = statExp.defense, speed = statExp.speed,
|
||||
special = statExp.special },
|
||||
happiness = row.happiness,
|
||||
})
|
||||
if mon then
|
||||
if row.stats then
|
||||
mon.stats = {
|
||||
hp = row.stats.hp, attack = row.stats.attack,
|
||||
defense = row.stats.defense, speed = row.stats.speed,
|
||||
specialAttack = row.stats.specialAttack,
|
||||
specialDefense = row.stats.specialDefense,
|
||||
}
|
||||
end
|
||||
mon.maxHp = row.maxHp or mon.maxHp
|
||||
mon.hp = row.hp or mon.maxHp
|
||||
mon.experience = row.experience or mon.experience
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
end
|
||||
return party
|
||||
end
|
||||
|
||||
return BattleTower
|
||||
+12
-1
@@ -109,6 +109,17 @@ function Boxes.restorePP(mon)
|
||||
end
|
||||
end
|
||||
|
||||
-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so
|
||||
-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83).
|
||||
function Boxes.enterBox(mon)
|
||||
if not mon then return mon end
|
||||
Boxes.restorePP(mon)
|
||||
mon.status = nil
|
||||
mon.statusTurns = nil
|
||||
mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp)
|
||||
return mon
|
||||
end
|
||||
|
||||
function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
|
||||
if not ok then return false, reason end
|
||||
@@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
box[#box + 1] = mon
|
||||
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
|
||||
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
|
||||
Boxes.restorePP(mon)
|
||||
Boxes.enterBox(mon)
|
||||
return true, mon
|
||||
end
|
||||
|
||||
|
||||
@@ -883,6 +883,11 @@ function Breeding.withdraw(data, save, which)
|
||||
rebuilt.status = nil
|
||||
for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end
|
||||
rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel
|
||||
-- RetrieveBreedmon copies the stored struct back whole, MON_CAUGHTDATA
|
||||
-- included -- engine/pokemon/move_mon.asm:805.
|
||||
rebuilt.caughtTime = mon.caughtTime
|
||||
rebuilt.caughtLocation = mon.caughtLocation
|
||||
rebuilt.caughtByGender = mon.caughtByGender
|
||||
rebuilt.ot, rebuilt.otId = mon.ot, mon.otId
|
||||
-- CalcExpAtLevel, which is the experience loss.
|
||||
rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel)
|
||||
@@ -1077,7 +1082,9 @@ end
|
||||
-- Returns the new record plus a table of side effects the caller owes:
|
||||
-- { species = , togepi = bool } -- SetSeenAndCaughtMon, and the
|
||||
-- EVENT_TOGEPI_HATCHED flag the ASM sets by hand for exactly one species.
|
||||
function Breeding.hatch(data, save, index, nickname)
|
||||
-- `where` is the hatch site for SetEggMonCaughtData: landmark, timeOfDay and
|
||||
-- playerGender -- engine/pokemon/breeding.asm:228.
|
||||
function Breeding.hatch(data, save, index, nickname, where)
|
||||
local party = (save and save.party) or {}
|
||||
local egg = party[index]
|
||||
if not Breeding.isEgg(egg) then return nil end
|
||||
@@ -1097,6 +1104,18 @@ function Breeding.hatch(data, save, index, nickname)
|
||||
hatched.ot = egg.ot or (save.player and save.player.name)
|
||||
hatched.otId = egg.otId or (save.player and save.player.id)
|
||||
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
|
||||
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
|
||||
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
|
||||
if Mon.hasCaughtData(save and save.version) then
|
||||
where = where or {}
|
||||
Mon.setCaughtData(hatched, {
|
||||
level = Mon.CAUGHT_EGG_LEVEL,
|
||||
timeOfDay = where.timeOfDay,
|
||||
landmark = where.landmark,
|
||||
playerGender = where.playerGender
|
||||
or (save.player and save.player.gender),
|
||||
})
|
||||
end
|
||||
party[index] = hatched
|
||||
|
||||
Breeding.markPokedex(save, egg.species)
|
||||
|
||||
@@ -803,7 +803,9 @@ function BugContest.collectCaughtMon(save, partySize, boxes)
|
||||
end
|
||||
boxes = boxes or require("src.core.gen2.Boxes")
|
||||
local box = boxes.box(save, save.currentBox or 1)
|
||||
if box then box[#box + 1] = mon end
|
||||
-- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into
|
||||
-- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90).
|
||||
if box then box[#box + 1] = boxes.enterBox(mon) end
|
||||
return BugContest.BOXED_MON, mon
|
||||
end
|
||||
|
||||
|
||||
@@ -357,6 +357,7 @@ Evolution.MON_FIELDS = {
|
||||
experience = true, dvs = true, stats = true, hp = true, maxHp = true,
|
||||
types = true, moves = true, item = true, status = true, happiness = true,
|
||||
caughtLevel = true, shiny = true, gender = true,
|
||||
caughtTime = true, caughtLocation = true, caughtByGender = true,
|
||||
}
|
||||
|
||||
-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it
|
||||
@@ -410,6 +411,11 @@ function Evolution.apply(data, mon, entry)
|
||||
evolved.experience = mon.experience
|
||||
evolved.status = mon.status
|
||||
evolved.caughtLevel = mon.caughtLevel
|
||||
-- Nothing between GetBaseData and the PARTYMON_STRUCT_LENGTH copy back
|
||||
-- touches MON_CAUGHTDATA -- engine/pokemon/evolve.asm:291-293.
|
||||
evolved.caughtTime = mon.caughtTime
|
||||
evolved.caughtLocation = mon.caughtLocation
|
||||
evolved.caughtByGender = mon.caughtByGender
|
||||
-- Anything a future field adds to a party record (mail, pokerus) rides along
|
||||
-- rather than being silently dropped. Only fields Mon.new does NOT own may
|
||||
-- be carried: copying `nickname` back would undo
|
||||
|
||||
@@ -86,8 +86,11 @@ Happiness.EVENT = {
|
||||
ENERGYROOT = 16, -- 10
|
||||
REVIVALHERB = 17, -- 11
|
||||
GROOMING = 18, -- 12
|
||||
-- Crystal only; Gold's enum stops at GROOMING
|
||||
-- (pokegold constants/pokemon_data_constants.asm:205).
|
||||
GAINLEVELATHOME = 19, -- 13
|
||||
}
|
||||
Happiness.NUM_EVENTS = 18
|
||||
Happiness.NUM_EVENTS = 19
|
||||
|
||||
-- data/events/happiness_changes.asm, transcribed row for row. The three
|
||||
-- columns are the three tiers below, in order.
|
||||
@@ -110,6 +113,7 @@ Happiness.CHANGES = {
|
||||
{ -10, -10, -15 }, -- 10 Used Energy Root (bitter)
|
||||
{ -15, -15, -20 }, -- 11 Used Revival Herb (bitter)
|
||||
{ 3, 3, 1 }, -- 12 Grooming
|
||||
{ 10, 6, 4 }, -- 13 Gained a level where it was caught (Crystal)
|
||||
}
|
||||
|
||||
-- Which of HappinessChanges' three columns a CURRENT value reads. The cart
|
||||
@@ -174,6 +178,23 @@ function Happiness.change(mon, event)
|
||||
return value
|
||||
end
|
||||
|
||||
-- LevelUpHappinessMod: the caught location masked with CAUGHT_LOCATION_MASK
|
||||
-- against the current landmark -- engine/pokemon/level_up_happiness.asm:1-20.
|
||||
function Happiness.levelUpEvent(mon, landmark)
|
||||
local caught = type(mon) == "table" and tonumber(mon.caughtLocation) or nil
|
||||
landmark = tonumber(landmark)
|
||||
if not (caught and landmark) then return "GAINLEVEL" end
|
||||
if math.floor(caught) % 0x80 ~= math.floor(landmark) % 0x80 then
|
||||
return "GAINLEVEL"
|
||||
end
|
||||
return "GAINLEVELATHOME"
|
||||
end
|
||||
|
||||
-- The `callfar ChangeHappiness` that follows -- level_up_happiness.asm:19.
|
||||
function Happiness.levelUp(mon, landmark)
|
||||
return Happiness.change(mon, Happiness.levelUpEvent(mon, landmark))
|
||||
end
|
||||
|
||||
-- The same event across a party, which is how the Gym Leader award is written
|
||||
-- out longhand in engine/battle/core.asm InitEnemyTrainer:
|
||||
--
|
||||
|
||||
+128
-18
@@ -1,5 +1,5 @@
|
||||
-- The two pieces of Gen 2 world state that override where a wild mon comes
|
||||
-- from: the three roaming legendaries, and swarms.
|
||||
-- from: the roaming legendaries, and swarms.
|
||||
--
|
||||
-- Both live in one module because they are the same KIND of thing -- a
|
||||
-- persistent record that sits in front of a map's own encounter table -- and
|
||||
@@ -26,6 +26,7 @@
|
||||
-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a
|
||||
-- beast is caught or beaten -- is nil.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -37,7 +38,7 @@ local Roamers = {}
|
||||
-- map connection or a door triggers) and JumpRoamMons (the scatter a fly or a
|
||||
-- teleport triggers) -- so a tracker mod sees every hop.
|
||||
--
|
||||
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune
|
||||
-- index the roamer slot, 1 Raikou / 2 Entei / 3 Suicune (Gold only)
|
||||
-- slot that roamer record, already carrying the new map
|
||||
-- species the beast's species id
|
||||
-- from the map id it left
|
||||
@@ -68,9 +69,31 @@ Roamers.SPECIES = {
|
||||
{ species = "ENTEI", level = 40, map = "ROUTE_37" },
|
||||
{ species = "SUICUNE", level = 40, map = "ROUTE_38" },
|
||||
}
|
||||
Roamers.COUNT = 3
|
||||
Roamers.LEVEL = 40
|
||||
|
||||
-- pokecrystal/engine/overworld/wildmons.asm:493 seeds Raikou and Entei only,
|
||||
-- so the roster is read out of the cache when the cache carries one.
|
||||
local function startMapFor(species)
|
||||
for _, row in ipairs(Roamers.SPECIES) do
|
||||
if row.species == species then return row.map end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Roamers.roster(encounters)
|
||||
local extracted = encounters and encounters.roamMons
|
||||
if type(extracted) ~= "table" or #extracted == 0 then return Roamers.SPECIES end
|
||||
local rows = {}
|
||||
for _, row in ipairs(extracted) do
|
||||
rows[#rows + 1] = {
|
||||
species = row.species,
|
||||
level = row.level or Roamers.LEVEL,
|
||||
map = row.map or startMapFor(row.species),
|
||||
}
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- data/wild/roammon_maps.asm, entry for entry and in order. The order matters
|
||||
-- twice over: `.Update` picks a connection by a two-bit index into the list, so
|
||||
-- shuffling one row changes which route a beast walks to, and JumpRoamMon picks
|
||||
@@ -185,7 +208,7 @@ end
|
||||
-- The save record
|
||||
--------------------------------------------------------------------------
|
||||
--
|
||||
-- save.roamers is a three-slot array in the order above, written first by the
|
||||
-- save.roamers is a slot array in the roster order above, written first by the
|
||||
-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower
|
||||
-- basement script fires. Each slot:
|
||||
--
|
||||
@@ -215,13 +238,15 @@ function Roamers.slot(save, index)
|
||||
end
|
||||
|
||||
-- InitRoamMons. Safe to call twice: the Burned Tower script is behind a scene
|
||||
-- flag, but a re-init would hand the player three fresh beasts, so this only
|
||||
-- writes when there is nothing there.
|
||||
-- flag, but a re-init would hand the player fresh beasts, so this only writes
|
||||
-- when there is nothing there.
|
||||
function Roamers.init(save, opts)
|
||||
if type(save) ~= "table" then return nil end
|
||||
if save.roamers and not (opts and opts.force) then return save.roamers end
|
||||
local encounters = opts and (opts.encounters
|
||||
or (opts.data and opts.data.gen2Encounters))
|
||||
local list = {}
|
||||
for _, row in ipairs(Roamers.SPECIES) do
|
||||
for _, row in ipairs(Roamers.roster(encounters)) do
|
||||
list[#list + 1] = {
|
||||
species = row.species,
|
||||
level = row.level,
|
||||
@@ -413,7 +438,23 @@ end
|
||||
-- -- the roaming battle is one attack long unless it is trapped. The other
|
||||
-- two lists are the same routine's 50% and 10% gates and live here so the
|
||||
-- battle engine has one place to read them from.
|
||||
Roamers.ALWAYS_FLEE = { RAIKOU = true, ENTEI = true, SUICUNE = true }
|
||||
-- pokegold/data/wild/flee_mons.asm:34 lists Suicune; pokecrystal's:34 ends the
|
||||
-- list at Entei, which is what makes maps/TinTower1F.asm:119 catchable.
|
||||
Roamers.ALWAYS_FLEE_BY_ENGINE = {
|
||||
gs = { RAIKOU = true, ENTEI = true, SUICUNE = true },
|
||||
crystal = { RAIKOU = true, ENTEI = true },
|
||||
}
|
||||
|
||||
function Roamers.alwaysFleeMons(versionId)
|
||||
return Roamers.ALWAYS_FLEE_BY_ENGINE[GameVersion.engine(versionId)]
|
||||
or Roamers.ALWAYS_FLEE_BY_ENGINE.gs
|
||||
end
|
||||
|
||||
-- src/battle/gen2/Battle.lua:4070 reads AlwaysFleeMons by species name.
|
||||
Roamers.ALWAYS_FLEE = setmetatable({}, {
|
||||
__index = function(_, species) return Roamers.alwaysFleeMons()[species] end,
|
||||
})
|
||||
|
||||
Roamers.OFTEN_FLEE = {
|
||||
CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true,
|
||||
QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true,
|
||||
@@ -433,29 +474,79 @@ local Swarm = {}
|
||||
Roamers.Swarm = Swarm
|
||||
|
||||
-- The state, on the save:
|
||||
-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id
|
||||
-- save.swarmMaps the stored map pairs, keyed by swarm kind
|
||||
-- save.swarmMap the Gold single pair, kept as the legacy alias
|
||||
-- save.dailyFlags.swarm DAILYFLAGS1_SWARM_F
|
||||
-- save.dailyFlags.fishingSwarm wFishingSwarmFlag (FISHSWARM_* 0/1/2)
|
||||
-- save.dailyResetDay the day wDailyResetTimer was last restarted
|
||||
--
|
||||
-- src/world/gen2/World.lua:setSwarm and the ActivateFishingSwarm special
|
||||
-- already write the first three; this module is where they are READ and where
|
||||
-- they expire.
|
||||
-- already write the flags and the alias; this module is where they are READ
|
||||
-- and where they expire.
|
||||
|
||||
-- constants/script_constants.asm, ActivateFishingSwarm setval arguments.
|
||||
Swarm.FISH_NONE = 0
|
||||
Swarm.FISH_QWILFISH = 1
|
||||
Swarm.FISH_REMORAID = 2
|
||||
|
||||
-- pokecrystal/constants/script_constants.asm:256-257 -- the kind byte Crystal
|
||||
-- puts in front of a `swarm` map, choosing between its two stored pairs.
|
||||
Swarm.KIND_ORDER = { "DUNSPARCE", "YANMA" }
|
||||
Swarm.KINDS = { [0] = "DUNSPARCE", [1] = "YANMA" }
|
||||
Swarm.DEFAULT_KIND = "DUNSPARCE"
|
||||
|
||||
local KIND_NAMES = { DUNSPARCE = true, YANMA = true }
|
||||
|
||||
local function kindName(kind)
|
||||
if type(kind) == "number" then return Swarm.KINDS[kind] or Swarm.DEFAULT_KIND end
|
||||
if KIND_NAMES[kind] then return kind end
|
||||
return Swarm.DEFAULT_KIND
|
||||
end
|
||||
|
||||
-- pokegold/engine/events/specials.asm:288-293 has the one pair, so a Gold save
|
||||
-- (and every save written before this record was keyed) folds onto that key.
|
||||
local function goldShape(maps)
|
||||
return maps.YANMA == nil
|
||||
end
|
||||
|
||||
function Swarm.maps(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) ~= "table" then
|
||||
maps = {}
|
||||
save.swarmMaps = maps
|
||||
end
|
||||
if save.swarmMap ~= nil and goldShape(maps) then
|
||||
maps[Swarm.DEFAULT_KIND] = save.swarmMap
|
||||
end
|
||||
return maps
|
||||
end
|
||||
|
||||
local function anyMap(save)
|
||||
if type(save) ~= "table" then return false end
|
||||
if save.swarmMap ~= nil then return true end
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) ~= "table" then return false end
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do
|
||||
if maps[name] ~= nil then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: one command
|
||||
-- writes the map pair AND the daily flag. A port that stored only the map
|
||||
-- would leave the Dunsparce call live for the rest of the game, because
|
||||
-- CheckSwarmFlag answers off the flag and clears the pair itself.
|
||||
function Swarm.set(save, mapId)
|
||||
--
|
||||
-- pokecrystal/engine/events/specials.asm:290-306 picks the pair off c instead.
|
||||
function Swarm.set(save, mapId, kind)
|
||||
if type(save) ~= "table" then return false end
|
||||
save.dailyFlags = save.dailyFlags or {}
|
||||
save.dailyFlags.swarm = true
|
||||
save.swarmMap = mapId
|
||||
local name = kindName(kind)
|
||||
local maps = Swarm.maps(save)
|
||||
maps[name] = mapId
|
||||
if name == Swarm.DEFAULT_KIND then save.swarmMap = mapId end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -475,9 +566,22 @@ function Swarm.active(save)
|
||||
and save.dailyFlags.swarm == true
|
||||
end
|
||||
|
||||
function Swarm.mapId(save)
|
||||
function Swarm.mapId(save, kind)
|
||||
if not Swarm.active(save) then return nil end
|
||||
return save.swarmMap
|
||||
local maps = Swarm.maps(save)
|
||||
return maps and maps[kindName(kind)] or nil
|
||||
end
|
||||
|
||||
-- pokecrystal/engine/overworld/wildmons.asm:414-451 tests Dunsparce first and
|
||||
-- falls through to Yanma, so a map both are on answers Dunsparce.
|
||||
function Swarm.onMap(save, mapId)
|
||||
if mapId == nil or not Swarm.active(save) then return nil end
|
||||
local maps = Swarm.maps(save)
|
||||
if not maps then return nil end
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do
|
||||
if maps[name] == mapId then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Swarm.fishing(save)
|
||||
@@ -493,6 +597,12 @@ function Swarm.check(save)
|
||||
if type(save) ~= "table" then return 1 end
|
||||
if Swarm.active(save) then return 0 end
|
||||
if save.dailyFlags then save.dailyFlags.fishingSwarm = nil end
|
||||
-- pokecrystal/engine/overworld/time.asm:103-112 zeroes wSwarmFlags whole,
|
||||
-- which strands both of Crystal's pairs on the one daily tick.
|
||||
local maps = save.swarmMaps
|
||||
if type(maps) == "table" then
|
||||
for _, name in ipairs(Swarm.KIND_ORDER) do maps[name] = nil end
|
||||
end
|
||||
save.swarmMap = nil
|
||||
return 1
|
||||
end
|
||||
@@ -518,10 +628,10 @@ end
|
||||
-- rather than needing its own timer. Returns true when the swarm ended on
|
||||
-- this call.
|
||||
function Swarm.timeEvents(save, day)
|
||||
local hadMap = anyMap(save)
|
||||
local reset = Swarm.checkDailyReset(save, day)
|
||||
local hadMap = save and save.swarmMap ~= nil
|
||||
Swarm.check(save)
|
||||
return reset and hadMap and (save.swarmMap == nil)
|
||||
return reset and hadMap and not anyMap(save)
|
||||
end
|
||||
|
||||
-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one,
|
||||
@@ -538,7 +648,7 @@ end
|
||||
-- lookup here falls through to the map's own list.
|
||||
function Swarm.entry(save, encounters, mapId, kind)
|
||||
if not encounters then return nil end
|
||||
if Swarm.mapId(save) ~= mapId then return nil end
|
||||
if not Swarm.onMap(save, mapId) then return nil end
|
||||
local table_ = (kind == "water") and encounters.swarmWater
|
||||
or encounters.swarmGrass
|
||||
return table_ and table_[mapId] or nil
|
||||
|
||||
+90
-7
@@ -133,10 +133,33 @@ local function fs()
|
||||
return love.filesystem
|
||||
end
|
||||
|
||||
-- The blank-name fallback is the first PlayerNameArray row, which differs
|
||||
-- per edition -- data/player_names.asm:12-23.
|
||||
function Save.defaultPlayerName(version)
|
||||
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
|
||||
-- The first PlayerNameArray row -- pokegold data/player_names.asm:12-22, and
|
||||
-- Crystal's gender-split arrays at data/player_names.asm:12-16, :31-35.
|
||||
Save.DEFAULT_PLAYER_NAMES = {
|
||||
gold = "GOLD",
|
||||
silver = "SILVER",
|
||||
crystal = "CHRIS",
|
||||
}
|
||||
|
||||
-- FemalePlayerNameArray's first row, the `.Kris` dname NamePlayer falls back to
|
||||
-- (data/player_names.asm:31-32, engine/menus/intro_menu.asm:768-781).
|
||||
Save.DEFAULT_PLAYER_NAMES_FEMALE = {
|
||||
crystal = "KRIS",
|
||||
}
|
||||
|
||||
-- wPlayerGender as this save spells it (constants/ram_constants.asm:176-177).
|
||||
function Save.isFemale(save)
|
||||
local player = type(save) == "table" and save.player
|
||||
return (player and player.gender) == "female"
|
||||
end
|
||||
|
||||
function Save.defaultPlayerName(version, gender)
|
||||
version = version or GameVersion.get()
|
||||
if gender == "female" then
|
||||
local female = Save.DEFAULT_PLAYER_NAMES_FEMALE[version]
|
||||
if female then return female end
|
||||
end
|
||||
return Save.DEFAULT_PLAYER_NAMES[version] or "GOLD"
|
||||
end
|
||||
|
||||
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
|
||||
@@ -148,10 +171,13 @@ function Save.newGame(opts)
|
||||
version = GameVersion.get(),
|
||||
generation = 2,
|
||||
player = {
|
||||
name = opts.playerName or Save.defaultPlayerName(),
|
||||
name = opts.playerName
|
||||
or Save.defaultPlayerName(nil, opts.gender or "male"),
|
||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||
-- (engine/menus/intro_menu.asm:41-49).
|
||||
id = opts.trainerId or rand(0, 65535),
|
||||
-- InitCrystalData zeroes wPlayerGender before InitGender is even offered
|
||||
-- (engine/menus/init_gender.asm:1-6).
|
||||
gender = opts.gender or "male",
|
||||
money = 3000,
|
||||
coins = 0,
|
||||
@@ -272,9 +298,11 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- uses (src/core/SaveData.lua) and they drive the same shared modules, so
|
||||
-- a player's display and speed choices mean the same thing in both games.
|
||||
speed = 1, -- GameSpeed.LEVELS multiplier, logic only
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto" is the
|
||||
-- Gen 1 save's own default (src/core/SaveData.lua); same key, same module.
|
||||
performance = "auto",
|
||||
zoom = 0, -- Zoom offset from the window's fit scale
|
||||
tilt = 0, -- Tilt.LEVELS degrees, 0 = off
|
||||
gbcfx = 0, -- GBCFX ladder, 0 = off
|
||||
-- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the
|
||||
-- default -- this is a Game Boy Color game, so colour is ON out of the box
|
||||
-- and the other two rungs are the deliberate step DOWN to a grey or green
|
||||
@@ -283,6 +311,8 @@ Save.DEFAULT_OPTIONS = {
|
||||
color = "gbc",
|
||||
videoMode = "windowed",
|
||||
fpsCap = 60,
|
||||
-- BATTLE BG (#1709): white | black, the surround around the battle screen.
|
||||
battleBg = "white",
|
||||
-- VOID FILL: fade | water | trees | black. fade is each map header's own
|
||||
-- border block with the dissolve across a boundary (#1418).
|
||||
voidFill = "fade",
|
||||
@@ -379,6 +409,49 @@ local function normalizePokerus(mons)
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte.
|
||||
Save.GS_BALL_STATES = { have = true, given = true, used = true }
|
||||
|
||||
local function counter(value)
|
||||
return math.max(0, math.floor(tonumber(value) or 0))
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:138 "SRAM Crystal Data", created on demand the
|
||||
-- way Mail.state creates sPartyMail, so a Crystal handler can index freely.
|
||||
function Save.crystalState(save)
|
||||
local crystal = save.crystal or {}
|
||||
save.crystal = crystal
|
||||
if crystal.celebiCaught == nil then crystal.celebiCaught = false end
|
||||
crystal.beasts = crystal.beasts or {}
|
||||
-- ../pokecrystal/ram/wram.asm:3342 wBuenasPassword, :3343 wBlueCardBalance.
|
||||
local buena = crystal.buenaPassword or {}
|
||||
crystal.buenaPassword = buena
|
||||
buena.prizesToday = counter(buena.prizesToday)
|
||||
buena.streak = counter(buena.streak)
|
||||
-- ../pokecrystal/engine/events/move_tutor.asm:1 MoveTutor.
|
||||
crystal.moveTutor = crystal.moveTutor or {}
|
||||
if crystal.moveTutor.used == nil then crystal.moveTutor.used = false end
|
||||
-- ../pokecrystal/ram/wram.asm:3445 wUnlockedUnowns.
|
||||
crystal.unownWords = crystal.unownWords or {}
|
||||
return crystal
|
||||
end
|
||||
|
||||
-- ../pokecrystal/ram/sram.asm:147 "SRAM Battle Tower". `reward` stays nil
|
||||
-- until one is won, which is sBattleTowerReward's zero byte (:160).
|
||||
function Save.battleTowerState(save)
|
||||
local tower = save.battleTower or {}
|
||||
save.battleTower = tower
|
||||
-- ../pokecrystal/ram/sram.asm:155 sNrOfBeatenBattleTowerTrainers.
|
||||
tower.streak = counter(tower.streak)
|
||||
tower.best = counter(tower.best)
|
||||
-- ../pokecrystal/ram/sram.asm:150 sBattleTowerChallengeState: 0 normal, 2 tower.
|
||||
tower.challenge = counter(tower.challenge)
|
||||
-- ../pokecrystal/ram/sram.asm:162 sBTMonOfTrainers.
|
||||
tower.prevTeams = tower.prevTeams or {}
|
||||
if tower.inChallenge == nil then tower.inChallenge = false end
|
||||
return tower
|
||||
end
|
||||
|
||||
-- Fill in anything a save (or an older save) is missing, so callers can index
|
||||
-- freely. Runs on both newGame and load.
|
||||
function Save.normalize(save)
|
||||
@@ -390,7 +463,11 @@ function Save.normalize(save)
|
||||
end
|
||||
save.generation = 2
|
||||
save.player = save.player or {}
|
||||
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
|
||||
-- _ResetWRAM leaves wPlayerGender at 0, PLAYERGENDER_MALE, and Gold never
|
||||
-- writes it -- constants/ram_constants.asm:177.
|
||||
save.player.gender = save.player.gender or "male"
|
||||
save.player.name = save.player.name
|
||||
or Save.defaultPlayerName(save.version, save.player.gender)
|
||||
save.player.id = save.player.id or rand(0, 65535)
|
||||
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
||||
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
||||
@@ -462,6 +539,12 @@ function Save.normalize(save)
|
||||
save.playTime = save.playTime
|
||||
or { hours = 0, minutes = 0, seconds = 0, frames = 0 }
|
||||
save.rtc = save.rtc or {}
|
||||
-- ../pokecrystal/ram/sram.asm:138,147: both regions are Crystal's own, so a
|
||||
-- Gold or Silver file never grows either key.
|
||||
if GameVersion.engine(save.version) == "crystal" then
|
||||
Save.crystalState(save)
|
||||
Save.battleTowerState(save)
|
||||
end
|
||||
-- HallOfFame.record fills in the count and the roster list, and trims a
|
||||
-- roster that a corrupt file grew past NUM_HOF_TEAMS -- the same guard the
|
||||
-- party gets below, for the same reason.
|
||||
|
||||
@@ -249,12 +249,13 @@ function SwitchDiagnostics.probeAssets(version)
|
||||
end
|
||||
|
||||
-- Shallow listing so we can see if the extract tree exists at all.
|
||||
local roots = { "yellow", "blue", "gold", "silver", "assets",
|
||||
local roots = { "yellow", "blue", "gold", "silver", "crystal", "assets",
|
||||
"yellow/assets/generated",
|
||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||
"gold/data/generated", "silver/assets/generated",
|
||||
"silver/data/generated" }
|
||||
"silver/data/generated", "crystal/assets/generated",
|
||||
"crystal/data/generated" }
|
||||
for _, dir in ipairs(roots) do
|
||||
local info = filesystem.getInfo(dir)
|
||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
-- Engine-owned contract for generated ROM caches.
|
||||
--
|
||||
-- A cache is playable only when its versioned marker matches the ROM and every
|
||||
-- required output for that version exists. Extraction writers may differ by
|
||||
-- platform, but they must publish through this contract so partial staging
|
||||
-- cannot look ready to the runtime.
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local CacheContract = {}
|
||||
|
||||
CacheContract.FORMAT = "rom-cache-v10:"
|
||||
CacheContract.MARKER_PATH = "rom-cache.complete"
|
||||
|
||||
CacheContract.REQUIRED_FILES = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/text.lua",
|
||||
"data/generated/field.lua",
|
||||
"data/generated/battle_anims.lua",
|
||||
"assets/generated/title/pokemon_logo.png",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/anims/move_anim_0.png",
|
||||
"assets/generated/battle/anims/move_anim_1.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/trade/game_boy.png",
|
||||
}
|
||||
|
||||
CacheContract.VERSION_REQUIRED_FILES = {
|
||||
yellow = {
|
||||
"assets/generated/battle/trainers/jessie_james.png",
|
||||
"assets/generated/battle/profoakb.png",
|
||||
"assets/generated/pikachu/pikapic_1.png",
|
||||
},
|
||||
}
|
||||
|
||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
gold = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/roofs.lua",
|
||||
"data/generated/sprites.lua",
|
||||
"data/generated/scripts.lua",
|
||||
"data/generated/text.lua",
|
||||
-- The engine's label-keyed strings are separate from Gen 2 script text.
|
||||
-- Caches made before RomExtractorGen2:extractText must be rebuilt so
|
||||
-- src/core/RomText.lua does not silently fall back to built-in wording.
|
||||
"data/generated/rom_text.lua",
|
||||
"data/generated/pokemon.lua",
|
||||
"data/generated/tilesets.lua",
|
||||
"data/generated/audio.lua",
|
||||
"data/generated/marts.lua",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/fonts/frames.png",
|
||||
"assets/generated/title/pokemon_logo.png",
|
||||
"assets/generated/title/title_screen.png",
|
||||
"assets/generated/title/hooh.png",
|
||||
"assets/generated/title/hooh_5.png",
|
||||
"assets/generated/title/clouds.png",
|
||||
"assets/generated/title/copyright_splash.png",
|
||||
"data/generated/oak_speech.lua",
|
||||
"assets/generated/intro/oak.png",
|
||||
"assets/generated/intro/cal.png",
|
||||
"assets/generated/tilesets/johto.png",
|
||||
"assets/generated/tilesets/roofs/new_bark.png",
|
||||
"assets/generated/sprites/chris.png",
|
||||
"assets/generated/battle/front/chikorita.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/front/marill.png",
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
"assets/generated/pc/mail_item.png",
|
||||
-- engine/events/fishing_gfx.asm:23
|
||||
"assets/generated/emotes/fishing.png",
|
||||
},
|
||||
crystal = {
|
||||
"data/generated/constants.lua",
|
||||
"data/generated/maps.lua",
|
||||
"data/generated/roofs.lua",
|
||||
"data/generated/sprites.lua",
|
||||
"data/generated/scripts.lua",
|
||||
"data/generated/text.lua",
|
||||
"data/generated/rom_text.lua",
|
||||
"data/generated/pokemon.lua",
|
||||
"data/generated/encounters.lua",
|
||||
"data/generated/tilesets.lua",
|
||||
"data/generated/landmarks.lua",
|
||||
"data/generated/audio.lua",
|
||||
"data/generated/marts.lua",
|
||||
"data/generated/oak_speech.lua",
|
||||
"data/generated/title.lua",
|
||||
"data/generated/intro.lua",
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/fonts/frames.png",
|
||||
"assets/generated/title/crystal_logo.png",
|
||||
"assets/generated/title/crystal_wordmark.png",
|
||||
"assets/generated/title/crystal_suicune.png",
|
||||
"assets/generated/title/copyright_splash.png",
|
||||
"assets/generated/splash/ditto.png",
|
||||
"assets/generated/intro/chris.png",
|
||||
"assets/generated/intro/kris.png",
|
||||
"assets/generated/intro/suicune_run_sprites.png",
|
||||
"assets/generated/intro/unowns_tiles.png",
|
||||
"assets/generated/intro/oak.png",
|
||||
"assets/generated/tilesets/johto.png",
|
||||
"assets/generated/tilesets/roofs/new_bark.png",
|
||||
"assets/generated/sprites/chris.png",
|
||||
"assets/generated/sprites/kris.png",
|
||||
"assets/generated/battle/front/chikorita.png",
|
||||
"assets/generated/battle/front/wooper.png",
|
||||
"assets/generated/battle/front/pikachu.png",
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
"assets/generated/pc/mail_item.png",
|
||||
"assets/generated/trainer_card/card_f.png",
|
||||
"data/generated/mobile_gfx.lua",
|
||||
"assets/generated/battle/player_back_female.png",
|
||||
"assets/generated/battle/trainers/kris.png",
|
||||
"assets/generated/battle/trainers/chris.png",
|
||||
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
|
||||
"assets/generated/emotes/fishing.png",
|
||||
},
|
||||
}
|
||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
|
||||
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
|
||||
|
||||
function CacheContract.requiredFilesFor(version)
|
||||
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||
if override then return override, true end
|
||||
return CacheContract.REQUIRED_FILES, false
|
||||
end
|
||||
|
||||
function CacheContract.markerFor(version, sha1)
|
||||
return CacheContract.FORMAT .. (sha1 or GameVersion.info(version).sha1)
|
||||
end
|
||||
|
||||
function CacheContract.markerMatches(version, marker)
|
||||
for _, revision in ipairs(GameVersion.revisions(version)) do
|
||||
if marker == CacheContract.markerFor(version, revision.sha1) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Keep the process-global CacheFs prefix isolated even when a filesystem
|
||||
-- adapter raises while probing or publishing. The real CacheFs methods
|
||||
-- return errors, but this also makes the contract safe for platform adapters
|
||||
-- that surface I/O failures as Lua errors.
|
||||
local function withVersionPrefix(version, fs, action)
|
||||
local saved = fs.prefix
|
||||
fs.prefix = GameVersion.cachePrefix(version)
|
||||
local ok, first, second = pcall(action)
|
||||
fs.prefix = saved
|
||||
if not ok then return false, first end
|
||||
return true, first, second
|
||||
end
|
||||
|
||||
function CacheContract.allRequiredFilesExist(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
local ok, complete, missing = withVersionPrefix(version, fs, function()
|
||||
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||
local missingPath
|
||||
for _, path in ipairs(required) do
|
||||
if not fs.exists(path) then missingPath = path; break end
|
||||
end
|
||||
if not missingPath and not isOverride then
|
||||
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||
if not fs.exists(path) then missingPath = path; break end
|
||||
end
|
||||
end
|
||||
return missingPath == nil, missingPath
|
||||
end)
|
||||
if not ok then return false, complete end
|
||||
return complete, missing
|
||||
end
|
||||
|
||||
function CacheContract.readMarker(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
local ok, marker, readError = withVersionPrefix(version, fs, function()
|
||||
return fs.read(CacheContract.MARKER_PATH)
|
||||
end)
|
||||
if not ok then return nil, marker end
|
||||
-- LÖVE may return contents plus a byte count; only a nil contents result
|
||||
-- makes the auxiliary value an error. CacheFs' portable reader returns
|
||||
-- just the contents, so this remains adapter-neutral.
|
||||
if marker == nil then return nil, readError end
|
||||
return marker
|
||||
end
|
||||
|
||||
function CacheContract.isReady(version, fs)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
if CacheContract.sourceTreeHasData(version) then return true end
|
||||
local marker, readError = CacheContract.readMarker(version, fs)
|
||||
if readError or not CacheContract.markerMatches(version, marker) then return false end
|
||||
return CacheContract.allRequiredFilesExist(version, fs)
|
||||
end
|
||||
|
||||
function CacheContract.publish(version, fs, sha1)
|
||||
fs = fs or require("src.import.CacheFs")
|
||||
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
|
||||
if not complete then
|
||||
-- A caller may be retrying over a partially replaced cache. Do not
|
||||
-- leave its old marker advertising readiness after this failed check.
|
||||
local removed, removeError = withVersionPrefix(version, fs, function()
|
||||
if not fs.remove then
|
||||
error("cache filesystem cannot remove the completion marker")
|
||||
end
|
||||
return fs.remove(CacheContract.MARKER_PATH)
|
||||
end)
|
||||
if not removed then
|
||||
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||
.. "; could not remove completion marker: " .. tostring(removeError)
|
||||
end
|
||||
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||
end
|
||||
local changed, ok, err = withVersionPrefix(version, fs, function()
|
||||
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version, sha1))
|
||||
end)
|
||||
if not changed then return false, tostring(ok) end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
function CacheContract.sourceTreeHasData(version)
|
||||
if not (love and love.filesystem and love.filesystem.getInfo
|
||||
and love.filesystem.getRealDirectory and love.filesystem.getSource) then
|
||||
return false
|
||||
end
|
||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||
local source = love.filesystem.getSource()
|
||||
for _, path in ipairs(required) do
|
||||
local fullPath = prefix .. path
|
||||
if love.filesystem.getInfo(fullPath, "file") == nil
|
||||
or love.filesystem.getRealDirectory(fullPath) ~= source then
|
||||
return false
|
||||
end
|
||||
end
|
||||
if not isOverride then
|
||||
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||
local fullPath = prefix .. path
|
||||
if love.filesystem.getInfo(fullPath, "file") == nil
|
||||
or love.filesystem.getRealDirectory(fullPath) ~= source then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return CacheContract
|
||||
+22
-8
@@ -614,14 +614,18 @@ function CacheFs.mountVersion(version)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on one game's save,
|
||||
-- close it, and press Play on another: with the first version's subtree
|
||||
-- still prepended, the other's require("data.generated.*") and generated
|
||||
-- art would silently resolve to the first game'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.
|
||||
-- Undo mountVersion in LIFO order relative to mountVersion: generated-tree
|
||||
-- overlays first (assets, then data -- reverse of mountGeneratedTrees), then
|
||||
-- the version folder. PHYSFS resolves by stack order; peeling the wrong
|
||||
-- layer first can leave another version's generated files winning a name.
|
||||
--
|
||||
-- A process normally mounts exactly one version and then boots it, but the
|
||||
-- launcher can open the save editor on one game's save, close it, and press
|
||||
-- Play on another: with the first version's subtree still prepended, the
|
||||
-- other's require("data.generated.*") and generated art would silently
|
||||
-- resolve to the first game'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.
|
||||
function CacheFs.unmountVersion(version)
|
||||
@@ -633,6 +637,16 @@ function CacheFs.unmountVersion(version)
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
local done = false
|
||||
-- LIFO vs mountGeneratedTrees: assets/generated, then data/generated.
|
||||
if love.filesystem and love.filesystem.unmount then
|
||||
local generated = {
|
||||
prefix .. "assets/generated",
|
||||
prefix .. "data/generated",
|
||||
}
|
||||
for _, src in ipairs(generated) do
|
||||
done = love.filesystem.unmount(src) or done
|
||||
end
|
||||
end
|
||||
local fn = resolveUnmount()
|
||||
if fn and base then
|
||||
done = fn(base .. SEP .. sub) or done
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
-- Crystal's intro movie and title screen extraction (pokecrystal
|
||||
-- engine/movie/intro.asm:1678-1777, engine/movie/title.asm:364-374).
|
||||
-- Called from RomExtractorGen2's extractIntro/extractTitle stages when
|
||||
-- edition == "crystal"; reads through the passed-in extractor and never
|
||||
-- mutates it.
|
||||
|
||||
local ImageWriter = require("src.import.ImageWriter")
|
||||
|
||||
local CrystalMovie = {}
|
||||
|
||||
local TILE_BYTES = 16
|
||||
local SHEET_TILES = 16
|
||||
|
||||
-- vBGMap tilemaps and attrmaps are one full 32x32 map
|
||||
-- (engine/movie/intro.asm:1611-1628 decompresses $40 tiles = 1024 bytes).
|
||||
local MAP_BYTES = 1024
|
||||
|
||||
local function padTiles(raw, count)
|
||||
local length = count * TILE_BYTES
|
||||
while #raw > length do table.remove(raw) end
|
||||
while #raw < length do raw[#raw + 1] = 0 end
|
||||
return raw
|
||||
end
|
||||
|
||||
-- Lay `count` tiles of `source` (from tile `from`, 0-based) over `target`
|
||||
-- starting at tile `at` (0-based).
|
||||
local function overlayTiles(target, source, at, from, count)
|
||||
for offset = 1, count * TILE_BYTES do
|
||||
target[at * TILE_BYTES + offset] = source[from * TILE_BYTES + offset] or 0
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
local function blankTiles(count)
|
||||
local out = {}
|
||||
for index = 1, count * TILE_BYTES do out[index] = 0 end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Sheets are 16 tiles per row so tile id N resolves to (N % 16, N / 16),
|
||||
-- the same layout data/sprite_anims/oam.asm:120-136 assumes.
|
||||
local function writeSheet(self, raw, count, relative)
|
||||
padTiles(raw, count)
|
||||
local rows = math.ceil(count / SHEET_TILES)
|
||||
padTiles(raw, rows * SHEET_TILES)
|
||||
self:write2bpp(raw, SHEET_TILES * 8, rows * 8, relative, true)
|
||||
return "assets/generated/" .. relative
|
||||
end
|
||||
|
||||
local function readMap(self, label)
|
||||
local bytes = self:decompressLz3Symbol(label)
|
||||
while #bytes > MAP_BYTES do table.remove(bytes) end
|
||||
while #bytes < MAP_BYTES do bytes[#bytes + 1] = 0 end
|
||||
return bytes
|
||||
end
|
||||
|
||||
-- 16 palettes: 8 BG then 8 OBJ (engine/movie/intro.asm:123-130 copies
|
||||
-- `16 palettes` over wBGPals1, which wOBPals1 follows).
|
||||
local function readPalettes(self, label)
|
||||
local symbol = self:symbol(label)
|
||||
local flat = self:colors(symbol.bank, symbol.address, 64)
|
||||
local bg, obj = {}, {}
|
||||
for pal = 0, 7 do
|
||||
local a, b = {}, {}
|
||||
for slot = 1, 4 do
|
||||
a[slot] = flat[pal * 4 + slot]
|
||||
b[slot] = flat[(pal + 8) * 4 + slot]
|
||||
end
|
||||
bg[pal + 1], obj[pal + 1] = a, b
|
||||
end
|
||||
return { bg = bg, obj = obj }
|
||||
end
|
||||
|
||||
local function readColors(self, label, count)
|
||||
local symbol = self:symbol(label)
|
||||
return self:colors(symbol.bank, symbol.address, count)
|
||||
end
|
||||
|
||||
function CrystalMovie.extractIntro(self)
|
||||
self:beginStage("Intro movie")
|
||||
local out = {
|
||||
generation = 2,
|
||||
layout = "crystal",
|
||||
source = "ROM:CrystalIntro (engine/movie/intro.asm)",
|
||||
}
|
||||
|
||||
local unowns = padTiles(self:decompressLz3Symbol("IntroUnownsGFX"), 128)
|
||||
local grassSym = self:symbol("IntroGrass4GFX")
|
||||
local grass4 = self.rom:bytes(grassSym.bank, grassSym.address, TILE_BYTES)
|
||||
|
||||
local unownsPath = writeSheet(self, unowns, 128, "intro/unowns_tiles.png")
|
||||
local pulsePath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroPulseGFX"), 16, "intro/pulse_sprites.png")
|
||||
local backgroundPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroBackgroundGFX"), 128,
|
||||
"intro/background_tiles.png")
|
||||
local runPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroSuicuneRunGFX"), 192,
|
||||
"intro/suicune_run_sprites.png")
|
||||
local pichuPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroPichuWooperGFX"), 128,
|
||||
"intro/pichu_wooper_sprites.png")
|
||||
self:tick("Intro movie", 1, 4)
|
||||
|
||||
-- IntroScene15: jump BG at vTiles2 plus IntroGrass4GFX at vTiles1 tile 0,
|
||||
-- i.e. BG id $80 (engine/movie/intro.asm:744-753).
|
||||
local jump = blankTiles(256)
|
||||
overlayTiles(jump, self:decompressLz3Symbol("IntroSuicuneJumpGFX"), 0, 0, 128)
|
||||
overlayTiles(jump, grass4, 0x80, 0, 1)
|
||||
local jumpPath = writeSheet(self, jump, 256, "intro/suicune_jump_tiles.png")
|
||||
|
||||
-- The same grass tile is the whole of the SUICUNE_AWAY object's sheet
|
||||
-- (data/sprite_anims/oam.asm:136 base $80; engine/movie/intro.asm:750-753).
|
||||
local unownBack = blankTiles(144)
|
||||
overlayTiles(unownBack, self:decompressLz3Symbol("IntroUnownBackGFX"), 0, 0, 48)
|
||||
overlayTiles(unownBack, grass4, 0x80, 0, 1)
|
||||
local unownBackPath = writeSheet(self, unownBack, 144,
|
||||
"intro/unown_back_sprites.png")
|
||||
|
||||
-- IntroScene17 loads 255 tiles from vTiles1, so BG id $80 is close tile 0
|
||||
-- and ids wrap through $00-$7e (engine/movie/intro.asm:825-828).
|
||||
local closeRaw = padTiles(self:decompressLz3Symbol("IntroSuicuneCloseGFX"), 255)
|
||||
local close = blankTiles(256)
|
||||
for id = 0, 255 do
|
||||
local from = (id + 128) % 256
|
||||
if from < 255 then overlayTiles(close, closeRaw, id, from, 1) end
|
||||
end
|
||||
local closePath = writeSheet(self, close, 256, "intro/suicune_close_tiles.png")
|
||||
|
||||
-- IntroScene19: suicune_back at vTiles2, the Unown ring at vTiles1, and
|
||||
-- grass over vTiles1 tile $7f = BG id $ff (engine/movie/intro.asm:890-901).
|
||||
local back = blankTiles(256)
|
||||
overlayTiles(back, self:decompressLz3Symbol("IntroSuicuneBackGFX"), 0, 0, 128)
|
||||
overlayTiles(back, unowns, 128, 0, 128)
|
||||
overlayTiles(back, grass4, 0xff, 0, 1)
|
||||
local backPath = writeSheet(self, back, 256, "intro/suicune_back_tiles.png")
|
||||
|
||||
local crystalUnownsPath = writeSheet(self,
|
||||
self:decompressLz3Symbol("IntroCrystalUnownsGFX"), 32,
|
||||
"intro/crystal_unowns_tiles.png")
|
||||
|
||||
-- Intro_RustleGrass swaps 4 tiles at vTiles2 tile $09 through grass1/2/3
|
||||
-- (engine/movie/intro.asm:1521-1547); one 16-tile row, frame f at f*4.
|
||||
local grassStrip = blankTiles(16)
|
||||
for frame, label in ipairs({ "IntroGrass1GFX", "IntroGrass2GFX",
|
||||
"IntroGrass3GFX" }) do
|
||||
local sym = self:symbol(label)
|
||||
overlayTiles(grassStrip,
|
||||
self.rom:bytes(sym.bank, sym.address, 4 * TILE_BYTES), (frame - 1) * 4,
|
||||
0, 4)
|
||||
end
|
||||
out.grassFrames = writeSheet(self, grassStrip, 16, "intro/grass_anim.png")
|
||||
self:tick("Intro movie", 2, 4)
|
||||
|
||||
local unownPals = readPalettes(self, "IntroUnownsPalette")
|
||||
local backgroundPals = readPalettes(self, "IntroBackgroundPalette")
|
||||
local suicunePals = readPalettes(self, "IntroSuicunePalette")
|
||||
local closePals = readPalettes(self, "IntroSuicuneClosePalette")
|
||||
local crystalUnownsPals = readPalettes(self, "IntroCrystalUnownsPalette")
|
||||
|
||||
local function act(tiles, sprites, tilemapLabel, attrmapLabel, palettes)
|
||||
return {
|
||||
tiles = tiles,
|
||||
sprites = sprites,
|
||||
tilemap = readMap(self, tilemapLabel),
|
||||
attrmap = readMap(self, attrmapLabel),
|
||||
palettes = palettes,
|
||||
}
|
||||
end
|
||||
|
||||
out.acts = {
|
||||
unownA = act(unownsPath, pulsePath,
|
||||
"IntroUnownATilemap", "IntroUnownAAttrmap", unownPals),
|
||||
unownHI = act(unownsPath, pulsePath,
|
||||
"IntroUnownHITilemap", "IntroUnownHIAttrmap", unownPals),
|
||||
unowns = act(unownsPath, pulsePath,
|
||||
"IntroUnownsTilemap", "IntroUnownsAttrmap", unownPals),
|
||||
background = act(backgroundPath, runPath,
|
||||
"IntroBackgroundTilemap", "IntroBackgroundAttrmap", backgroundPals),
|
||||
suicuneJump = act(jumpPath, unownBackPath,
|
||||
"IntroSuicuneJumpTilemap", "IntroSuicuneJumpAttrmap", suicunePals),
|
||||
suicuneClose = act(closePath, nil,
|
||||
"IntroSuicuneCloseTilemap", "IntroSuicuneCloseAttrmap", closePals),
|
||||
suicuneBack = act(backPath, unownBackPath,
|
||||
"IntroSuicuneBackTilemap", "IntroSuicuneBackAttrmap", suicunePals),
|
||||
crystalUnowns = act(crystalUnownsPath, nil,
|
||||
"IntroCrystalUnownsTilemap", "IntroCrystalUnownsAttrmap",
|
||||
crystalUnownsPals),
|
||||
}
|
||||
-- IntroScene7/10 load pichu_wooper into VRAM bank 1
|
||||
-- (engine/movie/intro.asm:340-348); OAM_BANK1 picks this sheet.
|
||||
out.acts.background.sprites1 = pichuPath
|
||||
self:tick("Intro movie", 3, 4)
|
||||
|
||||
out.fades = {
|
||||
toWhite = {},
|
||||
unownAppear = readColors(self, "Intro_Scene20_AppearUnown.pal1", 4),
|
||||
unownAppear2 = readColors(self, "Intro_Scene20_AppearUnown.pal2", 4),
|
||||
wordFast = readColors(self, "Intro_FadeUnownWordPals.FastFadePalettes", 16),
|
||||
wordSlow = readColors(self, "Intro_FadeUnownWordPals.SlowFadePalettes", 16),
|
||||
}
|
||||
-- Intro_Scene24_ApplyPaletteFade steps 8 rows of one palette each
|
||||
-- (engine/movie/intro.asm:1155-1189).
|
||||
local fade = readColors(self, "Intro_Scene24_ApplyPaletteFade.FadePals", 32)
|
||||
for row = 0, 7 do
|
||||
local pal = {}
|
||||
for slot = 1, 4 do pal[slot] = fade[row * 4 + slot] end
|
||||
out.fades.toWhite[row + 1] = pal
|
||||
end
|
||||
|
||||
self:write("intro", out)
|
||||
self:tick("Intro movie", 4, 4)
|
||||
return out
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
local function shadeOf(r)
|
||||
if r > 0.9 then return 0 end
|
||||
if r > 0.5 then return 1 end
|
||||
if r > 0.2 then return 2 end
|
||||
return 3
|
||||
end
|
||||
|
||||
local function tilesFrom2bpp(raw)
|
||||
local tiles = {}
|
||||
for offset = 1, #raw - (#raw % TILE_BYTES), TILE_BYTES do
|
||||
local one = {}
|
||||
for index = offset, offset + TILE_BYTES - 1 do one[#one + 1] = raw[index] end
|
||||
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, true)
|
||||
end
|
||||
return tiles
|
||||
end
|
||||
|
||||
local function blitTile(target, tile, tx, ty)
|
||||
if not tile then return end
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = tile:getPixel(x, y)
|
||||
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function colorize(image, palFor)
|
||||
local w, h = image:getDimensions()
|
||||
local out = ImageWriter.blank(w, h, 0, 0, 0, 0)
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, _, _, a = image:getPixel(x, y)
|
||||
if a ~= 0 then
|
||||
local pal = palFor(x, y)
|
||||
local c = pal[shadeOf(r) + 1] or pal[4] or { 0, 0, 0 }
|
||||
out:setPixel(x, y, c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- engine/movie/splash.asm:20-30 sets SCGB_GAMEFREAK_LOGO before Copyright, so
|
||||
-- the card runs on gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG.
|
||||
local COPYRIGHT_TILES = 29
|
||||
local COPYRIGHT_BG = {
|
||||
{ 0, 0, 0 }, { 66, 90, 90 }, { 173, 173, 173 }, { 255, 255, 255 },
|
||||
}
|
||||
|
||||
-- data/copyright.asm at hlcoord 2, 7 (engine/menus/intro_menu.asm:1315-1326).
|
||||
local COPYRIGHT_LINES = {
|
||||
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
|
||||
0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c },
|
||||
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
|
||||
0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x7a, 0x7b, 0x7c },
|
||||
{ 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
|
||||
0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c },
|
||||
}
|
||||
|
||||
local function extractCopyright(self)
|
||||
local symbol = self:symbol("CopyrightGFX")
|
||||
local raw = self.rom:bytes(symbol.bank, symbol.address,
|
||||
COPYRIGHT_TILES * TILE_BYTES)
|
||||
self:write2bpp(raw, COPYRIGHT_TILES * 8, 8, "title/copyright.png")
|
||||
|
||||
local painted = {}
|
||||
for index, tile in ipairs(tilesFrom2bpp(raw)) do
|
||||
painted[index] = colorize(tile, function() return COPYRIGHT_BG end)
|
||||
end
|
||||
local backdrop = COPYRIGHT_BG[1]
|
||||
local splash = ImageWriter.blank(160, 144,
|
||||
backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
for row, line in ipairs(COPYRIGHT_LINES) do
|
||||
for column, id in ipairs(line) do
|
||||
blitTile(splash, painted[id - 0x60 + 1], (1 + column) * 8, (6 + row) * 8)
|
||||
end
|
||||
end
|
||||
self:save(splash, "title/copyright_splash.png")
|
||||
end
|
||||
|
||||
function CrystalMovie.extractTitle(self)
|
||||
self:beginStage("Title screen")
|
||||
|
||||
-- TitleLogoGFX decompresses to vTiles1, so BG id $80 is logo tile 0 and
|
||||
-- ids $00-$1b wrap on past it (engine/movie/title.asm:91-94).
|
||||
local logoTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleLogoGFX"))
|
||||
-- TitleSuicuneGFX fills vTiles4-vTiles5: bank-1 ids $80-$ff then $00-$7f
|
||||
-- (engine/movie/title.asm:24-27).
|
||||
local suicuneTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleSuicuneGFX"))
|
||||
local gemTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleCrystalGFX"))
|
||||
local pals = readPalettes(self, "TitleScreenPalettes")
|
||||
self:tick("Title screen", 1, 5)
|
||||
|
||||
-- Attribute regions from _TitleScreen's ByteFills
|
||||
-- (engine/movie/title.asm:39-85): logo gradient rows, the version strip,
|
||||
-- pal 7 on the window copyright line, pal 0 everywhere else.
|
||||
local function bgPalAt(col, row)
|
||||
if row == 9 and col >= 5 and col <= 15 then return 1 end
|
||||
if row >= 3 and row <= 4 then return 2 end
|
||||
if row == 5 then return 3 end
|
||||
if row == 6 then return 4 end
|
||||
if row == 7 then return 5 end
|
||||
if row >= 8 and row <= 9 then return 6 end
|
||||
if row == 17 then return 7 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local shade = ImageWriter.blank(160, 144, 0, 0, 0, 0)
|
||||
-- DrawTitleGraphic lays 7 rows of 20 running tiles from d=$80
|
||||
-- (engine/movie/title.asm:107-112,275-302).
|
||||
for row = 0, 6 do
|
||||
for col = 0, 19 do
|
||||
blitTile(shade, logoTiles[row * 20 + col + 1], col * 8, (row + 3) * 8)
|
||||
end
|
||||
end
|
||||
-- Copyright line: 13 tiles from d=$c on the window's row 0, shown at
|
||||
-- hWY = $88 (engine/movie/title.asm:114-119; engine/menus/intro_menu.asm:1121-1122).
|
||||
for index = 0, 12 do
|
||||
blitTile(shade, logoTiles[128 + 12 + index + 1], (3 + index) * 8, 17 * 8)
|
||||
end
|
||||
|
||||
local colored = colorize(shade, function(x, y)
|
||||
return pals.bg[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1]
|
||||
end)
|
||||
self:save(colored, "title/crystal_screen.png")
|
||||
self:save(shade, "title/crystal_screen_gray.png")
|
||||
|
||||
local logo = ImageWriter.blank(160, 56, 0, 0, 0, 0)
|
||||
ImageWriter.blit(logo, colored, 0, 0, 0, 24, 160, 56)
|
||||
self:save(logo, "title/crystal_logo.png")
|
||||
local wordmark = ImageWriter.blank(88, 8, 0, 0, 0, 0)
|
||||
ImageWriter.blit(wordmark, colored, 0, 0, 40, 72, 88, 8)
|
||||
self:save(wordmark, "title/crystal_wordmark.png")
|
||||
self:tick("Title screen", 2, 5)
|
||||
|
||||
-- LoadSuicuneFrame: 6 rows of 8 tiles at hlcoord 6,12, row stride 16;
|
||||
-- frame bases $80/$88/$00/$08 (engine/movie/title.asm:245-273).
|
||||
local suicunePaths, suicuneGrayPaths = {}, {}
|
||||
for index, base in ipairs({ 0x00, 0x08, 0x80, 0x88 }) do
|
||||
local frame = ImageWriter.blank(64, 48, 0, 0, 0, 0)
|
||||
for row = 0, 5 do
|
||||
for col = 0, 7 do
|
||||
blitTile(frame, suicuneTiles[base + row * 16 + col + 1],
|
||||
col * 8, row * 8)
|
||||
end
|
||||
end
|
||||
local tinted = colorize(frame, function() return pals.bg[1] end)
|
||||
local rel = ("title/crystal_suicune_%d.png"):format(index)
|
||||
self:save(tinted, rel)
|
||||
suicunePaths[index] = "assets/generated/" .. rel
|
||||
local grayRel = ("title/crystal_suicune_%d_gray.png"):format(index)
|
||||
self:save(frame, grayRel)
|
||||
suicuneGrayPaths[index] = "assets/generated/" .. grayRel
|
||||
if index == 1 then self:save(tinted, "title/crystal_suicune.png") end
|
||||
end
|
||||
self:tick("Title screen", 3, 5)
|
||||
|
||||
-- InitializeBackground: five 48x16 strips of six 8x16 OBJs, consecutive
|
||||
-- tile pairs, OBJ pal 0, OAM_PRIO (engine/movie/title.asm:304-338).
|
||||
local gem = ImageWriter.blank(48, 80, 0, 0, 0, 0)
|
||||
for strip = 0, 4 do
|
||||
for slot = 0, 5 do
|
||||
local tile = strip * 12 + slot * 2
|
||||
blitTile(gem, gemTiles[tile + 1], slot * 8, strip * 16)
|
||||
blitTile(gem, gemTiles[tile + 2], slot * 8, strip * 16 + 8)
|
||||
end
|
||||
end
|
||||
local gemTinted = colorize(gem, function() return pals.obj[1] end)
|
||||
self:save(gemTinted, "title/crystal_gem.png")
|
||||
self:save(gem, "title/crystal_gem_gray.png")
|
||||
extractCopyright(self)
|
||||
self:tick("Title screen", 4, 5)
|
||||
|
||||
local function unit(color) return { color[1] / 255, color[2] / 255, color[3] / 255 } end
|
||||
|
||||
local data = {
|
||||
generation = 2,
|
||||
layout = "crystal_title",
|
||||
source = "ROM:TitleSuicuneGFX + TitleLogoGFX + TitleCrystalGFX"
|
||||
.. " + TitleScreenPalettes + CopyrightGFX",
|
||||
screen = "assets/generated/title/crystal_screen.png",
|
||||
screenGray = "assets/generated/title/crystal_screen_gray.png",
|
||||
image = "assets/generated/title/crystal_logo.png",
|
||||
wordmark = "assets/generated/title/crystal_wordmark.png",
|
||||
suicune = "assets/generated/title/crystal_suicune.png",
|
||||
suicuneFrames = suicunePaths,
|
||||
suicuneFramesGray = suicuneGrayPaths,
|
||||
-- hlcoord 6, 12 (engine/movie/title.asm:252) in screen pixels.
|
||||
suicuneX = 48,
|
||||
suicuneY = 96,
|
||||
-- SuicuneFrameIterator advances every 8 frames (engine/movie/title.asm:217-243).
|
||||
suicuneEvery = 8,
|
||||
gem = "assets/generated/title/crystal_gem.png",
|
||||
gemGray = "assets/generated/title/crystal_gem_gray.png",
|
||||
copyright = "assets/generated/title/copyright.png",
|
||||
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
||||
copyrightBackdrop = unit(COPYRIGHT_BG[1]),
|
||||
copyrightInk = unit(COPYRIGHT_BG[4]),
|
||||
-- Strip 0 starts at OAM (64, -$22) and stops at OAM y 22, i.e. screen
|
||||
-- (56, 6) (engine/movie/title.asm:306-311,340-362).
|
||||
gemX = 56,
|
||||
gemY = 6,
|
||||
gemFromY = -50,
|
||||
gemStep = 2,
|
||||
-- TitleScreenEntrance: hSCX from +112 to 0 by 4 with alternating-line
|
||||
-- signage over the logo's 80 lines; the copyright window sits at
|
||||
-- hWY = $88 only after it lands (engine/menus/intro_menu.asm:1078-1111).
|
||||
entrance = { scx = 112, step = 4, lines = 80, hideBelow = 136 },
|
||||
entranceSfx = "Sfx_TitleScreenEntrance",
|
||||
-- TitleScreenTimer (engine/menus/intro_menu.asm:1125-1136).
|
||||
timeoutFrames = 73 * 60 + 36,
|
||||
sky = unit(pals.bg[1][1]),
|
||||
below = unit(pals.bg[1][1]),
|
||||
palettes = { bg = pals.bg, obj = pals.obj },
|
||||
}
|
||||
self:write("title", data)
|
||||
self:tick("Title screen", 5, 5)
|
||||
return data
|
||||
end
|
||||
|
||||
return CrystalMovie
|
||||
@@ -13,7 +13,7 @@ require("love.math")
|
||||
require("love.system")
|
||||
require("love.timer")
|
||||
|
||||
local version, prefix, romData, progressName, resultName = ...
|
||||
local version, prefix, romData, progressName, resultName, romSha1 = ...
|
||||
|
||||
local progressChannel = love.thread.getChannel(progressName)
|
||||
local resultChannel = love.thread.getChannel(resultName)
|
||||
@@ -44,7 +44,7 @@ local ok, err = pcall(function()
|
||||
current = current, stageTotal = stageTotal,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end, romSha1)
|
||||
extractor:run()
|
||||
end)
|
||||
|
||||
|
||||
@@ -266,18 +266,6 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- issue #136: GBC FX soft-bricks the mobile present shader; same gate as
|
||||
-- the in-game row.
|
||||
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
||||
if okFx and GBCFX.isSupported() then
|
||||
add(Strings("GBC FX"),
|
||||
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
|
||||
function(dir)
|
||||
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||
if okZ then
|
||||
add(Strings("ZOOM"),
|
||||
@@ -678,17 +666,6 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Same #136 gate as the Gen 1 row and the in-game one.
|
||||
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
||||
if okFx and GBCFX.isSupported() then
|
||||
add(Strings("GBC FX"),
|
||||
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
|
||||
function(dir)
|
||||
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
|
||||
if okVm then
|
||||
add(Strings("VIDEO MODE"),
|
||||
@@ -709,6 +686,10 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours.
|
||||
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
|
||||
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
|
||||
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
return rows
|
||||
|
||||
+745
-97
File diff suppressed because it is too large
Load Diff
+1094
-120
File diff suppressed because it is too large
Load Diff
+1265
-288
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,8 @@ function RomManifest.decode(version)
|
||||
if not manifest then
|
||||
error("ROM import metadata is invalid: " .. tostring(decodeError))
|
||||
end
|
||||
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
|
||||
assert(GameVersion.acceptsSha1(version, manifest.romSha1),
|
||||
"ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
|
||||
+54
-15
@@ -1,28 +1,63 @@
|
||||
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
|
||||
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
|
||||
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
|
||||
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
|
||||
-- aloud or handwritten never has to be checked twice).
|
||||
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's
|
||||
-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32
|
||||
-- style alphabet pokeserver room/tournament codes are drawn from
|
||||
-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or
|
||||
-- handwritten never has to be checked twice).
|
||||
--
|
||||
-- The Gen 1 naming grid cannot stand in for this: it has no digits at all
|
||||
-- (data/text/alphabets.asm is letters and punctuation), so a room code or
|
||||
-- an address typed there would be unenterable. That is what this exists
|
||||
-- for.
|
||||
--
|
||||
-- new() takes an optional {length=, charset=} so the same interaction can
|
||||
-- carry something other than a room code -- a dotted IP over "0123456789.",
|
||||
-- say. Both default to the room-code shape, so existing callers are
|
||||
-- unaffected and CodeEntry.LENGTH / CodeEntry.CHARSET still describe them.
|
||||
|
||||
local CodeEntry = {}
|
||||
|
||||
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
CodeEntry.LENGTH = 6
|
||||
|
||||
function CodeEntry.new()
|
||||
-- state carries its own length/charset so a caller holding two widgets of
|
||||
-- different shapes cannot have one read the other's alphabet
|
||||
function CodeEntry.new(opts)
|
||||
local charset = (opts and opts.charset) or CodeEntry.CHARSET
|
||||
local length = (opts and opts.length) or CodeEntry.LENGTH
|
||||
local chars = {}
|
||||
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
|
||||
return { chars = chars, pos = 1 }
|
||||
for i = 1, length do chars[i] = 1 end -- index into charset, 1-based
|
||||
return { chars = chars, pos = 1, charset = charset, length = length }
|
||||
end
|
||||
|
||||
local N = #CodeEntry.CHARSET
|
||||
-- Seed the slots from an existing string: prefilling the LAN address means
|
||||
-- the player scrubs the last octet instead of all twelve digits. Anything
|
||||
-- not in the charset lands on slot 1's character.
|
||||
-- Slots past the end of the seed -- and any character the charset does not
|
||||
-- carry -- land on the charset's blank where it has one, so seeding a
|
||||
-- 15-slot address widget with "192.168.1.40" reads back as that address and
|
||||
-- not as "192.168.1.40000". A charset with no blank (the room code's) has
|
||||
-- nowhere to put one, so those fall back to the first character as before.
|
||||
function CodeEntry.fromText(text, opts)
|
||||
local state = CodeEntry.new(opts)
|
||||
local blank = state.charset:find(" ", 1, true) or 1
|
||||
for i = 1, state.length do
|
||||
local ch = tostring(text or ""):sub(i, i)
|
||||
state.chars[i] = (ch ~= "" and state.charset:find(ch, 1, true)) or blank
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
local function charsetOf(state) return state.charset or CodeEntry.CHARSET end
|
||||
local function lengthOf(state) return state.length or CodeEntry.LENGTH end
|
||||
|
||||
function CodeEntry.up(state)
|
||||
state.chars[state.pos] = state.chars[state.pos] % N + 1
|
||||
local n = #charsetOf(state)
|
||||
state.chars[state.pos] = state.chars[state.pos] % n + 1
|
||||
end
|
||||
|
||||
function CodeEntry.down(state)
|
||||
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
|
||||
local n = #charsetOf(state)
|
||||
state.chars[state.pos] = (state.chars[state.pos] - 2) % n + 1
|
||||
end
|
||||
|
||||
function CodeEntry.left(state)
|
||||
@@ -30,14 +65,18 @@ function CodeEntry.left(state)
|
||||
end
|
||||
|
||||
function CodeEntry.right(state)
|
||||
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
|
||||
state.pos = math.min(lengthOf(state), state.pos + 1)
|
||||
end
|
||||
|
||||
-- the character in slot i, which is what a draw loop wants
|
||||
function CodeEntry.charAt(state, i)
|
||||
local charset = charsetOf(state)
|
||||
return charset:sub(state.chars[i], state.chars[i])
|
||||
end
|
||||
|
||||
function CodeEntry.text(state)
|
||||
local out = {}
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
|
||||
end
|
||||
for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
|
||||
+53
-1
@@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code)
|
||||
return self
|
||||
end
|
||||
|
||||
-- Adopt a transport that is ALREADY paired and skip the connect UI: an
|
||||
-- overworld multiplayer session handing one pair of players off to a battle
|
||||
-- or a trade. The caller has settled which mode and which side hosts, so
|
||||
-- all that is left is the hello exchange every link session runs before it
|
||||
-- commits -- the fingerprint/mod compatibility check still gets its say,
|
||||
-- exactly as it would have on the LAN or ONLINE path.
|
||||
--
|
||||
-- `transport` is anything Session accepts (update/poll/send/close plus the
|
||||
-- .paired/.closed/.error fields), which is what lets a mod route a battle
|
||||
-- over a channel of its own. Ownership transfers with it: exitWith closes
|
||||
-- the session, so the caller's transport is done once this state unwinds.
|
||||
--
|
||||
-- opts.forceLevel the level rule, normally chosen on the battleOptions
|
||||
-- screen; an adopted session has no menu to pick it on
|
||||
function LinkState.newFromSession(game, transport, mode, isHost, opts)
|
||||
local self = LinkState.new(game)
|
||||
self.net = Session.new(transport, { role = isHost and "host" or "guest",
|
||||
kind = "link" })
|
||||
self.adopted = true
|
||||
self.adoptedMode, self.adoptedHost = mode, isHost and true or false
|
||||
self.forceLevel = opts and opts.forceLevel or nil
|
||||
self.stage = "adopted"
|
||||
self:sendHello(isHost and mode or nil)
|
||||
return self
|
||||
end
|
||||
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
@@ -203,8 +229,9 @@ function LinkState:decideCompat(mode, isHost)
|
||||
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
||||
})
|
||||
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
||||
if isHost and mode == "battle" then
|
||||
if isHost and mode == "battle" and not self.adopted then
|
||||
-- host picks the level rule now, before the parties are exchanged
|
||||
-- (an adopted session had it passed in; see newFromSession)
|
||||
self.stage = "battleOptions"
|
||||
else
|
||||
self:startMode(mode, isHost)
|
||||
@@ -243,6 +270,16 @@ function LinkState:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- handed over from an already-paired session (LinkState.newFromSession):
|
||||
-- both hellos are in flight, and the first one to land settles compat and
|
||||
-- drops us straight into the agreed mode
|
||||
if self.stage == "adopted" then
|
||||
if self:pollHello() then
|
||||
self:decideCompat(self.adoptedMode, self.adoptedHost)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if self.stage == "menu" then
|
||||
if input:wasPressed("down") then
|
||||
self.index = self.index % 3 + 1
|
||||
@@ -497,11 +534,26 @@ function LinkState:update(dt)
|
||||
return
|
||||
end
|
||||
self.game.stack:push(battle)
|
||||
self.battle = battle
|
||||
self.stage = "battleRunning"
|
||||
end
|
||||
|
||||
elseif self.stage == "battleRunning" then
|
||||
if self.game.stack:top() == self then
|
||||
-- the lockstep copies carry the damage the real party never takes
|
||||
-- (cable rules), so a mode that wants it -- a tournament ladder, a
|
||||
-- battle royale -- reads it from here before the state unwinds
|
||||
local battle = self.battle
|
||||
if battle and Runtime.wants("link.battle_ended") then
|
||||
Runtime.emit("link.battle_ended", {
|
||||
result = battle.result or "ended",
|
||||
myParty = battle.playerParty,
|
||||
theirParty = battle.enemyParty,
|
||||
peerName = self.peerName,
|
||||
role = self.isHost and "host" or "guest",
|
||||
})
|
||||
end
|
||||
self.battle = nil
|
||||
self:exitWith(nil) -- battle finished
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1582,7 +1582,7 @@ local function buildPartyMenu()
|
||||
elseif opts.battle and opts.onSwitch then
|
||||
prompt, battleSubmenu = "choose", true
|
||||
elseif opts.pickOnly then
|
||||
prompt = "useItem"
|
||||
prompt = opts.itemUse and "useItem" or "choose"
|
||||
elseif opts.onSwitch then
|
||||
-- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself,
|
||||
-- so the field submenu must not swallow the press
|
||||
@@ -1647,7 +1647,7 @@ COVERAGE["src.ui.PartyMenu"] = {
|
||||
warned = "keepOpen tmhm",
|
||||
absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo "
|
||||
.. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch "
|
||||
.. "pickOnly forceSwitch",
|
||||
.. "pickOnly itemUse forceSwitch",
|
||||
notes = {
|
||||
new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts."
|
||||
.. "battle carries only its BOOLEAN sense and self.battle is left nil "
|
||||
@@ -1663,7 +1663,7 @@ COVERAGE["src.ui.PartyMenu"] = {
|
||||
onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field "
|
||||
.. "Gen 2 never reads; pass it to .new instead",
|
||||
bottomMessage = "returns Gold's strings with <PK>/<MN> charmap glyphs, so "
|
||||
.. "a compare against \"Use on which one?\" will not match",
|
||||
.. "a compare against \"Use item on which\\nPOKéMON?\" will not match",
|
||||
["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold "
|
||||
.. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a "
|
||||
.. "BattleState",
|
||||
@@ -1838,7 +1838,7 @@ local function buildBattleState()
|
||||
"syncSides", "playerHasPP", "lockedAction", "computeMusicKind",
|
||||
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
|
||||
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "uiSize",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
|
||||
"trainerSprite", "invalidate",
|
||||
"imageBattleScale", "resolveBattleScale", "backPlacement",
|
||||
@@ -1922,11 +1922,11 @@ COVERAGE["src.battle.BattleState"] = {
|
||||
backed = "update draw __index isOpaque openParty swapMoves "
|
||||
.. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems "
|
||||
.. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT "
|
||||
.. "tryRun wantsFillScale",
|
||||
.. "tryRun wantsFillScale bgMode",
|
||||
warned = "tryRun askNicknameUI",
|
||||
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
|
||||
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
|
||||
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
|
||||
.. "isWideBattleLayout wideLayout uiSize letterboxWhite "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
|
||||
.. "trainerTrueColor trainerSprite invalidate "
|
||||
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
|
||||
|
||||
@@ -471,6 +471,20 @@ discover = function()
|
||||
return out
|
||||
end
|
||||
|
||||
-- installedVersions() -> id -> installed version. MOD INDEX only needs to
|
||||
-- know whether a listing is already present; it does not need enablement,
|
||||
-- dependency/conflict status, required-import validation, or migration. Keep
|
||||
-- that cheap read separate from list(), whose richer work belongs to MODS.
|
||||
function LauncherMods.installedVersions()
|
||||
local out = {}
|
||||
local ok, manifests = pcall(discover)
|
||||
if not ok then return out end
|
||||
for _, manifest in ipairs(manifests or {}) do
|
||||
out[manifest.id] = manifest.version or true
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- list([version]) -> the mods-panel rows for the current install. Reads the
|
||||
-- same enable-state the loader persists, so a toggle here is what the game
|
||||
-- sees on its next boot; `version` narrows that to one game's answers.
|
||||
|
||||
+178
-5
@@ -1,6 +1,8 @@
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
@@ -268,9 +270,10 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
cart = opts and opts.cart or nil,
|
||||
dev = dev == true,
|
||||
safeMode = false,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
-- active version is set once in main.lua's bootGame before anything
|
||||
@@ -367,7 +370,11 @@ function Loader:_saveState()
|
||||
local scope = self:_enableScope()
|
||||
local version = self:_targetVersion()
|
||||
for id in pairs(self.mods) do
|
||||
SaveData.setModEnabled(options, id, not self.disabled[id], scope)
|
||||
-- a switch the cart owns is answered in the cart's scope by setEnabled,
|
||||
-- so it never rewrites what the base game runs
|
||||
if not self.cartSwitches[id] then
|
||||
SaveData.setModEnabled(options, id, not self.disabled[id], scope)
|
||||
end
|
||||
-- only the games this boot can answer for: another version's overrides
|
||||
-- are not this run's to rewrite. With no version (an injected-generation
|
||||
-- harness) the override stays in memory for this boot only.
|
||||
@@ -434,6 +441,9 @@ function Loader:setEnabled(id, enabled)
|
||||
if not self.mods[id] then return false end
|
||||
self.disabled[id] = not enabled
|
||||
self.mods[id].enabled = enabled
|
||||
if self.cartSwitches[id] and self.cartReport then
|
||||
SaveData.setCartModEnabled(self.cartReport.id, id, enabled, self.fs)
|
||||
end
|
||||
self:_saveState()
|
||||
return true
|
||||
end
|
||||
@@ -488,6 +498,161 @@ function Loader:_discover()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- custom carts
|
||||
|
||||
local function installedVersions(installed)
|
||||
local out = {}
|
||||
for key, entry in pairs(installed or {}) do
|
||||
if type(entry) == "table" then
|
||||
local manifest = type(entry.manifest) == "table" and entry.manifest or entry
|
||||
local id = manifest.id or (type(key) == "string" and key or nil)
|
||||
if type(id) == "string" then out[id] = manifest.version end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function pinnedVersion(pin)
|
||||
local version = pin.version
|
||||
if type(version) ~= "string" or version == "" then return nil end
|
||||
if pin.source == "local" and version == CartStore.UNPINNED_VERSION then return nil end
|
||||
return version
|
||||
end
|
||||
|
||||
local function sameVersion(want, have)
|
||||
local order = Semver.compare(want, have)
|
||||
if order ~= nil then return order == 0 end
|
||||
return want == have
|
||||
end
|
||||
|
||||
local function cartComplaints(report)
|
||||
local parts = {}
|
||||
for _, row in ipairs(report.missing) do
|
||||
parts[#parts + 1] = ("%s %s is not installed")
|
||||
:format(row.id, row.version or "(any version)")
|
||||
end
|
||||
for _, row in ipairs(report.mismatched) do
|
||||
parts[#parts + 1] = ("%s is pinned at %s but %s is installed")
|
||||
:format(row.id, row.version, row.installed)
|
||||
end
|
||||
return parts
|
||||
end
|
||||
|
||||
-- Whether the player's own enable flag decides a pinned mod. "sealed+" hands
|
||||
-- every pin over; any other seal hands over only the pins the cart ships off.
|
||||
function Loader.pinTogglable(report, pin)
|
||||
if type(report) ~= "table" or type(pin) ~= "table" then return false end
|
||||
if report.seal == "sealed+" then return true end
|
||||
if pin.enabled ~= false then return false end
|
||||
return not (report.seal == "sealed" and not report.broken)
|
||||
end
|
||||
|
||||
function Loader.planCart(cart, installed, broken)
|
||||
local report = { seal = "sealed", sealed = true, broken = broken == true,
|
||||
order = {}, rank = {}, pins = {}, missing = {}, mismatched = {},
|
||||
floor = 1, refused = false }
|
||||
if type(cart) ~= "table" then
|
||||
report.enforced = true
|
||||
report.refused = true
|
||||
report.message = "this cart is not installed"
|
||||
return report
|
||||
end
|
||||
report.id, report.title = cart.id, cart.title
|
||||
report.seal = CartManifest.SEALS[cart.seal] and cart.seal or "sealed"
|
||||
report.sealed = report.seal ~= "open"
|
||||
report.enforced = report.sealed and not report.broken
|
||||
local have = installedVersions(installed)
|
||||
local pins = {}
|
||||
for _, pin in ipairs(cart.mods or {}) do
|
||||
if type(pin) == "table" and type(pin.id) == "string" then pins[pin.id] = pin end
|
||||
end
|
||||
for _, id in ipairs(cart.load_order or {}) do
|
||||
local pin = pins[id]
|
||||
if pin and not report.pins[id] then
|
||||
report.order[#report.order + 1] = id
|
||||
report.rank[id] = #report.order
|
||||
report.pins[id] = pin
|
||||
local want, got = pinnedVersion(pin), have[id]
|
||||
if got == nil then
|
||||
report.missing[#report.missing + 1] =
|
||||
{ id = id, version = want, source = pin.source }
|
||||
elseif want and not sameVersion(want, got) then
|
||||
report.mismatched[#report.mismatched + 1] =
|
||||
{ id = id, version = want, installed = got }
|
||||
end
|
||||
end
|
||||
end
|
||||
report.floor = #report.order + 1
|
||||
local parts = cartComplaints(report)
|
||||
if #parts > 0 then
|
||||
report.message = ("%s: %s"):format(cart.title or cart.id or "cart",
|
||||
table.concat(parts, "; "))
|
||||
report.refused = report.enforced
|
||||
end
|
||||
return report
|
||||
end
|
||||
|
||||
function Loader:cartStatus()
|
||||
return self.cartReport
|
||||
end
|
||||
|
||||
function Loader:_applyCart()
|
||||
local cartId = SaveData.getCart()
|
||||
if not cartId or self.safeMode then return end
|
||||
local cart, err = self.cart, nil
|
||||
if not cart then cart, err = CartStore.get(cartId, self.fs) end
|
||||
local report = Loader.planCart(cart, self.mods, SaveData.isSealBroken())
|
||||
report.id = cartId
|
||||
if not cart then
|
||||
report.message = ("%s: %s"):format(cartId, tostring(err or "this cart is not installed"))
|
||||
end
|
||||
self.cartReport = report
|
||||
if report.refused then
|
||||
for _, mod in pairs(self.mods) do
|
||||
mod.enabled, mod.state = false, "disabled"
|
||||
end
|
||||
self.errors[#self.errors + 1] = report.message
|
||||
Logger.error("cart %s refused: %s", cartId, report.message)
|
||||
return
|
||||
end
|
||||
if report.message then Logger.warn("cart %s: %s", cartId, report.message) end
|
||||
-- the pins whose switch the cart hands to the player: their answer lives in
|
||||
-- the cart's own scope, never in the per-game flags
|
||||
self.cartSwitches = {}
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
for id, mod in pairs(self.mods) do
|
||||
local pin = report.pins[id]
|
||||
if pin then
|
||||
local on = CartManifest.modEnabled(pin)
|
||||
if Loader.pinTogglable(report, pin) then
|
||||
local chosen = SaveData.cartModEnabled(options, cartId, id)
|
||||
if type(chosen) == "boolean" then on = chosen end
|
||||
self.cartSwitches[id] = true
|
||||
end
|
||||
mod.enabled, mod.state = on, on and "pending" or "disabled"
|
||||
elseif report.enforced then
|
||||
mod.enabled, mod.state = false, "disabled"
|
||||
end
|
||||
end
|
||||
local merged = {}
|
||||
for id, bucket in pairs(self.modOptions) do merged[id] = bucket end
|
||||
for id, pin in pairs(report.pins) do
|
||||
local bucket = {}
|
||||
for key, value in pairs(pin.options or {}) do bucket[key] = value end
|
||||
if not report.enforced then
|
||||
for key, value in pairs(self.modOptions[id] or {}) do bucket[key] = value end
|
||||
end
|
||||
merged[id] = bucket
|
||||
end
|
||||
self.modOptions = merged
|
||||
end
|
||||
|
||||
function Loader:_cartRank(id)
|
||||
local report = self.cartReport
|
||||
if not report then return 0 end
|
||||
return report.rank[id] or report.floor
|
||||
end
|
||||
|
||||
-- ------- validate and resolve
|
||||
|
||||
-- a failed mod keeps the user's enable flag (the manager still shows it as
|
||||
@@ -773,9 +938,12 @@ function Loader:_order()
|
||||
if not best then
|
||||
best = id
|
||||
else
|
||||
local ra, rb = self:_cartRank(id), self:_cartRank(best)
|
||||
local pa, pb = self.mods[id].manifest.priority,
|
||||
self.mods[best].manifest.priority
|
||||
if pa < pb or (pa == pb and id < best) then best = id end
|
||||
if ra < rb or (ra == rb and (pa < pb or (pa == pb and id < best))) then
|
||||
best = id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -980,6 +1148,10 @@ function Loader:_api(mod)
|
||||
id = modId,
|
||||
version = mod.manifest.version,
|
||||
path = mod.path,
|
||||
-- Fixed at Loader construction and copied as plain data: a sandboxed
|
||||
-- entry chunk can decide whether to register developer-only diagnostics
|
||||
-- without receiving the process environment or the loader itself.
|
||||
developer = loader.dev == true,
|
||||
-- a deep copy: what a mod does to its own view never reaches the loader
|
||||
manifest = Merge.deepCopy(mod.manifest),
|
||||
content = {},
|
||||
@@ -1610,6 +1782,7 @@ function Loader:load(data)
|
||||
mod.enabled = not self.disabled[id]
|
||||
mod.state = mod.enabled and "pending" or "disabled"
|
||||
end
|
||||
self:_applyCart()
|
||||
-- engine call sites reach these buses -- and this error feed, for failures
|
||||
-- that only surface at play time -- through Runtime from here on
|
||||
Runtime.install(self.events, self.hooks, self.errors)
|
||||
@@ -1783,7 +1956,7 @@ function Loader:status()
|
||||
table.sort(available, function(a, b) return a.id < b.id end)
|
||||
table.sort(loaded, function(a, b) return a.id < b.id end)
|
||||
return { available = available, loaded = loaded, errors = self.errors,
|
||||
order = self.order }
|
||||
order = self.order, cart = self.cartReport }
|
||||
end
|
||||
|
||||
return Loader
|
||||
|
||||
+143
-6
@@ -18,6 +18,9 @@
|
||||
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
|
||||
-- name for something else, so an unknown version is refused outright rather
|
||||
-- than parsed hopefully.
|
||||
--
|
||||
-- Carts ride the same feed additively at schema_version 1: `doc.carts` is a
|
||||
-- second array beside `doc.mods`, and a feed without it lists no carts.
|
||||
|
||||
local ModIndex = {}
|
||||
|
||||
@@ -26,7 +29,7 @@ local ModIndex = {}
|
||||
-- single repo's releases; a whole index is heavier and changes more slowly.
|
||||
ModIndex.CACHE_TTL = 24 * 60 * 60
|
||||
ModIndex.SCHEMA_VERSION = 1
|
||||
ModIndex.CACHE_VERSION = 2
|
||||
ModIndex.CACHE_VERSION = 3
|
||||
|
||||
-- ------- pure: source resolution
|
||||
|
||||
@@ -225,8 +228,95 @@ local function parseEntry(raw)
|
||||
}
|
||||
end
|
||||
|
||||
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
|
||||
-- | nil, err
|
||||
local function numArray(v)
|
||||
local out = {}
|
||||
if type(v) == "table" then
|
||||
for _, entry in ipairs(v) do
|
||||
local n = tonumber(entry)
|
||||
if n then out[#out + 1] = n end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- One pinned mod out of a cart's `mods` array, in the bundle's own cart.json
|
||||
-- shape: github pins carry repo/version/sha256, gamebanana pins mod/file/md5.
|
||||
local function parseCartPin(raw)
|
||||
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
||||
local source = str(raw.source)
|
||||
if source ~= "github" and source ~= "gamebanana" then return nil end
|
||||
local pin = {
|
||||
id = raw.id,
|
||||
source = source,
|
||||
repo = str(raw.repo),
|
||||
version = str(raw.version),
|
||||
sha256 = str(raw.sha256),
|
||||
mod = tonumber(raw.mod),
|
||||
file = tonumber(raw.file),
|
||||
md5 = str(raw.md5),
|
||||
options = type(raw.options) == "table" and raw.options or nil,
|
||||
}
|
||||
if raw.enabled == false then pin.enabled = false end
|
||||
return pin
|
||||
end
|
||||
|
||||
-- One cart listing. The eight fields the cart schema marks required are the
|
||||
-- gate: a row missing any of them is dropped rather than half-listed, because
|
||||
-- every one of them is load bearing for installing or naming the cart.
|
||||
local function parseCartEntry(raw)
|
||||
if type(raw) ~= "table" then return nil end
|
||||
if not (str(raw.id) and str(raw.title) and str(raw.author)
|
||||
and str(raw.version) and str(raw.base) and str(raw.seal)
|
||||
and str(raw.repo) and type(raw.mods) == "table") then
|
||||
return nil
|
||||
end
|
||||
local pins = {}
|
||||
for _, pin in ipairs(raw.mods) do
|
||||
local parsed = parseCartPin(pin)
|
||||
if parsed then pins[#pins + 1] = parsed end
|
||||
end
|
||||
if #pins == 0 then return nil end
|
||||
return {
|
||||
kind = "cart",
|
||||
folder = str(raw.folder),
|
||||
id = raw.id,
|
||||
title = raw.title,
|
||||
author = raw.author,
|
||||
version = raw.version,
|
||||
base = raw.base,
|
||||
seal = raw.seal,
|
||||
summary = str(raw.summary) or "",
|
||||
shell = str(raw.shell),
|
||||
finish = str(raw.finish),
|
||||
speeds = numArray(raw.speeds),
|
||||
tags = strArray(raw.tags),
|
||||
repo = raw.repo,
|
||||
github = str(raw.github),
|
||||
downloadURL = str(raw.downloadURL),
|
||||
automatic_version_check = raw.automatic_version_check ~= false,
|
||||
fixed_release_tag = str(raw.fixed_release_tag),
|
||||
game_version = str(raw.game_version),
|
||||
license = str(raw.license),
|
||||
mods = pins,
|
||||
load_order = strArray(raw.load_order),
|
||||
thumbnail = str(raw.thumbnail),
|
||||
description_url = str(raw.description_url),
|
||||
downloads = parseDownloads(raw.downloads),
|
||||
first_release = str(raw.first_release),
|
||||
last_release = str(raw.last_release),
|
||||
latest = parseLatest(raw.latest),
|
||||
update_check = str(raw.update_check) or "pending",
|
||||
}
|
||||
end
|
||||
|
||||
-- True for a listing that installs through CartStore, not the mod installer.
|
||||
function ModIndex.isCart(entry)
|
||||
return type(entry) == "table" and entry.kind == "cart"
|
||||
end
|
||||
|
||||
-- parse(jsonText [, Json])
|
||||
-- -> { schemaVersion, generatedAt, categories, baseGames, mods, carts }
|
||||
-- | nil, err
|
||||
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
||||
-- future schema all come back as a message the panel can print.
|
||||
function ModIndex.parse(jsonText, Json)
|
||||
@@ -254,11 +344,21 @@ function ModIndex.parse(jsonText, Json)
|
||||
local entry = parseEntry(raw)
|
||||
if entry then mods[#mods + 1] = entry end
|
||||
end
|
||||
-- Absent carts is the old-feed case, not an error.
|
||||
local carts = {}
|
||||
if type(doc.carts) == "table" then
|
||||
for _, raw in ipairs(doc.carts) do
|
||||
local entry = parseCartEntry(raw)
|
||||
if entry then carts[#carts + 1] = entry end
|
||||
end
|
||||
end
|
||||
return {
|
||||
schemaVersion = schema,
|
||||
generatedAt = str(doc.generated_at),
|
||||
categories = strArray(doc.categories),
|
||||
baseGames = strArray(doc.base_games),
|
||||
mods = mods,
|
||||
carts = carts,
|
||||
}
|
||||
end)
|
||||
if not ok then return nil, "could not read the index: " .. tostring(result) end
|
||||
@@ -421,12 +521,14 @@ function ModIndex.matches(entry, query)
|
||||
return true
|
||||
end
|
||||
|
||||
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
|
||||
-- Category and tag compare case-insensitively; feed order (already sorted by
|
||||
-- title) is preserved.
|
||||
-- filter(entries, opts) -> a new array. opts = { query, category, base, tag }.
|
||||
-- Category, base and tag compare case-insensitively; feed order (already
|
||||
-- sorted by title) is preserved. `base` is the cart-side equivalent of a
|
||||
-- mod's category: a cart plays as exactly one game and has no categories.
|
||||
function ModIndex.filter(mods, opts)
|
||||
opts = opts or {}
|
||||
local want = opts.category and tostring(opts.category):lower() or nil
|
||||
local wantBase = opts.base and tostring(opts.base):lower() or nil
|
||||
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
|
||||
local out = {}
|
||||
for _, entry in ipairs(mods or {}) do
|
||||
@@ -437,6 +539,9 @@ function ModIndex.filter(mods, opts)
|
||||
if tostring(c):lower() == want then keep = true; break end
|
||||
end
|
||||
end
|
||||
if keep and wantBase then
|
||||
keep = tostring(entry.base or ""):lower() == wantBase
|
||||
end
|
||||
if keep and wantTag then
|
||||
keep = false
|
||||
for _, t in ipairs(entry.tags or {}) do
|
||||
@@ -469,6 +574,32 @@ function ModIndex.categoriesIn(index)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Every base game the feed's carts actually play as, in the feed's declared
|
||||
-- base_games order, with anything a cart names that the header forgot
|
||||
-- appended. The cart-side twin of categoriesIn.
|
||||
function ModIndex.baseGamesIn(index)
|
||||
local out, seen = {}, {}
|
||||
if type(index) ~= "table" then return out end
|
||||
local used = {}
|
||||
for _, entry in ipairs(index.carts or {}) do
|
||||
if entry.base then used[entry.base] = true end
|
||||
end
|
||||
for _, base in ipairs(index.baseGames or {}) do
|
||||
if used[base] and not seen[base] then
|
||||
seen[base] = true
|
||||
out[#out + 1] = base
|
||||
end
|
||||
end
|
||||
for _, entry in ipairs(index.carts or {}) do
|
||||
local base = entry.base
|
||||
if base and not seen[base] then
|
||||
seen[base] = true
|
||||
out[#out + 1] = base
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- sources (options.modIndexes)
|
||||
|
||||
local function loadOptions()
|
||||
@@ -569,7 +700,9 @@ function ModIndex.writeCache(feed, index)
|
||||
version = ModIndex.CACHE_VERSION,
|
||||
generatedAt = index.generatedAt,
|
||||
categories = index.categories,
|
||||
baseGames = index.baseGames,
|
||||
mods = index.mods,
|
||||
carts = index.carts,
|
||||
}
|
||||
SaveData.saveOptions(opts)
|
||||
end)
|
||||
@@ -609,7 +742,9 @@ function ModIndex.fetch(source, opts)
|
||||
schemaVersion = ModIndex.SCHEMA_VERSION,
|
||||
generatedAt = entry.generatedAt,
|
||||
categories = entry.categories or {},
|
||||
baseGames = entry.baseGames or {},
|
||||
mods = entry.mods or {},
|
||||
carts = entry.carts or {},
|
||||
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
||||
end
|
||||
|
||||
@@ -674,7 +809,9 @@ local function cachedIndex(feed, stale)
|
||||
schemaVersion = ModIndex.SCHEMA_VERSION,
|
||||
generatedAt = entry.generatedAt,
|
||||
categories = entry.categories or {},
|
||||
baseGames = entry.baseGames or {},
|
||||
mods = entry.mods or {},
|
||||
carts = entry.carts or {},
|
||||
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
||||
end
|
||||
|
||||
|
||||
+11
-2
@@ -87,6 +87,7 @@ local function drain()
|
||||
j.status = msg.ok and "ok" or "error"
|
||||
j.body, j.err, j.path = msg.body, msg.err, msg.path
|
||||
j.code = msg.code
|
||||
j.notModified = msg.notModified
|
||||
j.progress = msg.ok and 1 or j.progress
|
||||
end
|
||||
end
|
||||
@@ -154,12 +155,18 @@ end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
-- opts.etagRel (optional, relative to the save directory like saveRel itself)
|
||||
-- turns this into a conditional GET against a cached ETag at that path --
|
||||
-- see HostShell.httpDownload's own comment for the 304/notModified contract.
|
||||
-- A caller checks Fetch.poll(job).notModified once status is "ok" to tell a
|
||||
-- real download apart from a no-op cache hit (no file was written for the
|
||||
-- latter).
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "download", url = url, dest = saveRel,
|
||||
size = opts.size,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds, etagRel = opts.etagRel })
|
||||
end
|
||||
|
||||
-- Non-blocking status. Returns a table; never nil, even for an unknown id
|
||||
@@ -227,7 +234,9 @@ function Fetch.shutdown()
|
||||
end
|
||||
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
|
||||
workers = {}
|
||||
cmdCh, resCh, quitCh, ready = nil, nil, nil, false
|
||||
cmdCh, resCh, quitCh, ready = nil, nil, nil, nil
|
||||
end
|
||||
|
||||
require("src.core.SessionLifecycle").registerProcessShutdown(Fetch.shutdown)
|
||||
|
||||
return Fetch
|
||||
|
||||
@@ -73,6 +73,12 @@ end
|
||||
-- inside the call. Where the caller knows the expected size we poll the
|
||||
-- growing file from a second pass instead; where it does not, the job simply
|
||||
-- reports indeterminate progress and the UI shows a spinner.
|
||||
--
|
||||
-- job.etagRel (optional, relative to saveDir like job.dest) turns this into
|
||||
-- a conditional GET -- see HostShell.httpDownload's own comment for the
|
||||
-- 304/notModified contract. A notModified result never writes `rel` at all
|
||||
-- (confirmed empirically, not assumed -- see TrueFX/etag-cache-repro/), so
|
||||
-- the usual "did a file actually land" check below is skipped for it.
|
||||
local function doDownload(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
@@ -84,12 +90,17 @@ local function doDownload(job)
|
||||
if dir then love.filesystem.createDirectory(dir) end
|
||||
love.filesystem.remove(rel)
|
||||
|
||||
local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent,
|
||||
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS)
|
||||
local etagAbs = job.etagRel and (saveDir .. "/" .. job.etagRel) or nil
|
||||
local ok, err, notModified = HostShell.httpDownload(job.url, abs, job.userAgent,
|
||||
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS, etagAbs)
|
||||
if not ok then
|
||||
post({ id = job.id, ok = false, err = err or "download failed" })
|
||||
return
|
||||
end
|
||||
if notModified then
|
||||
post({ id = job.id, ok = true, path = rel, done = true, notModified = true })
|
||||
return
|
||||
end
|
||||
local info = love.filesystem.getInfo(rel)
|
||||
if not info or (info.size or 0) == 0 then
|
||||
love.filesystem.remove(rel)
|
||||
|
||||
+25
-2
@@ -14,6 +14,8 @@ local Assets = {}
|
||||
local cache = {}
|
||||
-- downstream caches that must empty when the search path changes
|
||||
local invalidators = {}
|
||||
-- optional GPU release hooks for session end (never run on hot reload flush)
|
||||
local releasers = {}
|
||||
|
||||
-- The loader bridge: overrideOrder() yields mods highest-priority-first
|
||||
-- and derivedPath(rel) yields an existing save/mod-derived/<id>/<rel>.
|
||||
@@ -68,8 +70,18 @@ function Assets.imageData(path)
|
||||
return love.image.newImageData(Assets.resolve(path))
|
||||
end
|
||||
|
||||
function Assets.register(invalidate)
|
||||
invalidators[#invalidators + 1] = invalidate
|
||||
-- Register a cache invalidator, or { invalidate = fn, release = fn } when a
|
||||
-- module caches LOVE Images/Canvases and can eagerly free them at session end.
|
||||
-- release is optional and is NOT run on flush/invalidate (HotReload safe).
|
||||
-- MapLoader is the canonical split-hook example: invalidateAll clears tables
|
||||
-- without GPU release; releaseAll evicts every resident map renderer.
|
||||
function Assets.register(hooks)
|
||||
if type(hooks) == "function" then
|
||||
invalidators[#invalidators + 1] = hooks
|
||||
return
|
||||
end
|
||||
if hooks.invalidate then invalidators[#invalidators + 1] = hooks.invalidate end
|
||||
if hooks.release then releasers[#releasers + 1] = hooks.release end
|
||||
end
|
||||
|
||||
-- hot reload's single entry point (20-developer-tooling): drop the central
|
||||
@@ -82,6 +94,17 @@ end
|
||||
|
||||
Assets.flush = Assets.invalidate
|
||||
|
||||
-- In-process return-to-launcher / editor close: release central Images and
|
||||
-- run release hooks only. Does not call invalidate hooks (MapLoader must
|
||||
-- keep invalidateAll separate from releaseAll).
|
||||
function Assets.releaseSession()
|
||||
for _, img in pairs(cache) do
|
||||
if img and img.release then pcall(img.release, img) end
|
||||
end
|
||||
cache = {}
|
||||
for _, fn in ipairs(releasers) do pcall(fn) end
|
||||
end
|
||||
|
||||
-- Loader:load hands over the live mod set once the merge is done. Load
|
||||
-- order is priority ascending, so the search walks it backwards: the mod
|
||||
-- that wins the record merge wins the asset lookup too.
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
-- GBC Effects post-process ("Pixel Transparency" style, see
|
||||
-- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder
|
||||
-- applied after palette colorization and before the CRT pass:
|
||||
-- 1 reflective screen: bright pixels blend toward a procedurally
|
||||
-- grained warm backing (the unlit-GBC "transparent whites" look)
|
||||
-- 2 + LCD subpixel grid
|
||||
-- 3 + drop shadows (dark pixels float above the backing)
|
||||
-- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly
|
||||
-- drifting light source
|
||||
-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles.
|
||||
-- Spec: docs/new-features.md (Custom Options / GBC FX)
|
||||
--
|
||||
-- One shader for all levels: features are gated by the `level` uniform
|
||||
-- (float comparisons), so cycling never recompiles. All spatial effects
|
||||
-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch,
|
||||
-- shadow offsets and grain stay window-size independent.
|
||||
|
||||
local GBCFX = {}
|
||||
|
||||
GBCFX.LABELS = { "OFF", "1", "2", "3", "4" }
|
||||
GBCFX.level = 0
|
||||
|
||||
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"
|
||||
end
|
||||
|
||||
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
|
||||
-- shadow blur are unrolled by hand).
|
||||
local SHADER_SRC = [[
|
||||
extern number level;
|
||||
extern number time;
|
||||
extern number pixelScale; // screen pixels per GB pixel (integer fit scale)
|
||||
|
||||
#define PI 3.14159265359
|
||||
|
||||
// ---- level thresholds (cumulative ladder) ----
|
||||
#define L_GRID 1.5
|
||||
#define L_SHADOW 2.5
|
||||
#define L_SUN 3.5
|
||||
|
||||
// ---- level 1: reflective backing ----
|
||||
#define BACK_BRIGHTNESS 0.48
|
||||
#define GRAIN_INTENSITY 0.065
|
||||
// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness
|
||||
#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424)
|
||||
#define BASE_ALPHA 0.20
|
||||
#define WHITE_EXTRA 0.75
|
||||
// front polarizer film tint
|
||||
#define POLARIZER vec3(0.94, 1.0, 0.865)
|
||||
|
||||
// ---- level 2: LCD grid (lcd1x style) ----
|
||||
#define BRIGHTEN_SCANLINES 16.0
|
||||
#define BRIGHTEN_LCD 4.0
|
||||
|
||||
// ---- level 3: drop shadow ----
|
||||
#define SHADOW_OFFSET 3.0
|
||||
#define SHADOW_OPACITY 0.5
|
||||
|
||||
// ---- level 4: sunlight ----
|
||||
#define GLARE_INTENSITY 0.15
|
||||
#define GLARE_SIGMA 0.25
|
||||
#define SHIMMER_INTENSITY 0.25
|
||||
// chroma amplification so the bands read on the already-desaturated,
|
||||
// backing-blended image (reference applies 0.25 to raw film reflectance)
|
||||
#define SHIMMER_CHROMA_GAIN 3.0
|
||||
#define SHIMMER_SPREAD 1.8
|
||||
#define LIGHT_RANGE 0.6
|
||||
#define FILM_NOISE_AMOUNT 0.5
|
||||
#define REFLECT_FLOOR 0.03
|
||||
|
||||
float hash21(vec2 p)
|
||||
{
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
// smooth value noise, ~[0,1]
|
||||
float vnoise(vec2 p)
|
||||
{
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash21(i);
|
||||
float b = hash21(i + vec2(1.0, 0.0));
|
||||
float c = hash21(i + vec2(0.0, 1.0));
|
||||
float d = hash21(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float luma(vec3 c)
|
||||
{
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
|
||||
{
|
||||
vec4 src = Texel(tex, tc);
|
||||
if (level < 0.5) {
|
||||
return src * color;
|
||||
}
|
||||
|
||||
float ps = max(pixelScale, 1.0);
|
||||
vec2 gbPix = pc / ps; // GB-pixel coordinates
|
||||
vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc
|
||||
vec2 gbTexel = texel * ps; // one GB pixel in tc
|
||||
|
||||
// Drifting light position (normalized screen coords, upper area).
|
||||
// Computed unconditionally: level 3 borrows it for shadow drift.
|
||||
vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13),
|
||||
0.3 + 0.2 * sin(time * 0.07));
|
||||
|
||||
// ---- level 1: procedural backing material ----
|
||||
// flat gray + 3-octave paper grain, tinted warm
|
||||
float grain = vnoise(gbPix * 0.9) * 0.5
|
||||
+ vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3
|
||||
+ vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2;
|
||||
float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0);
|
||||
vec3 back = backLum * POCKET_TINT;
|
||||
|
||||
// ---- level 3: dark pixels cast a soft shadow onto the backing ----
|
||||
if (level >= L_SHADOW) {
|
||||
vec2 shOff = vec2(SHADOW_OFFSET);
|
||||
if (level >= L_SUN) {
|
||||
// subtle drift opposite the light's wander
|
||||
shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0);
|
||||
}
|
||||
vec2 so = tc - shOff * gbTexel;
|
||||
vec2 e = gbTexel;
|
||||
// 9-tap gaussian blur of the offset sample's brightness (unrolled)
|
||||
float s = 0.0;
|
||||
s += luma(Texel(tex, so).rgb) * 4.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0;
|
||||
s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0;
|
||||
s /= 16.0;
|
||||
float dark = 1.0 - s;
|
||||
// deadzone: near-white pixels (dark ~ 0) cast no shadow at all
|
||||
float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY;
|
||||
back = mix(back, back * 0.2, shadow);
|
||||
}
|
||||
|
||||
// ---- level 2: LCD subpixel grid on the lit image only ----
|
||||
vec3 lit = src.rgb;
|
||||
if (level >= L_GRID) {
|
||||
vec2 angle = 2.0 * PI * (gbPix - 0.25);
|
||||
float yfac = (BRIGHTEN_SCANLINES + sin(angle.y))
|
||||
/ (BRIGHTEN_SCANLINES + 1.0);
|
||||
float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0);
|
||||
lit *= yfac * xfac;
|
||||
}
|
||||
|
||||
// ---- level 1: brightness-proportional pixel transparency ----
|
||||
float lum = luma(src.rgb);
|
||||
float a = BASE_ALPHA * lum;
|
||||
// near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly
|
||||
// fully transparent -- narrow smoothsteps stand in for the hard AND
|
||||
float mn = min(src.r, min(src.g, src.b));
|
||||
a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn);
|
||||
vec3 col = mix(lit, back, clamp(a, 0.0, 1.0));
|
||||
|
||||
// ---- level 4: sunlight (glare + rainbow QWP shimmer) ----
|
||||
float glare = 0.0;
|
||||
if (level >= L_SUN) {
|
||||
float aspect = love_ScreenSize.x / love_ScreenSize.y;
|
||||
vec2 p = vec2(tc.x * aspect, tc.y);
|
||||
vec2 lp = vec2(lightPos.x * aspect, lightPos.y);
|
||||
float d = distance(p, lp);
|
||||
|
||||
// specular gaussian hotspot (added after the polarizer tint:
|
||||
// it reflects off the front glass, not the LCD)
|
||||
glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA));
|
||||
|
||||
// quarter-wave-plate film: effective retardance (nm) grows with
|
||||
// distance from the light point -> concentric interference bands;
|
||||
// smooth "film thickness" noise makes the bands splotchy
|
||||
float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01);
|
||||
float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE))
|
||||
* (1.0 + FILM_NOISE_AMOUNT * (film - 0.5));
|
||||
float ph = 4.0 * PI * gammaEff;
|
||||
|
||||
// 7 wavelength samples 400..700nm with approximate spectral RGB,
|
||||
// unrolled (no const arrays in GLSL 1.20)
|
||||
vec3 rb = vec3(0.0);
|
||||
float cw;
|
||||
cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50);
|
||||
cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00);
|
||||
cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40);
|
||||
cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00);
|
||||
cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00);
|
||||
cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00);
|
||||
cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00);
|
||||
rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0
|
||||
|
||||
// fade with distance from the light, kill on dark pixels,
|
||||
// weight by pixel color squared
|
||||
float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d);
|
||||
float refl = max(lum, REFLECT_FLOOR);
|
||||
// luminance-preserving tint: add only the chroma of the rainbow
|
||||
vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN
|
||||
* src.rgb * src.rgb * refl * att;
|
||||
col += shimmer * SHIMMER_INTENSITY;
|
||||
}
|
||||
|
||||
// front polarizer tint, then front-surface glare on top
|
||||
col *= POLARIZER;
|
||||
col += vec3(glare);
|
||||
|
||||
return vec4(col, src.a) * color;
|
||||
}
|
||||
]]
|
||||
|
||||
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
|
||||
|
||||
function GBCFX.shader()
|
||||
if not GBCFX.isSupported() then return nil end
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
shader = ok and sh or false
|
||||
end
|
||||
return shader or nil
|
||||
end
|
||||
|
||||
function GBCFX.setLevel(level)
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return
|
||||
end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
if level > 4 then level = 4 end
|
||||
GBCFX.level = level
|
||||
end
|
||||
|
||||
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
|
||||
function GBCFX.cycle()
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return 0
|
||||
end
|
||||
GBCFX.setLevel((GBCFX.level + 1) % 5)
|
||||
return GBCFX.level
|
||||
end
|
||||
|
||||
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
|
||||
-- persisted non-zero value so boot recovers from a soft-brick. Returns
|
||||
-- true when opts was sanitized (caller should persist options.lua).
|
||||
function GBCFX.applyOptions(opts)
|
||||
if not GBCFX.isSupported() then
|
||||
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
|
||||
if opts then opts.gbcfx = 0 end
|
||||
GBCFX.level = 0
|
||||
return had and true or false
|
||||
end
|
||||
GBCFX.setLevel(opts and opts.gbcfx or 0)
|
||||
return false
|
||||
end
|
||||
|
||||
function GBCFX.levelLabel(level)
|
||||
return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF"
|
||||
end
|
||||
|
||||
function GBCFX.active()
|
||||
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
end
|
||||
|
||||
-- Draw `canvas` fullscreen through the GBC FX shader into the current
|
||||
-- render target (or plain if the shader is unavailable). pixelScale is
|
||||
-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets
|
||||
-- stay window-size independent.
|
||||
function GBCFX.present(canvas, pixelScale)
|
||||
local sh = GBCFX.shader()
|
||||
if not sh or GBCFX.level <= 0 then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(canvas, 0, 0)
|
||||
return
|
||||
end
|
||||
local t = 0
|
||||
if love.timer and love.timer.getTime then
|
||||
t = love.timer.getTime()
|
||||
end
|
||||
sh:send("level", GBCFX.level)
|
||||
sh:send("time", t)
|
||||
sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1)))
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(canvas, 0, 0)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
return GBCFX
|
||||
@@ -0,0 +1,164 @@
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:544 ConvertAndApplyBitmask
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:356 PokeAnim_DoAnimScript
|
||||
|
||||
local MonAnim = {}
|
||||
MonAnim.__index = MonAnim
|
||||
|
||||
-- .Sizes db 4, 5, 7: bitmask bytes for a 5x5, 6x6 and 7x7 pic.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:534
|
||||
MonAnim.BITMASK_BYTES = { [5] = 4, [6] = 5, [7] = 7 }
|
||||
|
||||
-- ../pokecrystal/macros/scripts/pic_anims.asm:13-27
|
||||
MonAnim.END = 0xff
|
||||
MonAnim.SETREPEAT = 0xfe
|
||||
MonAnim.DOREPEAT = 0xfd
|
||||
|
||||
-- .NextBit walks the row first, so bit i is row i % height, column i / height.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:746
|
||||
function MonAnim.tileMap(data, frame)
|
||||
local tiles = data and data.tiles
|
||||
if not tiles then return nil end
|
||||
local count = tiles * tiles
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = i - 1 end
|
||||
if not frame or frame <= 0 then return out end
|
||||
local row = data.frames and data.frames[frame]
|
||||
local mask = row and data.bitmasks and data.bitmasks[row.bitmask]
|
||||
if not (row and mask) then return nil end
|
||||
local cursor = 1
|
||||
for i = 0, count - 1 do
|
||||
local byte = mask[math.floor(i / 8) + 1] or 0
|
||||
if math.floor(byte / 2 ^ (i % 8)) % 2 == 1 then
|
||||
local id = row.tiles[cursor]
|
||||
if id == nil then return nil end
|
||||
out[i + 1] = id
|
||||
cursor = cursor + 1
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16), truncated to 8 bits.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:413
|
||||
function MonAnim.duration(param, speed)
|
||||
param = param % 256
|
||||
local scaled = math.floor(param * (speed or 0) / 16) % 256
|
||||
return (scaled + param) % 256
|
||||
end
|
||||
|
||||
-- The PokeAnims programs less their cry commands; "wait" is SetWait's 18.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:69-77, :150-164
|
||||
local SCENES = {
|
||||
battle = { "setup", "play" },
|
||||
battleSlow = { "setup2", "play" },
|
||||
menu = { "setup", "play", "wait", "idle", "play" },
|
||||
}
|
||||
|
||||
MonAnim.SCENE_WAIT = 18
|
||||
|
||||
function MonAnim.scenes() return SCENES end
|
||||
|
||||
function MonAnim.new(data, scene)
|
||||
local steps = SCENES[scene or "battle"]
|
||||
if not (data and steps and data.play and #data.play > 0) then return nil end
|
||||
return setmetatable({
|
||||
data = data,
|
||||
steps = steps,
|
||||
step = 1,
|
||||
frame = 0,
|
||||
speed = 0,
|
||||
script = nil,
|
||||
pc = 1,
|
||||
repeatTimer = 0,
|
||||
waiting = false,
|
||||
waitCounter = 0,
|
||||
sceneWait = nil,
|
||||
done = false,
|
||||
}, MonAnim)
|
||||
end
|
||||
|
||||
function MonAnim:finished() return self.done end
|
||||
|
||||
-- PokeAnim_GetFrame's `and a / ret z`: command 0 is the base picture.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:431-435
|
||||
function MonAnim:currentFrame() return self.frame end
|
||||
|
||||
function MonAnim:beginScript(rows, speed)
|
||||
self.script = rows or {}
|
||||
self.speed = speed
|
||||
self.pc = 1
|
||||
self.repeatTimer = 0
|
||||
self.waiting = false
|
||||
self.waitCounter = 0
|
||||
end
|
||||
|
||||
-- One tick of PokeAnim_DoAnimScript; true once the script has hit endanim.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:370-411
|
||||
function MonAnim:runScript()
|
||||
if self.waiting then
|
||||
self.waitCounter = (self.waitCounter - 1) % 256
|
||||
if self.waitCounter == 0 then self.waiting = false end
|
||||
return false
|
||||
end
|
||||
for _ = 1, 256 do
|
||||
local row = self.script[self.pc]
|
||||
self.pc = self.pc + 1
|
||||
if row == nil then return true end
|
||||
local command = row[1]
|
||||
if command == MonAnim.END then
|
||||
return true
|
||||
elseif command == MonAnim.SETREPEAT then
|
||||
self.repeatTimer = row[2]
|
||||
elseif command == MonAnim.DOREPEAT then
|
||||
-- .DoRepeat returns on both `ret z` (:397-406).
|
||||
if self.repeatTimer == 0 then return false end
|
||||
self.repeatTimer = self.repeatTimer - 1
|
||||
if self.repeatTimer == 0 then return false end
|
||||
self.pc = row[2] + 1
|
||||
else
|
||||
self.frame = command
|
||||
self.waiting = true
|
||||
self.waitCounter = MonAnim.duration(row[2], self.speed)
|
||||
-- StartWaitAnim falls through into .WaitAnim (:383-388), so the frame
|
||||
-- it places is the frame the counter first ticks on.
|
||||
self.waitCounter = (self.waitCounter - 1) % 256
|
||||
if self.waitCounter == 0 then self.waiting = false end
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- One iteration of AnimateFrontpic's .loop: one scene command per frame.
|
||||
-- ../pokecrystal/engine/gfx/pic_animation.asm:79-89
|
||||
function MonAnim:update()
|
||||
if self.done then return end
|
||||
local step = self.steps[self.step]
|
||||
if step == nil then
|
||||
-- PokeAnim_Finish's DeinitFrames puts the base picture back (:224-228).
|
||||
self.frame = 0
|
||||
self.done = true
|
||||
return
|
||||
end
|
||||
if step == "setup" or step == "setup2" or step == "idle" then
|
||||
local rows = (step == "idle") and self.data.idle or self.data.play
|
||||
self:beginScript(rows, step == "setup2" and 4 or 0)
|
||||
self.step = self.step + 1
|
||||
return
|
||||
end
|
||||
if step == "wait" then
|
||||
self.sceneWait = (self.sceneWait or MonAnim.SCENE_WAIT) - 1
|
||||
if self.sceneWait <= 0 then
|
||||
self.sceneWait = nil
|
||||
self.step = self.step + 1
|
||||
end
|
||||
return
|
||||
end
|
||||
if self:runScript() then
|
||||
-- PokeAnim_Play redraws the base picture as the script ends (:196-205).
|
||||
self.frame = 0
|
||||
self.step = self.step + 1
|
||||
end
|
||||
end
|
||||
|
||||
return MonAnim
|
||||
+16
-10
@@ -397,9 +397,12 @@ function PaletteFX.uiSpriteRedraws()
|
||||
end
|
||||
|
||||
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
|
||||
-- everything else uses the ROM-imported data.palettes.
|
||||
-- Yellow uses data/palettes_yellow.lua, everything else uses the ROM-imported data.palettes.
|
||||
function PaletteFX.pack(data)
|
||||
if PaletteFX.usesGbcPack() then
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
if y then return y end
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
local g = PaletteFX.gbcPack()
|
||||
if g then return g end
|
||||
end
|
||||
@@ -449,24 +452,27 @@ function PaletteFX.pal(data, name)
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
if GameVersion.isYellow() then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local y = PaletteFX.yellowPack()
|
||||
local yc = y and y.palettes and y.palettes[name]
|
||||
if yc then return yc end
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes and p.palettes[name]
|
||||
if c then return c end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
local yc = y and y.palettes and y.palettes[name]
|
||||
if yc then return yc end
|
||||
end
|
||||
if PaletteFX.usesGbcPack() then
|
||||
return romNamedPal(data, name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
|
||||
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
|
||||
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
|
||||
|
||||
+82
-16
@@ -100,10 +100,32 @@ local function positionLift(ph, contentPx, dpiY, cut)
|
||||
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
|
||||
end
|
||||
|
||||
-- Free GPU canvases immediately. Overwriting the Lua reference alone leaves
|
||||
-- VRAM allocated until LOVE's GC runs, which is too slow when Android loops
|
||||
-- Play → launcher → Play in one process.
|
||||
local function releaseCanvas(canvas)
|
||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||
end
|
||||
|
||||
function Renderer:releaseCanvases()
|
||||
releaseCanvas(self.canvas); self.canvas = nil
|
||||
releaseCanvas(self.battleHUDCanvas); self.battleHUDCanvas = nil
|
||||
releaseCanvas(self.worldCanvas); self.worldCanvas = nil
|
||||
releaseCanvas(self.uprightCanvas); self.uprightCanvas = nil
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
end
|
||||
|
||||
Renderer.release = Renderer.releaseCanvases
|
||||
|
||||
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.
|
||||
-- Release any prior session's surfaces before reallocating (in-process
|
||||
-- return-to-launcher reuses this Renderer singleton).
|
||||
self:releaseCanvases()
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.battleHUDCanvas = nil
|
||||
@@ -134,8 +156,8 @@ function Renderer:setWorldOverride(canvas)
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||
-- units via / dpiX and / dpiY when drawing.
|
||||
-- ShaderFX / callers treat this as the crisp scale; endFrame converts to
|
||||
-- LOVE units via / dpiX and / dpiY when drawing.
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
local w, h = self:uiSize()
|
||||
@@ -574,7 +596,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target,
|
||||
local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if zoneShader then
|
||||
love.graphics.setShader(zoneShader)
|
||||
-- same trueColor sentinel the flat blit honors (14 §trueColor)
|
||||
-- same trueColor sentinel the flat blit below honors
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
local plain = z.colors == false
|
||||
@@ -765,8 +787,8 @@ end
|
||||
-- visible map area separately), applied to the world pass; the world
|
||||
-- pass falls back to the UI zones when absent. Each zone is drawn
|
||||
-- scissored through the shade-remap shader, later zones on top.
|
||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
-- When ShaderFX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the shader chain as a final pass.
|
||||
function Renderer:frameRects()
|
||||
local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics()
|
||||
local r = {
|
||||
@@ -830,7 +852,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy
|
||||
local Ux, Uy = R.Ux, R.Uy
|
||||
local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local Up = R.Up
|
||||
-- Physical-pixel numerators for ShaderFX's per-frame rect; derived from
|
||||
-- the unit rects frameRects already lifted so both agree.
|
||||
local uoxPx, uoyPx = uox * dpiX, uoy * dpiY
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
zones = PaletteFX.ensureZones(zones)
|
||||
@@ -873,12 +899,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
end
|
||||
|
||||
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
|
||||
-- Post-process pipelines, ShaderFX and an enabled final-output owner need the
|
||||
-- whole composite in a canvas. With none of them, the frame draws straight
|
||||
-- to the screen exactly as it always did.
|
||||
local hasOutputHook = Runtime.wantsHook("render.output")
|
||||
and Runtime.call("render.output_enabled", function() return false end) == true
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local needPresent = ShaderFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -983,6 +1009,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
return Renderer.clipToView(R, x, y, w, h)
|
||||
end
|
||||
|
||||
-- Real per-frame ShaderFX game rect + source content size, in PHYSICAL
|
||||
-- framebuffer pixels -- what ShaderFX.render below actually draws through
|
||||
-- the chain, instead of it reconstructing a fixed 160x144-at-base-Sp
|
||||
-- approximation of its own. Defaults to this frame's real UI rect, which
|
||||
-- is already correct whenever neither branch below overrides it
|
||||
-- (title/menu/credits, no world active) -- uiFill is already folded into
|
||||
-- Up above, so that case needs no extra handling here.
|
||||
local fxRectPxX, fxRectPxY, fxRectPxW, fxRectPxH, fxScale =
|
||||
uoxPx, uoyPx, uiw * Up, uih * Up, Up
|
||||
local fxSrcW, fxSrcH = uiw, uih
|
||||
|
||||
if self.worldOverride then
|
||||
-- A render pipeline already produced the whole world -- terrain,
|
||||
-- characters and its own FX overlay -- as one window-resolution image,
|
||||
@@ -990,6 +1027,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- worldOverride is already a window-resolution image drawn 1:1 at the
|
||||
-- origin -- ShaderFX's real rect degenerates to the whole image, no
|
||||
-- crop needed (source size == rect size).
|
||||
fxRectPxX, fxRectPxY = 0, 0
|
||||
fxRectPxW, fxRectPxH = self.worldOverride:getPixelWidth(), self.worldOverride:getPixelHeight()
|
||||
fxScale = 1
|
||||
fxSrcW, fxSrcH = fxRectPxW, fxRectPxH
|
||||
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||
local loveMajor = love.getVersion()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||
@@ -1010,8 +1054,20 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local sx, sy = sp / dpiX, sp / dpiY
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
||||
local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY
|
||||
local woxPx = vx + math.floor((pw - wvw * sp) / 2)
|
||||
local woyPx = vy + math.floor((ph - wvh * sp) / 2) - R.lift
|
||||
local wox, woy = woxPx / dpiX, woyPx / dpiY
|
||||
-- The real on-screen world rect at the CURRENT survey zoom -- can be
|
||||
-- larger (zoomed out, more map revealed) or smaller than the UI's own
|
||||
-- default rect above. ShaderFX.render below now shades this real rect
|
||||
-- against this real wvw x wvh source, not a fixed 160x144 box, so a
|
||||
-- grid/LCD-style effect's own math lines up with true on-screen pixels
|
||||
-- at any zoom level. Covers both the flat blit below and the
|
||||
-- Tilt-projected blit -- both share this wox/woy/sp/wvw/wvh.
|
||||
fxRectPxX, fxRectPxY = woxPx, woyPx
|
||||
fxRectPxW, fxRectPxH = wvw * sp, wvh * sp
|
||||
fxScale = sp
|
||||
fxSrcW, fxSrcH = wvw, wvh
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -1223,9 +1279,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if present then
|
||||
GameViewport.setTarget()
|
||||
-- Post-process pipelines run over the finished composite -- world, UI
|
||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||
-- LCD grid is then drawn over rather than something that smears the
|
||||
-- grid itself. Each pass hands back a canvas; with none registered
|
||||
-- and all -- and before ShaderFX, so a blur or colour grade is what a
|
||||
-- shader preset is then drawn over rather than something that smears
|
||||
-- it. Each pass hands back a canvas; with none registered
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||
@@ -1240,9 +1296,19 @@ function Renderer:endFrame(zones, worldZones)
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
if ShaderFX.active() then
|
||||
-- The ShaderFX feature replaced GBCFX.lua's fixed level ladder
|
||||
-- with a preset picker (GBCFX.lua itself removed). fxRectPx*/fxScale/fxSrc*
|
||||
-- are this frame's REAL game rect + source size, set above by
|
||||
-- whichever branch actually ran (worldOverride, worldActive at the
|
||||
-- current survey zoom, or the UI-rect default for no-world/uiFill
|
||||
-- states) -- not a fixed 160x144-at-base-Sp reconstruction, so the
|
||||
-- chain sees true on-screen pixel geometry at any zoom/Faithful
|
||||
-- Ratio state.
|
||||
ShaderFX.render(composed,
|
||||
{ x = fxRectPxX, y = fxRectPxY, w = fxRectPxW, h = fxRectPxH, scale = fxScale },
|
||||
{ w = fxSrcW, h = fxSrcH },
|
||||
dpiX, dpiY)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user