Merge pull request #1806 from bryanthaboi/dev

bugs and shader fix
This commit is contained in:
bryanthaboi
2026-08-25 09:33:02 -04:00
committed by GitHub
100 changed files with 4459 additions and 758 deletions
+2
View File
@@ -33,6 +33,8 @@ body:
- Blue
- Yellow
- Gold
- Silver
- Crystal
- N/A
validations:
required: true
+9 -2
View File
@@ -1,5 +1,9 @@
# Generated from a user-provided Pokemon Red ROM.
# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean
# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal).
# Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean
# Yellow Surfing Pikachu minigame art also lands here on import:
# assets/generated/minigame/surf_1{a,b,c}.png
# assets/generated/minigame/title_bg.png
# assets/generated/minigame/intro_pika_{0,1,2,3}.png
data/generated/
assets/generated/
@@ -62,6 +66,9 @@ mobile/dist/
# ROM cache exactly like data/generated/, so it is never committable.
/build/tiled/
# Local visual scratch from minigame development (not wired to CI).
/tests/fixtures/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
+21 -25
View File
@@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
local function linkCableHelp(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
end
function askHeading()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
showMenu))
local items, menu, openMenu
local function closeAll()
game.stack:pop()
end
items = {}
for i, label in ipairs(LINK_HEADINGS) do
items[i] = { label = label, onSelect = function()
items[i] = { label = label, keepOpen = true, onSelect = function()
game.stack:push(TextBox.new(game,
text["_LinkCableInfoText" .. i] or label, askHeading))
text["_LinkCableInfoText" .. i] or label))
end }
end
items[#items + 1] = { label = "STOP READING" }
items[#items + 1] = { label = "STOP READING", onSelect = closeAll }
menu = Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2,
onCancel = closeAll })
function openMenu() game.stack:push(menu) end
game.stack:push(TextBox.new(game,
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
askHeading))
function()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
nil, { stay = { onShown = openMenu } }))
end))
end
return {
@@ -84,21 +86,15 @@ return {
{ "jump_if_false", 5 }, -- 2
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
{ "jump", 13 }, -- 4
{ "give_pokemon", "EEVEE", 25 }, -- 5
{ "jump_if_false", 12 }, -- 6 (party+box full)
-- flag + HideObject before the jingle and GotMonText: the nickname
-- prompt inside give_pokemon and the text row both yield, and a
-- script that dies there would leave the EEVEE taken with the ball
-- still on the table and the gift claimable again (#426). The row
-- count is unchanged, so the numeric jump targets still hold.
{ "jump", 11 }, -- 4
{ "give_pokemon", "EEVEE", 25, false, true }, -- 5
{ "jump_if_false", 10 }, -- 6 (party+box full)
-- scripts/CeladonMansionRoofHouse.asm:17-20 (#426)
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
{ "jump", 11 }, -- 9
{ "show_text", "_BoxIsFullText" }, -- 10
},
},
}
+2 -4
View File
@@ -2,12 +2,10 @@
return {
CELADON_CITY = {
talk = {
-- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far
-- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONCITY_POLIWRATH = {
{"face_player"},
-- pokered/scripts/CeladonCity.asm:90
{"play_cry", "POLIWRATH", true},
{"show_text", "_CeladonCityPoliwrathText"},
},
},
+6 -12
View File
@@ -2,30 +2,24 @@
return {
CELADON_MANSION_1F = {
talk = {
-- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_CLEFAIRY = {
{"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:27
{"play_cry", "CLEFAIRY", true},
{"show_text", "_CeladonMansion1FClefairyText"},
},
-- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_MEOWTH = {
{"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:18
{"play_cry", "MEOWTH", true},
{"show_text", "_CeladonMansion1FMeowthText"},
},
-- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far
-- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends.
-- The cry playback has no port-side equivalent command, so we just
-- show the flavor line.
TEXT_CELADONMANSION1F_NIDORANF = {
{"face_player"},
-- pokered/scripts/CeladonMansion1F.asm:33
{"play_cry", "NIDORAN_F", true},
{"show_text", "_CeladonMansion1FNidoranFText"},
},
},
@@ -4,11 +4,10 @@
return {
LAVENDER_CUBONE_HOUSE = {
talk = {
-- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText,
-- then text_asm plays the CUBONE cry. Cry playback has no Commands
-- equivalent in this port, so just show the line.
-- LavenderCuboneHouse.asm:10
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
{ "face_player" },
{ "play_cry", "CUBONE", true },
{ "show_text", "_LavenderCuboneHouseCuboneText" },
},
+4 -5
View File
@@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = {
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
},
-- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then
-- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just
-- show the flavor text.
-- scripts/MrFujisHouse.asm:56
TEXT_MRFUJISHOUSE_PSYDUCK = {
{ "play_cry", "PSYDUCK", true },
{ "show_text", "_MrFujisHousePsyduckText" },
},
-- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then
-- PlayCry(NIDORINO).
-- scripts/MrFujisHouse.asm:63
TEXT_MRFUJISHOUSE_NIDORINO = {
{ "play_cry", "NIDORINO", true },
{ "show_text", "_MrFujisHouseNidorinoText" },
},
},
+2 -3
View File
@@ -4,10 +4,9 @@
return {
ROUTE_16_FLY_HOUSE = {
talk = {
-- Route16FlyHouseFearowText: text_asm just prints the one line and
-- plays the FEAROW cry (no cry-playback command exists in this
-- port's Commands vocabulary, so only the text is ported).
-- scripts/Route16FlyHouse.asm:45-51
TEXT_ROUTE16FLYHOUSE_FEAROW = {
{ "play_cry", "FEAROW", true },
{ "show_text", "_Route16FlyHouseFearowText" },
},
},
+1
View File
@@ -1047,6 +1047,7 @@ local championsRoomRivalScript = {
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3
{ "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
+9 -5
View File
@@ -359,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = {
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
local t = text(game)
if game.save.flags.EVENT_GOT_HM02 then
push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText
or "HM02 is FLY!\fIt will whisk you\nback to any town!", done)
push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText
or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done)
return
end
push(game, t._Route16FlyHouseBrunetteGirlText
or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function()
or "Oh, you found my\nsecret retreat!", function()
if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then
push(game, "You don't have\nroom for HM02!", done)
push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText
or "You don't have any\nroom for this.", done)
return
end
game.save.flags.EVENT_GOT_HM02 = true
push(game, ("%s got\nHM02!"):format(game.save.player.name), done)
-- scripts/Route16FlyHouse.asm:32-35
push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text
or "{PLAYER} received\nHM02!", { player = game.save.player.name }),
done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item"))
end)
end,
},
+77
View File
@@ -200,6 +200,60 @@ the adapter's own coverage table:
python3 tools/modkit.py gen2check mods/my_mod
```
## Imported version datasets
A mod can inspect semantic content from another game the player has already
imported without switching the active game or reaching into engine cache
internals:
```lua
local gold, reason = mod.datasets:open("gold")
if not gold then
-- reason is "unknown_version", "not_imported", or "invalid_cache"
return
end
local chikorita = gold.content.pokemon:get("CHIKORITA")
local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST")
local spritePath = gold.assets:path(chikorita.spriteFront)
for id, record in gold.content.pokemon:each() do
-- ids are returned in deterministic lexical order
end
```
`view.version` and `view.generation` identify the selected dataset.
`view.content` exposes the same registry names, aliases, generation routing,
and data-only record shapes as `mod.content`, but only `get`, `has`, and
`each`. Returned records are detached copies and cannot mutate either dataset.
Every generated base record passes the selected generation's existing public
schema before it is returned; extractor metadata beside record maps stays out
of the registry id space and is reserved against `register`, `override`,
`patch`, and `remove` writes through the active registry. A malformed record
makes `get` return nil, `has`
return false, and `each` return no rows, and invalidates that dataset view.
Records containing functions, userdata, threads, metatables, or cycles are not
exposed. Each open call receives an independent facade, so one mod cannot
replace another mod view method. Canonical boot shaping is included, such as
Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and
derived `held_items`.
`open` checks the completion marker, exact version-specific file inventory,
source/cache boundary, and file-size bounds without reading or decoding the
semantic modules. A root is read, bounded-decoded, normalized, and cached only
when a content operation first needs it. Each later view operation rechecks
readiness and the source bytes behind already cached roots; unchanged roots are
not decoded again. Missing, partial, and stale imports return
`nil, "not_imported"`. Malformed syntax, a resource-limit violation, or a
record that fails the public schema is discovered on first root access and
fails that operation closed; a later `open` against the same source returns
`nil, "invalid_cache"`. Generated modules use a bounded literal-only grammar
and are never executed. No raw ROM bytes or generated source are exposed.
`view.assets:path(relative)` and
`view.assets:info(relative)` accept only `assets/generated/...` paths and
keep them under the selected version cache prefix. The API never changes
`mod.game`, the active `Data` table, `GameVersion`, or cache mount state.
## Editing maps in Tiled
Maps are data, not assets, so they can be authored in a real map editor and
@@ -451,6 +505,29 @@ Menu choices and moves use the same engine methods as the native controls;
their mutable logic. Tutorial, link, forced, stale, and covered battle states
refuse core intents. Use `mod.input` for ordinary text advance.
## Party-full custody at a catch
When a capture lands on a full party, the cart deposits the mon in storage
without a question. `catch.party_full` (RFC 0018) lets a mode stand in front
of `SendNewMonToBox` and take custody instead:
```lua
mod.hooks:wrap("catch.party_full", function(next, ctx)
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
-- game = <Game> }
if myMode.active then
takeCustody(ctx.mon) -- the mon is the mod's problem now
return true -- nothing is deposited
end
return next(ctx) -- false: deposit, as today
end)
```
A truthy return skips the box entirely -- the mon is neither in the party nor
in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can
find its own custody again. Anything falsy deposits as always, "But every BOX
is full!" included.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
+50 -1
View File
@@ -342,6 +342,7 @@ accepted and merged as-is.
| `source` | string |
| `swarmGrass` | map of string -> {map?, rates, slots} |
| `swarmWater` | map of string -> {map?, rate, slots} |
| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} |
| `treeSets` | map of string -> {common, rare} |
| `trees` | map of string -> string |
| `water` | map of string -> {map?, rate, slots} |
@@ -509,6 +510,26 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })
mod.content.items:patch("POTION", { price = 100 })
```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.items`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `ball` | balls id | no |
| `effect` | item_effects id | no |
| `id` | string | yes |
| `index` | integer 0..255 | no |
| `machine` | {kind, move, number} | no |
| `name` | string | yes |
| `needsTarget` | boolean | no |
| `price` | integer >= 0 | yes |
| `tossable` | boolean | no |
## landmarks
- semantics: `record`
@@ -598,7 +619,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")
| `id` | string | yes |
| `index` | integer >= 0 | no |
| `label` | string | no |
| `objects` | list of any value | no |
| `objects` | list of {pokemon?, ...} | no |
| `palette` | string | no |
| `signs` | list of any value | no |
| `tileset` | tilesets id | yes |
@@ -673,6 +694,34 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f
mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.moves`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `accuracy` | integer 0..100 | yes |
| `anim` | any value | no |
| `category` | one of "physical" | "special" | "status" | no |
| `chargeText` | string | no |
| `counterable` | boolean | no |
| `effect` | move_effects id | yes |
| `fixedDamage` | integer >= 1 | function | no |
| `highCrit` | boolean | no |
| `id` | string | yes |
| `index` | integer 0..255 | no |
| `multiHit` | integer >= 1 | list of integer >= 1 | no |
| `name` | string | yes |
| `power` | integer 0..255 | yes |
| `pp` | integer 0..64 | yes |
| `priority` | integer -7..7 | no |
| `semiInvulnerable` | boolean | no |
| `type` | type_chart id | yes |
## music
- semantics: `record`
+154
View File
@@ -0,0 +1,154 @@
# RFC 0015: Read-only imported dataset views
## Status
Proposed.
## Motivation
A cross-version content mod can read only the active merged dataset through
`mod.content`. Even when the player has already imported another supported
game, a mod cannot inspect that version's semantic species, moves, items, type
chart, or generated asset namespace. The available alternatives are private:
mutating `CacheFs.prefix`, mounting another cache over the active one, loading
generated Lua directly, or asking for a second raw-ROM import. They compose
poorly with the active game, expose unstable import layout, and make safe
read-only use impossible from the sandbox.
The concrete consumer is **Adaptive Trainers**, whose approved Phase G Kanto+
sidecar must derive nine Kanto-line continuations, Steel/type and move
definitions, and generated sprites from the player's existing verified Gold
cache while Red, Blue, or Yellow remains active. `mod.content` exposes only the
active R/B/Y dataset; `mod.imports` can read only separately declared raw mod
imports; and `mod.cache` is mod-private generated output. None can inspect the
launcher-owned Gold semantic dataset without a second ROM import and mod-side
ROM interpretation. The engine API remains generic and contains no Adaptive
Trainers policy.
## Decision and plan extended
This implements **D-AT-004: optional Kanto+ content consumes an
active-independent semantic dataset view**. 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 8. The delta is a generic, additive public API and contains no trainer
pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers
policy.
## Exact API delta
Every sandboxed mod receives:
```lua
local view, reason = mod.datasets:open("gold")
```
A known version with the current completed import marker returns a read-only view:
```lua
view = {
version = "gold",
generation = 2,
content = {
pokemon = {
get = function(self, id) end,
has = function(self, id) end,
each = function(self) end,
},
-- every public registry name and alias
},
assets = {
path = function(self, generatedPath) end,
info = function(self, generatedPath) end,
},
}
```
`get` returns a bounded detached data-only copy or nil. `has` reports data-only
semantic presence.
`each` returns ids in lexical order and detached values. The registries use
the selected version's generation routing and the engine's existing
`Schemas`, `Registry`, and `Builtins` normalization, so structured sources
such as type matchups retain the same public ids used by the active
`mod.content` facade and extractor metadata beside record maps is not exposed
as a record id or writable through the active registry's mutation verbs. Every
generated base record is checked with that selected generation's existing
public schema before it can cross `get`, `has`, or
`each`; there is no dataset-specific duplicate schema. No register, patch,
override, or remove verb is exposed. Each call returns an independent facade
over the cached internal dataset, so facade mutation cannot cross mod
boundaries.
`assets:path` returns the selected cache-prefixed virtual path.
`assets:info` returns sanitized `type` and optional `size` metadata. Both
accept only relative paths below `assets/generated/`, reject control
characters, absolute paths, backslashes, and traversal, and expose no byte
reader.
An unknown version returns `nil, "unknown_version"`. `open` checks the current
completion marker, exact version-specific file inventory, source/cache
boundary, and available file sizes; it does not read or decode semantic
modules. A missing, partial, or stale cache returns `nil, "not_imported"`.
The first content operation that needs a root reads it once, applies the
limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 values, 2 MiB
per string, and 250,000 entries per table), decodes the restricted literal
grammar, applies canonical selected-version normalization, and caches the
detached root. Each later content or asset operation rechecks marker/file and
source-bound readiness. It also rereads the source bytes for already cached
roots; unchanged roots are not decoded again, while a changed root clears the
derived registry cache and is decoded on its next use.
Malformed syntax, non-table roots, binary/trailing content, resource-limit
violations, and records that fail the public schema are therefore discovered
on first access rather than during `open`. The triggering `get` returns nil,
`has` returns false, or `each` returns no rows; the whole internal view is
invalidated, and a subsequent `open` against the unchanged source returns
`nil, "invalid_cache"`. Actionable detail is engine-logged but not exposed to
the mod. Generated Lua is never executed. Functions, userdata, threads,
metatables, and cycles cannot cross the facade. The API never exposes raw ROM
bytes, generated source, host paths, or a mount.
## Migration and compatibility
Existing mods change nothing. `mod.datasets` is additive and requires no
permission. The service is allocated lazily on the first explicit
`mod.datasets:open` call. A boot with no mods, or with mods that do not call
it, performs no cross-version cache reads.
Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active
`Data` table, PhysFS mounts, save state, or the selected game's behavior.
Red, Blue, Yellow, Gold, Silver, and Crystal keep their existing active data paths.
The completion marker and per-version required-file rules live in the pure,
injected `CacheContract` shared by the importer and dataset service. It also
defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while
checking readiness. Every `open` revalidates the contract and required module
inventory without semantic decoding. Every view operation rechecks that
readiness before serving cached state; a stale/remove/reimport transition
evicts the previous semantic view.
## Verification
- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through
the public API and covers Red, Blue, Yellow, Gold, Silver, and Crystal independently.
- The test proves semantic registry normalization, deterministic iteration,
detached records, read-only facades, cross-mod facade isolation,
version-prefixed generated assets,
traversal rejection, stable failure reasons, and stale-marker rejection.
- It also proves missing/empty/partial/stale/remove/reimport behavior, hostile
generated-source and malformed-record rejection, canonical Gen
1/Yellow/Gold hydration, active Red/Blue/Yellow isolation while reading Gold,
and the approved Kanto+ Gold records and assets.
- `tests/engine/dataset_views_lazy_validation.lua` counts semantic reads and
decoder calls to prove `open` decodes nothing, unused roots stay unread, and
repeated access does not re-decode an unchanged cached root.
- `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code
is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua`
proves every decoder resource bound.
- `tests/engine/dataset_views_no_mod_parity.lua` is the separate guarded no-mod
parity suite and proves the service stays unallocated with zero cache reads.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.
+113
View File
@@ -0,0 +1,113 @@
# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold
## Status
Proposed.
## Motivation
When a capture lands and the party already holds six Pokémon, the cart does
not ask the player anything: `AddPartyMon` fails and the mon goes to
`SendNewMonToBox` without a stop. The engine mirrors that —
`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game
mode has no way to stand in front of it.
That silence is wrong for any mode where the box is not a thing. A battle
royale locks the Pokémon Center PC for the whole match (the box is a second
health bar: deposit the healthy ones, fight with one, withdraw fresh ones),
so a seventh catch is deposited into storage the player cannot reach — the
mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon
as lost, a randomizer that wants to hand out a replacement instead, a
challenge run that makes the player release someone for the catch — all want
the decision, and today there is no seam to catch it at.
The immediate consumer is a battle-royale mode, where the fix is the game's
own rule: at 6/6 you choose who makes room, and whoever leaves hits the
ground as a ball. Neither the decision nor the spill is specific to it.
## The decision it extends
This extends the **additive, guarded seam convention** Route B in
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
`tests/engine/gate_meta_coverage.lua` enforces. It sits next to
`catch.nickname` (RFC 0015) on the same capture path: that hook answers
*what the mon is called* once its home is decided; this one decides the home
when the party cannot hold it.
There is no in-repo D-number registry to amend.
## Exact API delta
### New hook: `catch.party_full`
```lua
mod.hooks:wrap("catch.party_full", function(next, ctx)
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
-- game = <Game> }
if myMode.active then
takeCustody(ctx.mon) -- the mon is the mod's problem now
return true -- nothing is deposited
end
return next(ctx) -- false: deposit, as today
end)
```
Call site: `BattleState:partyFullDestination(mon)`, called from the capture
path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run.
The vanilla link returns `false`. A truthy return skips the box entirely —
the mon is neither in the party nor in any box, and `pokemon.caught` reports
`destination = "mod"` so the mode can find its own custody again. Anything
falsy deposits as always, "But every BOX is full!" included.
The method returns `"box"` or `"mod"`, and is a *method* on purpose: the
compatibility seam for engines that predate this RFC needs a name to ask for
(see below), and `battleStyle`/`offerNickname` set the precedent.
### One event value, already emitted
`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to
`"party"` and `"box"`. Nothing that reads the event today matches on it.
### No other surface changes
The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a
file-local, so a build with nothing wrapped runs the branch exactly as
before and allocates nothing it did not allocate before.
## Migration
Nothing changes for existing mods. A mode that was fighting the box after
the fact — withdrawing the deposit it could not prevent, or eating the loss —
should wrap `catch.party_full` and take the mon at the moment of the catch.
## Verification
- `tests/modkit/cases/catch_party_full.lua` — through the public mod API:
with no mod a 6/6 catch lands in the box with the transfer text; a wrapped
mod that claims custody leaves the mon out of both party and boxes; a
fall-through deposits; the hook's ctx carries the battle, the mon, the
display name and the game.
- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes
the no-mod parity gate (vanilla called exactly once, result unchanged,
nothing allocated).
- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit
corpus.
## Backward compatibility
Additive. No existing hook, event, registry or manifest field changes shape.
`storeCaughtMon`'s box branch keeps its text, its nickname offer and its
`pokemon.caught` emit; the only reader-visible difference with no subscriber
is one extra method on `BattleState`.
## Compatibility seam for older engines
The call site is mid-function, so a mod on a stock engine cannot see it —
the same problem `world.talk` has. The battle-royale mod's shim covers the
gap by wrapping `Boxes.deposit` (the one call the old branch makes that a
patch can reach): during a match it raises `catch.party_full` first, and a
claim refuses the deposit, so nothing reaches a box even there. The mod then
takes custody from the `pokemon.caught` emit, which carries the mon. What
the shim cannot repair is the text: the old branch answers a refused deposit
with "But every BOX is full!" before the mode's own prompt opens — the wrong
reason for the right decision, and the argument for the seam.
+50 -1
View File
@@ -220,6 +220,54 @@ output directory; nothing packages it next to a shipped game yet.
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this.
**Statically linked bridges.** On iOS there is no loadable library at all:
the bridge is linked straight into the app binary, so its symbols live in the
main image and are reachable only through `ffi.C`. The candidate list is empty
on iOS and, on every platform, a failed candidate walk falls back to probing
`ffi.C` for `librashader_translate_preset` after the `ffi.cdef`. If the symbol
is there, `ffi.C` becomes the library handle and `ShaderFX.translate` uses it
unchanged. If it is not, `ShaderFX.bridgeError()` reports that `ffi.C` was
probed as well, followed by the file paths that were tried (omitted on iOS,
where none are).
**Android ships the bridge inside the APK.** `scripts/build_android.sh`
stages `liblibrashader_bridge.so` into
`mobile/android/app/src/main/jniLibs/<abi>/` for the two ABIs the APK ships,
arm64-v8a and armeabi-v7a, so `ffi.load("liblibrashader_bridge.so")` finds it
by bare name in the app's native library directory. The build cross-compiles
the crate with `cargo ndk` against the same NDK the gradle project uses
(25.2.9519653), which needs `cargo install cargo-ndk` and
`rustup target add aarch64-linux-android armv7-linux-androideabi`; the script
names the exact command for any target that is missing. Set
`SHADERFX_BRIDGE_ANDROID_DIR` to a directory holding
`<abi>/liblibrashader_bridge.so` to bundle prebuilt libraries instead. As on
desktop, the bridge is only needed to CONVERT a preset: a build without it
still runs presets converted elsewhere, and the packager warns and continues
rather than failing.
**iOS links the bridge instead of loading it.** An iOS app cannot `dlopen` a
dylib shipped beside its binary, so `tools/shaderfx-bridge` also builds as a
`staticlib` and `scripts/build_ios.sh` links it into the app executable
(`bundle_shader_bridge_ios`, mirroring `bundle_shader_bridge` in
`scripts/build.sh`). `SHADERFX_BRIDGE_IOS` points at a prebuilt archive;
otherwise cargo builds `aarch64-apple-ios` for device builds and every
installed simulator target (`aarch64-apple-ios-sim`, `x86_64-apple-ios`) for
the Simulator, with `IPHONEOS_DEPLOYMENT_TARGET=15.0`, and `ARCHS` is pinned to
what got built. The archive is never linked directly: LÖVE 12 carries its own
glslang for shader validation and the crate drags in a second, incompatible
one, and linking both crashed inside `Shader::validateInternal` at startup.
Each slice is therefore prelinked with `ld -r -all_load
-exported_symbols_list` so only `_librashader_translate_preset` and
`_librashader_free_string` stay external and every other symbol (glslang,
spirv-cross, Rust std) becomes private to the object, then `rust-objcopy`
drops the `__LLVM` bitcode sections rustup's prebuilt std carries, since
Apple's `nm` cannot read them. `OTHER_LDFLAGS` adds `-Wl,-u` on both entry
points to keep them past dead-stripping and `-lc++` for the C++ the crate
needs; no Objective-C framework is involved. A build that linked the object
fails if `nm` cannot find `_librashader_translate_preset` in the finished
binary; a build that could not produce one only warns and lands where the
desktop packages without cargo land: converted presets run, CONVERT does not.
### Fixup
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
@@ -656,7 +704,8 @@ None of these are theoretical.
`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`.
so rather than failing. `scripts/build_android.sh` and `scripts/build_ios.sh`
follow the same rule with `cargo ndk` and the iOS static archive.
- **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
+42 -7
View File
@@ -384,7 +384,14 @@ function bootGame(version, cartId)
Game = require("src.core.Game2").new()
Game:load()
else
Game = require("src.core.Game")
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
-- place; if a prior teardown left load missing, rebuild from source.
local gameMod = require("src.core.Game")
if type(gameMod.load) ~= "function" then
package.loaded["src.core.Game"] = nil
gameMod = require("src.core.Game")
end
Game = gameMod
Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then
autopilot = require("tests.autopilot")
@@ -1170,6 +1177,17 @@ function love.run()
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
local idleFor = 0
local SLEEP_FLOOR = 0.001
local WAKE = {
keypressed = true, keyreleased = true, textinput = true,
mousepressed = true, mousereleased = true, mousemoved = true,
wheelmoved = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickpressed = true, joystickreleased = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
joystickadded = true, joystickremoved = true, filedropped = true,
directorydropped = true, focus = true, visible = true, resize = true,
}
return function()
-- process events
@@ -1188,17 +1206,34 @@ function love.run()
return a or 0
end
end
if WAKE[name] then
idleFor = 0
elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then
idleFor = 0
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
idleFor = idleFor + dt
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
local visible = not (love.window and love.window.isVisible)
or love.window.isVisible()
local focused = not (love.window and love.window.hasFocus)
or love.window.hasFocus()
local cap = FrameCap.current
if not visible then
cap = 10
elseif Importer and (not focused or idleFor > 30) then
cap = 15
end
if visible and love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
@@ -1209,9 +1244,9 @@ function love.run()
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
-- responsive. The pacer yields to vsync inside a 1ms dead band, so
-- when the panel already paces at or below the cap it is a no-op.
local budget = 1 / cap
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
@@ -1222,8 +1257,8 @@ function love.run()
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
if remaining <= SLEEP_FLOOR then break end
love.timer.sleep(0.001)
end
else
love.timer.sleep(0.001)
+5
View File
@@ -100,6 +100,11 @@ love-android 11.5a expects:
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`.
**ShaderFX bridge**: `scripts/build_android.sh` bundles
`liblibrashader_bridge.so` for arm64-v8a and armeabi-v7a via `cargo ndk` (or
from `SHADERFX_BRIDGE_ANDROID_DIR`), and warns and continues when neither is
available; see `docs/shaderfx.md`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`.
+1
View File
@@ -52,6 +52,7 @@ The script verifies the final app before packaging it:
- the public Documents plist settings are present
- the native picker bridge is present
- the SHADER FX librashader bridge is linked into the app binary, when this build produced one
- `game.love` exists and is non-empty
If the payload is missing, the build fails instead of producing a blank app.
+110
View File
@@ -461,6 +461,115 @@ pack_game_love() {
fi
}
# --------------------------------------------------------------- ShaderFX bridge
SHADER_BRIDGE_LIB="liblibrashader_bridge.so"
SHADER_BRIDGE_ABIS="arm64-v8a armeabi-v7a"
shader_bridge_rust_target() {
case "$1" in
arm64-v8a) printf 'aarch64-linux-android' ;;
armeabi-v7a) printf 'armv7-linux-androideabi' ;;
x86_64) printf 'x86_64-linux-android' ;;
x86) printf 'i686-linux-android' ;;
*) printf '' ;;
esac
}
shader_bridge_staged_count() {
local jni="$1" abi count=0
for abi in $SHADER_BRIDGE_ABIS; do
[ -f "$jni/$abi/$SHADER_BRIDGE_LIB" ] && count=$((count + 1))
done
printf '%s' "$count"
}
bundle_shader_bridge_android() {
local jni="$ANDROID_DIR/app/src/main/jniLibs"
local crate="$ROOT/tools/shaderfx-bridge"
local abi target
for abi in $SHADER_BRIDGE_ABIS; do
rm -f "$jni/$abi/$SHADER_BRIDGE_LIB"
done
local prebuilt="${SHADERFX_BRIDGE_ANDROID_DIR:-}"
if [ -n "$prebuilt" ]; then
for abi in $SHADER_BRIDGE_ABIS; do
if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then
mkdir -p "$jni/$abi"
cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB"
else
warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB"
fi
done
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)"
return
fi
fi
if [ ! -f "$crate/Cargo.toml" ]; then
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)"
return
fi
if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')"
return
fi
local ndk="${ANDROID_NDK_HOME:-}"
if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then
ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION"
fi
if [ ! -d "$ndk" ]; then
warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)"
return
fi
local installed missing="" buildable=""
installed="$(rustup target list --installed 2>/dev/null || true)"
for abi in $SHADER_BRIDGE_ABIS; do
target="$(shader_bridge_rust_target "$abi")"
if grep -qx "$target" <<< "$installed"; then
buildable="$buildable $abi"
else
missing="$missing $target"
fi
done
if [ -n "$missing" ]; then
warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing"
fi
if [ -z "$buildable" ]; then
warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)"
return
fi
local args=()
for abi in $buildable; do
args+=(-t "$abi")
done
say "building the ShaderFX bridge with cargo-ndk (${buildable# })"
mkdir -p "$jni"
if ! (
cd "$crate"
export ANDROID_NDK_HOME="$ndk"
export ANDROID_NDK_ROOT="$ndk"
export CARGO_PROFILE_RELEASE_STRIP="symbols"
cargo ndk "${args[@]}" -o "$jni" build --release
); then
warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones"
return
fi
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion"
else
warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones"
fi
}
# --------------------------------------------------------------- SDK check
require_android_sdk() {
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
@@ -575,5 +684,6 @@ if $PACKAGE_ONLY; then
fi
require_android_sdk
bundle_shader_bridge_android
run_gradle
say "done"
+149 -3
View File
@@ -615,6 +615,138 @@ verify_game_payload() {
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
}
SHADER_BRIDGE_LIB=""
SHADER_BRIDGE_NAME="liblibrashader_bridge.a"
SHADER_BRIDGE_OBJ="librashader_bridge.o"
SHADER_BRIDGE_ARCHS=""
rust_targets_for_sdk() {
if [ "$1" = "iphoneos" ]; then
printf 'aarch64-apple-ios'
else
printf 'aarch64-apple-ios-sim x86_64-apple-ios'
fi
}
build_shader_bridge_slice() {
local rust_target="$1"
local crate="$ROOT/tools/shaderfx-bridge"
local built="$crate/target/$rust_target/release/$SHADER_BRIDGE_NAME"
if [ -f "$built" ]; then
printf '%s' "$built"
return 0
fi
if command -v cargo >/dev/null 2>&1 \
&& rustup target list --installed 2>/dev/null \
| grep -x "$rust_target" >/dev/null; then
say "building the ShaderFX bridge with cargo ($rust_target)" >&2
if (cd "$crate" && IPHONEOS_DEPLOYMENT_TARGET=15.0 \
cargo build --release --target "$rust_target" >/dev/null 2>&1); then
printf '%s' "$built"
return 0
fi
fi
return 1
}
prelink_shader_bridge_slice() {
local archive="$1" sdk="$2" out="$3"
local arch platform sdk_version syms objcopy tmp
arch="$(lipo -archs "$archive" 2>/dev/null | awk '{print $1}')"
[ -n "$arch" ] || return 1
if [ "$sdk" = "iphoneos" ]; then platform="ios"; else platform="ios-simulator"; fi
sdk_version="$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null)"
[ -n "$sdk_version" ] || return 1
syms="$LIBS_DIR/librashader_bridge.exports"
printf '_librashader_translate_preset\n_librashader_free_string\n' > "$syms"
tmp="$out.tmp"
xcrun ld -r -arch "$arch" -platform_version "$platform" 15.0 "$sdk_version" \
-all_load -exported_symbols_list "$syms" -o "$tmp" "$archive" || return 1
objcopy="$(ls "$(rustc --print sysroot 2>/dev/null)"/lib/rustlib/*/bin/rust-objcopy 2>/dev/null | head -1)"
if [ -n "$objcopy" ] && "$objcopy" --remove-section __LLVM,__bitcode \
--remove-section __LLVM,__cmdline "$tmp" "$out" 2>/dev/null; then
rm -f "$tmp"
else
mv "$tmp" "$out"
fi
}
bundle_shader_bridge_ios() {
local rust_targets="$1" sdk="$2"
local src="${SHADERFX_BRIDGE_IOS:-}"
local slices=() objs=() target slice obj i
SHADER_BRIDGE_LIB=""
SHADER_BRIDGE_ARCHS=""
rm -f "$LIBS_DIR/$SHADER_BRIDGE_NAME" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" "$LIBS_DIR"/librashader_bridge.*.o
if [ -n "$src" ]; then
if [ -f "$src" ]; then
slices+=("$src")
else
warn "SHADERFX_BRIDGE_IOS=$src does not exist"
fi
else
for target in $rust_targets; do
if slice="$(build_shader_bridge_slice "$target")"; then
slices+=("$slice")
fi
done
fi
if [ "${#slices[@]}" -gt 0 ]; then
mkdir -p "$LIBS_DIR"
i=0
for slice in "${slices[@]}"; do
i=$((i + 1))
obj="$LIBS_DIR/librashader_bridge.$i.o"
if prelink_shader_bridge_slice "$slice" "$sdk" "$obj"; then
objs+=("$obj")
else
warn "could not prelink $(basename "$slice") for $sdk"
fi
done
fi
if [ "${#objs[@]}" -eq 1 ]; then
mv "${objs[0]}" "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
elif [ "${#objs[@]}" -gt 1 ]; then
lipo -create "${objs[@]}" -output "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
rm -f "${objs[@]}"
fi
if [ -f "$LIBS_DIR/$SHADER_BRIDGE_OBJ" ]; then
SHADER_BRIDGE_LIB="$LIBS_DIR/$SHADER_BRIDGE_OBJ"
SHADER_BRIDGE_ARCHS="$(lipo -archs "$SHADER_BRIDGE_LIB" 2>/dev/null || true)"
say "linking $SHADER_BRIDGE_OBJ for SHADER FX preset conversion (${SHADER_BRIDGE_ARCHS:-unknown arch})"
else
warn "$SHADER_BRIDGE_NAME not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_IOS, or install cargo plus one of: rustup target add $rust_targets)"
fi
}
verify_shader_bridge() {
local app="$1"
local exe bin
if [ -z "$SHADER_BRIDGE_LIB" ]; then
warn "no SHADER FX bridge in this build: CONVERT stays unavailable, converted presets still run"
return 0
fi
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping SHADER FX bridge check"
return 0
fi
if nm "$bin" 2>/dev/null | grep -E ' _librashader_translate_preset$' >/dev/null \
|| xcrun dyld_info -exports "$bin" 2>/dev/null \
| grep -E ' _librashader_translate_preset$' >/dev/null; then
say "SHADER FX bridge present (librashader_translate_preset)"
return 0
fi
fail "built app does not carry the SHADER FX bridge symbols.
$SHADER_BRIDGE_OBJ was linked but librashader_translate_preset is absent,
so SHADER FX CONVERT would fail at runtime.
Rebuild after: rm -rf tools/shaderfx-bridge/target"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -633,6 +765,8 @@ run_xcodebuild() {
mkdir -p "$BUILD_DIR"
bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk"
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
@@ -661,6 +795,13 @@ run_xcodebuild() {
ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
if [ -n "$SHADER_BRIDGE_LIB" ]; then
args+=(OTHER_LDFLAGS="-Wl,-u,_librashader_translate_preset -Wl,-u,_librashader_free_string \"$SHADER_BRIDGE_LIB\" -lc++")
if [ -n "$SHADER_BRIDGE_ARCHS" ]; then
args+=(ARCHS="$SHADER_BRIDGE_ARCHS")
fi
fi
if ! $DEVICE; then
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
# build would drop the entitlements file, and HealthKit refuses to run
@@ -723,11 +864,14 @@ run_xcodebuild() {
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
local app=""
local candidate
local candidate newest=0 mtime
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
if [ -d "$candidate" ]; then
app="$candidate"
break
mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)"
if [ "$mtime" -gt "$newest" ]; then
newest="$mtime"
app="$candidate"
fi
fi
done
if [ -z "$app" ]; then
@@ -736,6 +880,7 @@ run_xcodebuild() {
return 0
fi
if [ "$app" != "$products/$APP_NAME.app" ]; then
rm -rf "$products/$APP_NAME.app"
mv "$app" "$products/$APP_NAME.app"
app="$products/$APP_NAME.app"
fi
@@ -754,6 +899,7 @@ run_xcodebuild() {
verify_game_payload "$app"
verify_native_bridge "$app"
verify_shader_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
+108 -41
View File
@@ -97,7 +97,7 @@ end
-- space.
function BattleState:extendedHUD()
local options = self.game and self.game.save and self.game.save.options
local bg = options and options.battleBg
local bg = options and self:bgMode()
return self:wideLayout()
and options and options.battleHud == "extended"
and ((options.battleFit == "fixed"
@@ -108,12 +108,12 @@ end
function BattleState:extendedWorldHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options
and options.battleFit == "fixed" and options.battleBg == "world"
and options.battleFit == "fixed" and self:bgMode() == "world"
end
function BattleState:extendedBlackHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options and options.battleBg == "black"
return self:extendedHUD() and options and self:bgMode() == "black"
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
@@ -130,6 +130,10 @@ end
-- opaque 160x144 field over the top, so only the surround changes.
function BattleState:bgMode()
local options = self.game and self.game.save and self.game.save.options
if options and self:wideLayout() and options.battleFit == "fill"
and options.battleHud == "extended" then
return "white"
end
local mode = options and options.battleBg
if mode == "black" or mode == "world" then return mode end
return "white"
@@ -212,6 +216,13 @@ local BALL_ANIMS = {
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
}
-- engine/battle/effects.asm:552
local ENEMY_STAT_DOWN_MISS = {
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
local imageCache = {}
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
-- running battle holds the pics it built at enter() (battler.sprite,
@@ -229,6 +240,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = setmetatable({}, WEAK_KEYS)
local function mattedPic(path)
return path:sub(1, 17) == "assets/generated/"
or path:sub(1, 17) == "save/mod-derived/"
end
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
-- entirely, so its palette variant collapses back onto the plain path.
@@ -254,29 +270,31 @@ local function getImage(path, pal, trueColor)
end)
end
local w, h = id:getDimensions()
local bottom = h - 1
while bottom >= 0 do
local opaque = false
for x = 0, w - 1 do
local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
if mattedPic(Assets.resolve(path)) then
local bottom = h - 1
while bottom >= 0 do
local opaque = false
for x = 0, w - 1 do
local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
end
if opaque then break end
bottom = bottom - 1
end
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
if opaque then break end
left = left + 1
pad = h - 1 - bottom
padL = left
end
img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else
img = Assets.image(path) -- headless stub: no pixel access
end
@@ -2214,6 +2232,9 @@ function BattleState:update(dt)
self.waitFrames = nil
if destination == "menu" then
self.introSlide = nil
-- engine/battle/core.asm:2007
self.msgHold = nil
self.shown = nil
self.phase = "menu"
elseif destination == "finish" then
self:finish()
@@ -3041,18 +3062,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1)
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB)
local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4
local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9
-- engine/battle/animations.asm:1090
local function onSgb()
local m = require("src.render.PaletteFX").mode
return m == "gbc" or m == "gbc_inv"
end
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
-- (FlashScreenLongDelay)
local FLASH_LONG_MAPS = {
local FLASH_LONG_SGB = {
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
}
-- engine/battle/animations.asm:992
local FLASH_LONG_MONO = {
{ [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
}
-- the shade map in force this frame (a running flash wins over the
-- persistent palette)
function BattleState:activeBgp()
@@ -3186,7 +3222,7 @@ function BattleState:applyAnimEffect(ev)
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
fx.bgp = BGP_LIGHT
elseif e == "SE_DARKEN_MON_PALETTE" then
fx.bgp = BGP_DARKEN
fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO
elseif e == "SE_RESET_SCREEN_PALETTE" then
fx.bgp = nil
elseif e == "SE_DARK_SCREEN_FLASH" then
@@ -3196,8 +3232,9 @@ function BattleState:applyAnimEffect(ev)
idx = 1, left = 2 }
elseif e == "SE_FLASH_SCREEN_LONG" then
local steps = {}
local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO
for cycle = 1, 3 do
for _, m in ipairs(FLASH_LONG_MAPS) do
for _, m in ipairs(maps) do
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
end
end
@@ -4139,6 +4176,12 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- pure status moves
if move.power == 0 and record and record.kind == "primary" and record.run then
if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer
and self.kind ~= "link" and self.rng(0, 255) < 64 then
self:cancelMoveAnim()
self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
return
end
-- accuracy-checked status effects run MoveHitTest, which has no
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
-- roll) and misses outright against a mid-Fly/Dig target; the
@@ -4417,7 +4460,11 @@ function BattleState:awardExp()
-- new current HP (house convention: potions drain the bar too, see
-- itemUsed) so the bar grows instead. Only the active player battler
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
if mon == self.player.mon then self:drainNext() end
if mon == self.player.mon then
-- engine/battle/experience.asm:236
self.player.badgeExtraBoosts = nil
self:drainNext()
end
for _, moveId in ipairs(Experience.movesLearnedAt(
self.data.pokemon[mon.species], lv)) do
self:learnMove(mon, moveId)
@@ -5086,6 +5133,22 @@ end
-- "New POKéDEX data will be added" + the dex entry page, then
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full.
-- Where a caught mon goes when the party has no room for it (RFC 0018):
-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or
-- "mod" when the catch.party_full hook claims it -- a game mode that has
-- done away with storage hands the decision to the player instead of
-- laundering the catch through a PC it has locked. A method rather than
-- an inline read, so a mod or a compatibility shim can tell a seam engine
-- from a stock one by name.
function BattleState:partyFullDestination(mon)
if not Runtime.wantsHook("catch.party_full") then return "box" end
local claimed = Runtime.call("catch.party_full", function() return false end,
{ battle = self, mon = mon, name = self.enemy and self.enemy.name,
game = self.game })
if claimed then return "mod" end
return "box"
end
function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the
@@ -5126,19 +5189,21 @@ function BattleState:storeCaughtMon()
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
self:sayNext(self:romText(
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
metBill and "%s was\ntransferred to\nBILL's PC!"
or "%s was\ntransferred to\nsomeone's PC!",
self.enemy.name))
else
self:sayNext(Strings("But every BOX\nis full!"))
destination = self:partyFullDestination(self.enemy.mon)
if destination == "box" then
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
self:sayNext(self:romText(
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
metBill and "%s was\ntransferred to\nBILL's PC!"
or "%s was\ntransferred to\nsomeone's PC!",
self.enemy.name))
else
self:sayNext(Strings("But every BOX\nis full!"))
end
end
end
Runtime.emit("pokemon.caught", {
@@ -5920,7 +5985,9 @@ function BattleState:animSpriteColors(s, px, py)
local P
-- engine/battle/animations.asm:551 (.notSGB)
if PaletteFX.usesSpriteObp() then
P = PaletteFX.ogObj()
-- engine/battle/init_battle_variables.asm:18
P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE
or PaletteFX.GBC_OBJ
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
+30 -8
View File
@@ -41,6 +41,34 @@ local function badgeBoost(battler, stat)
return nil
end
-- engine/battle/core.asm:6454
function Damage.applyBadgeBoost(battler, stat, value)
if battler.hazeStatReset then return value end
local row = badgeBoost(battler, stat)
if not row then return value end
local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0
for _ = 1, 1 + extra do
value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8)))
end
return value
end
-- engine/battle/effects.asm:498,689
function Damage.reapplyBadgeBoosts(battler, changedStat)
if not battler or not battler.badges then return end
local extra = battler.badgeExtraBoosts
if not extra then extra = {} battler.badgeExtraBoosts = extra end
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if battler.badges[row.badge] then
if row.stat == changedStat then
extra[row.stat] = 0
else
extra[row.stat] = (extra[row.stat] or 0) + 1
end
end
end
end
-- the merged status record for a battler's persistent condition, or nil
local function statusRecord(battler)
return Status.recordFor(battler.statuses, battler.mon.status)
@@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
-- Volcano -> special
local atkBoost = badgeBoost(attacker, atkStat)
if atkBoost then
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
end
local defBoost = badgeBoost(defender, defStat)
if defBoost then
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
end
atk = Damage.applyBadgeBoost(attacker, atkStat, atk)
dfn = Damage.applyBadgeBoost(defender, defStat, dfn)
-- burn halves physical attack (applied as part of the stat in Gen 1;
-- the status record's statPenalty names the stat it cuts).
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
+11
View File
@@ -11,6 +11,7 @@
-- is the registry view of all three -- the merged Data.move_effects a
-- battle dispatches on serves these same objects.
local Damage = require("src.battle.Damage")
local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
@@ -57,6 +58,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run,
-- re-baking the burn/para penalty and ending Haze's temporary lift.
who.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" and who == battle.player then
Damage.reapplyBadgeBoosts(who, stat)
end
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
@@ -132,6 +137,11 @@ end
local function statDownSide(stat)
return function(battle, user, target)
-- engine/battle/effects.asm:552
if not user.isPlayer and battle.kind ~= "link"
and battle.rng(0, 255) < 64 then
return {}
end
if target.substituteHP then return {} end
if battle.rng(0, 255) >= 85 then return {} end -- 33 percent + 1 (85/256)
-- StatModifierDownEffect's side-effect branch never runs MoveHitTest,
@@ -254,6 +264,7 @@ MoveEffects.primary = {
-- Attack-halving and paralysis Speed-quartering on BOTH battlers
-- until the next stat recompute (a stage change or switch-in).
b.hazeStatReset = true
b.badgeExtraBoosts = nil
end
-- Gen 1 also removes the enemy's major status; if that cured sleep
-- or freeze, the target forfeits its move this turn (haze.asm
+1 -9
View File
@@ -14,15 +14,7 @@ local function effectiveSpeed(battler)
battler.stages and battler.stages.speed or 0)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback
local badges = battler.badges
if badges then
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == "speed" and badges[row.badge] then
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
break
end
end
end
spd = Damage.applyBadgeBoost(battler, "speed", spd)
-- paralysis quarters speed (the status record's statPenalty);
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
-- copied the unmodified speed over the quartered battle stat, lifting
+14 -1
View File
@@ -97,6 +97,19 @@ local function battleIsTopState(battle)
return not (stack and stack.top) or stack:top() == battle
end
-- engine/menus/party_menu.asm:4
local function coveredByOpaqueState(battle)
local stack = battle.game and battle.game.stack
local states = stack and stack.states
if not states then return false end
local above = false
for i = 1, #states do
if above and states[i] and states[i].isOpaque then return true end
if states[i] == battle then above = true end
end
return false
end
local function anchorHUD(battle, x, y, w, h, anchor)
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
local renderer = battle.game and battle.game.renderer
@@ -351,7 +364,7 @@ function WideBattle.draw(battle)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
if battle.blankForAskName or coveredByOpaqueState(battle) then return end
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
+64 -7
View File
@@ -645,6 +645,7 @@ function Battle:smartAiState()
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
playerLockOn = playerState.lockOn or nil,
playerIdentified = playerState.identified or nil,
playerPhysicalMoves = physical,
enemyRage = enemyState.rage,
enemyRageCount = enemyState.rageCount,
@@ -1082,7 +1083,7 @@ function Battle:hitOnce(attacker, defender, def, opts)
local attackerStages = self.stages[self:sideOf(attacker)]
local defenderStages = self.stages[self:sideOf(defender)]
local types = self.data.type_chart and self.data.type_chart.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
local matchups = self:matchupsAgainst(defender)
local heldEffect, heldParam = self:heldEffect(attacker, "damage")
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
@@ -1202,6 +1203,8 @@ function Battle:hitOnce(attacker, defender, def, opts)
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
damage = math.max(0, (defender.hp or 0) - 1)
end
-- engine/battle_anims/anim_commands.asm:1200
if self.moveEvent then self.moveEvent.effectiveness = info.effectiveness end
return self:dealDamage(attacker, defender, damage, {
critical = critical, effectiveness = info.effectiveness,
-- Counter answers physical damage and Mirror Coat special, so what kind
@@ -1690,6 +1693,13 @@ function Battle:useMove(attacker, defender, moveId)
text = ("Magnitude %d!"):format(number) })
end
-- data/moves/effects.asm:1607, :1649
if def.effect == "EFFECT_RETURN" then
powerOverride = Effects.happinessPower(attacker.happiness)
elseif def.effect == "EFFECT_FRUSTRATION" then
powerOverride = Effects.happinessPower(attacker.happiness, true)
end
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
@@ -1744,7 +1754,7 @@ function Battle:useMove(attacker, defender, moveId)
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
local matchups = self:matchupsAgainst(defender)
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
@@ -2119,6 +2129,21 @@ Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender)
text = self:monName(attacker) .. " took aim!" })
end
-- engine/battle/move_effects/foresight.asm
Battle.MOVE_EFFECTS.EFFECT_FORESIGHT = function(self, attacker, defender,
def, _, sureHit)
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
return fail(self)
end
local target = self:volatile(defender)
if target.vanished or target.identified then return fail(self) end
target.identified = true
self:emit({ kind = "message",
text = self:monName(attacker) .. " identified "
.. self:monName(defender) .. "!" })
end
-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next
-- move aimed at the mon carrying it, whether or not that move was the one the
-- lock-on was meant for, and whether or not the exception at :1683-1688 then
@@ -2166,9 +2191,15 @@ function Battle:accuracyRoll(def, attacker, defender, accuracy)
end
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
return Damage.rollHit(self:moveAccuracy(accuracy, defender),
self.stages[self:sideOf(attacker)].accuracy,
self.stages[self:sideOf(defender)].evasion, self.random)
local acc = self.stages[self:sideOf(attacker)].accuracy
local eva = self.stages[self:sideOf(defender)].evasion
-- engine/battle/effect_commands.asm:1786
if defender and self:volatile(defender).identified
and (eva or 0) >= (acc or 0) then
acc, eva = 0, 0
end
return Damage.rollHit(self:moveAccuracy(accuracy, defender), acc, eva,
self.random)
end
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
@@ -2354,7 +2385,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def
stages = self.stages[self:sideOf(defender)],
},
types = self.data.type_chart and self.data.type_chart.types,
matchups = self.data.type_chart and self.data.type_chart.matchups,
matchups = self:matchupsAgainst(defender),
random = self.random,
})
state.futureSight = Effects.FUTURE_SIGHT_TURNS
@@ -3072,6 +3103,30 @@ function Battle:safeguarded(mon)
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
end
-- engine/battle/effect_commands.asm:1305
function Battle:matchupsAgainst(defender)
local chart = self.data.type_chart
local rows = chart and chart.matchups
if not rows or not defender then return rows end
if not self:volatile(defender).identified then return rows end
local skipped = chart.foresightMatchups
if not skipped or #skipped == 0 then return rows end
if not self.identifiedMatchups then
local drop = {}
for _, row in ipairs(skipped) do
drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] = true
end
local out = {}
for _, row in ipairs(rows) do
if not drop[tostring(row.attacker) .. "/" .. tostring(row.defender)] then
out[#out + 1] = row
end
end
self.identifiedMatchups = out
end
return self.identifiedMatchups
end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
@@ -3084,7 +3139,7 @@ function Battle:statusRefusedByType(defender, moveType, status)
end
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
if moveType then
local matchups = self.data.type_chart and self.data.type_chart.matchups
local matchups = self:matchupsAgainst(defender)
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
end
if status == "poison" or status == "toxic" then
@@ -3917,6 +3972,8 @@ end
-- check. Split out because playerAttack needs the same answer.
function Battle:lockedInMove(mon)
local state = self:volatile(mon)
-- engine/battle/core.asm:543
if state.chargeMove then return state.chargeMove end
if state.rolloutLock then return state.rolloutLock end
if state.rampageMove and (state.rampageTurns or 0) > 0 then
return state.rampageMove
+8
View File
@@ -284,6 +284,14 @@ function Effects.magnitudePower(random)
return last[2], last[3]
end
-- engine/battle/move_effects/return.asm:1-24, frustration.asm:1-25
function Effects.happinessPower(happiness, frustration)
local h = happiness or 0
if h < 0 then h = 0 elseif h > 255 then h = 255 end
if frustration then h = 255 - h end
return math.floor(h * 10 / 25)
end
-- ------------------------------------------------------------------- weather
--
-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to
+5 -2
View File
@@ -58,9 +58,12 @@ Prize.AMULET_COIN = "AMULET_COIN"
-- data/text/battle.asm. Declared here and formatted at the call site so
-- Strings.source is what registers them, the same way Decorations declares
-- its own five. No line markers: every battle message in this port is one
-- flowing string that Chrome.wrap breaks to the box.
-- flowing string that Chrome.wrap breaks to the box, except SentSomeToMomText,
-- which keeps the cart's own `line`/`cont` breaks because it does not fit two
-- rows.
local GOT_MONEY = Strings.source("%s got %s%d for winning!")
local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!")
-- data/text/battle.asm:179-185
local SENT_SOME = Strings.source("%s got %s%d\nfor winning!\vSent some to MOM!")
-- The half and all texts really are this short on the cart: they replace the
-- money line rather than following it, which is a quirk no Gold player can
-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F.
+2
View File
@@ -23,4 +23,6 @@ return {
-- (core.asm:426-464): poison/burn/leech seed tick before the slower
-- mon acts, not in an end-of-round sweep like Gen 3+.
residualAfterMove = true,
-- engine/battle/effects.asm:498,689
badgeBoostReapplyBug = true,
}
+1 -1
View File
@@ -27,7 +27,7 @@ local BATTLER_FIELDS = {
"chargeReady", "invulnerable", "mustRecharge",
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
"skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted",
"aiLayer2",
}
+9 -7
View File
@@ -82,8 +82,9 @@ local function copy(value)
return out
end
function Data:applyVersionedFieldData()
if require("src.core.GameVersion").isYellow() then
function Data:applyVersionedFieldData(version)
version = version or require("src.core.GameVersion").get()
if version == "yellow" then
self.field.trades = copy(YELLOW_TRADES)
-- The old man's catch demo is a RATTATA in Yellow
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
@@ -106,7 +107,8 @@ end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
function Data:seedDefaults(version)
version = version or require("src.core.GameVersion").get()
local constants = self.constants or {}
self.constants = constants
self.field = self.field or {}
@@ -131,7 +133,7 @@ function Data:seedDefaults()
if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end
self:applyVersionedFieldData()
Data.applyVersionedFieldData(self, version)
local boot = self.field.boot
if boot == nil then
boot = {}
@@ -144,7 +146,7 @@ function Data:seedDefaults()
-- only the un-overridden default flips, so a total conversion that set
-- field.boot.screens.splash keeps its choice on any version.
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
and require("src.core.GameVersion").isYellow() then
and version == "yellow" then
boot.screens.splash = "YellowIntro"
end
-- the naming screen presets the importer already extracts but nothing
@@ -163,11 +165,11 @@ function Data:seedDefaults()
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
Data.seedCinnabarGymTrainerHeaders(self)
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- writes no header for him -- seed one so he engages on sight and has
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
self:seedFightingDojoKarateMaster()
Data.seedFightingDojoKarateMaster(self)
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
+29
View File
@@ -0,0 +1,29 @@
-- Canonical pure shaping shared by the active boot and inactive dataset views.
-- The caller supplies both the data table and selected version; no global
-- GameVersion, cache prefix, mount, or active Data table is changed.
local GameVersion = require("src.core.GameVersion")
local DatasetHydration = {}
function DatasetHydration.applyGen2(data, moduleLoader)
local chart = data.type_chart or {}
chart.matchups = chart.matchups or {}
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
data.type_chart = chart
data.gen2HeldItems =
(moduleLoader or require)("src.core.gen2.ItemEffects").heldItemsFrom(data.items)
return data
end
function DatasetHydration.apply(data, version, moduleLoader)
if GameVersion.generation(version) == 2 then
return DatasetHydration.applyGen2(data, moduleLoader)
end
require("src.core.Data").seedDefaults(data, version)
return data
end
return DatasetHydration
+19 -1
View File
@@ -6,6 +6,9 @@ local FixedStep = {}
FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
local SMOOTH_FRAMES = 4
local SMOOTH_MAX = 1 / 60 * 2.5
local STEP_EPS = 1 / 60 * 0.02
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
-- been paid for. Half a step is the balanced point: a frame has to come in
@@ -23,6 +26,7 @@ function FixedStep:init(callback)
self.accum = 0
self.callback = callback
self.suppressCatchup = false
self.dtHistory, self.dtSum = nil, 0
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -39,12 +43,26 @@ function FixedStep:update(dt)
-- the burst it would otherwise release doesn't play out as a slide.
if self.suppressCatchup then
self.suppressCatchup = false
self.dtHistory, self.dtSum = nil, 0
self.accum = self.STEP * RESEED_PHASE
self.callback(self.STEP)
return
end
if dt > 0 and dt <= SMOOTH_MAX then
local hist = self.dtHistory
if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end
hist[#hist + 1] = dt
self.dtSum = self.dtSum + dt
if #hist > SMOOTH_FRAMES then
self.dtSum = self.dtSum - hist[1]
table.remove(hist, 1)
end
dt = self.dtSum / #hist
else
self.dtHistory, self.dtSum = nil, 0
end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
while self.accum >= self.STEP - STEP_EPS do
self.accum = self.accum - self.STEP
self.callback(self.STEP)
end
+15 -12
View File
@@ -1324,9 +1324,7 @@ function Game:restoreSave(loaded, recovered, opts)
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
SaveData.repairTradedOtIds(loaded)
-- saves from before OT/ID stamping: backfill with the player's
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
@@ -1389,8 +1387,13 @@ 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.
--
-- Gen1 Game is a MODULE SINGLETON (methods live on this table). Never fan
-- out arbitrary field:release() here: session fields can hold shared modules
-- (Fetch, SyncClient, …) whose :release is a job-handle API, not instance
-- teardown -- calling them as value:release() corrupts process state and has
-- been observed to leave Game.load nil after EXIT GAME on Android.
-- Explicit GPU owners are released below; everything else is just dropped.
function Game:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
@@ -1403,23 +1406,23 @@ function Game:reset()
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)
if self.renderer then
local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) end
end
-- Keep methods; clear every other field (including session scalars like
-- speedOverride). Re-seed module constants afterward.
local skinFast = self.SKIN_FAST_FORWARD
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
self.SKIN_FAST_FORWARD = skinFast or 4
end
return Game
+9 -7
View File
@@ -350,6 +350,10 @@ function Game2:showTitle()
onContinue = function()
self:showMainMenu()
end,
-- engine/menus/intro_menu.asm:848-889
onTimeout = function()
self:showCopyright()
end,
})
end
@@ -2204,6 +2208,8 @@ 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.
-- Same rule as Gen1: only release known GPU owners -- never fan out
-- arbitrary field:release() (shared modules use :release as a handle API).
function Game2:reset()
if self.stack and self.stack.clear then
pcall(function() self.stack:clear() end)
@@ -2216,17 +2222,13 @@ function Game2:reset()
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)
if self.renderer then
local release = self.renderer.releaseCanvases or self.renderer.release
if release then pcall(release, self.renderer) 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
+7 -2
View File
@@ -1983,6 +1983,11 @@ SaveData.addCoreMigration(3, function(save)
end
end)
-- #1461 repair, one-shot on pre-format-5 saves
SaveData.addCoreMigration(4, function(save)
SaveData.repairTradedOtIds(save)
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -2394,8 +2399,8 @@ function SaveData.applyPostGameHome(save, boot)
end
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
-- it, which reads back as home-caught. A caught mon can never carry
-- traded=true, so clearing that pair is safe on every load. #1461
-- it, which reads back as home-caught. Run only as the pre-format-5
-- migration: a same-id trade partner makes that pair legitimate. #1461
function SaveData.repairTradedOtIds(save)
local playerId = save and save.player and save.player.id
if playerId == nil then return 0 end
+70 -15
View File
@@ -70,9 +70,27 @@ local function fail(state, why)
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
end
local function limit(state, name)
return state.limits and state.limits[name]
end
local function bumpNode(state)
state.nodes = state.nodes + 1
local maximum = limit(state, "maxNodes")
if maximum and state.nodes > maximum then fail(state, "too many values") end
end
local function skip(state)
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
state.pos = last + 1
while true do
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
state.pos = last + 1
if not (state.limits and state.limits.allowComments
and state.src:sub(state.pos, state.pos + 1) == "--") then
return
end
local newline = state.src:find("\n", state.pos + 2, true)
state.pos = newline and newline + 1 or (#state.src + 1)
end
end
local function peek(state)
@@ -90,7 +108,10 @@ local function readString(state)
fail(state, "unterminated string")
elseif c == '"' then
state.pos = i + 1
return table.concat(out)
local value = table.concat(out)
local maximum = limit(state, "maxStringBytes")
if maximum and #value > maximum then fail(state, "string too long") end
return value
elseif c == "\\" then
local nxt = src:sub(i + 1, i + 1)
if nxt:match("%d") then
@@ -136,10 +157,14 @@ end
local readValue
local function readTable(state)
bumpNode(state)
state.depth = state.depth + 1
if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end
if state.depth > (limit(state, "maxDepth") or MAX_DEPTH) then
fail(state, "table nesting too deep")
end
state.pos = state.pos + 1
local out = {}
local entries, nextArray = 0, 1
skip(state)
if peek(state) == "}" then
state.pos = state.pos + 1
@@ -148,7 +173,7 @@ local function readTable(state)
end
while true do
skip(state)
local key
local key, value
local c = peek(state)
if c == "[" then
state.pos = state.pos + 1
@@ -156,15 +181,38 @@ local function readTable(state)
skip(state)
if peek(state) ~= "]" then fail(state, "expected ]") end
state.pos = state.pos + 1
skip(state)
if peek(state) ~= "=" then fail(state, "expected =") end
state.pos = state.pos + 1
value = readValue(state)
elseif c:match("[%a_]") then
key = readIdent(state)
local start = state.pos
local ident = readIdent(state)
skip(state)
if peek(state) == "=" then
key = ident
state.pos = state.pos + 1
value = readValue(state)
elseif state.limits and state.limits.allowArray then
state.pos = start
key = nextArray
value = readValue(state)
else
fail(state, "expected =")
end
elseif state.limits and state.limits.allowArray then
key = nextArray
value = readValue(state)
else
fail(state, "expected key")
end
skip(state)
if peek(state) ~= "=" then fail(state, "expected =") end
state.pos = state.pos + 1
out[key] = readValue(state)
if key == nil then fail(state, "nil table key") end
if out[key] ~= nil then fail(state, "duplicate table key") end
entries = entries + 1
local maximum = limit(state, "maxTableEntries")
if maximum and entries > maximum then fail(state, "too many table entries") end
out[key] = value
if type(key) == "number" and key == nextArray then nextArray = nextArray + 1 end
skip(state)
local sep = peek(state)
if sep == "," then
@@ -189,25 +237,30 @@ readValue = function(state)
skip(state)
local c = peek(state)
if c == '"' then
bumpNode(state)
return readString(state)
elseif c == "{" then
return readTable(state)
elseif c:match("[%a_]") then
-- the only bare words in the grammar are the boolean literals
local word = readIdent(state)
if word == "true" then return true end
if word == "false" then return false end
if word == "true" then bumpNode(state); return true end
if word == "false" then bumpNode(state); return false end
state.pos = state.pos - #word
fail(state, "unexpected name '" .. word .. "'")
elseif c:match("[%-%d%.]") then
bumpNode(state)
return readNumber(state)
end
fail(state, c == "" and "unexpected end of input" or "unexpected character")
end
function SaveSerializer.decode(str)
function SaveSerializer.decode(str, limits)
if type(str) ~= "string" then return nil, "save must be a string" end
local state = { src = str, pos = 1, depth = 0 }
if limits and limits.maxBytes and #str > limits.maxBytes then
return nil, ("input is %d bytes (max %d)"):format(#str, limits.maxBytes)
end
local state = { src = str, pos = 1, depth = 0, nodes = 0, limits = limits }
local ok, result = pcall(function()
skip(state)
local word = state.src:match("^[%a_][%w_]*", state.pos)
@@ -219,7 +272,9 @@ function SaveSerializer.decode(str)
return value
end)
if not ok then return nil, result end
if type(result) ~= "table" then return nil, "save root must be a table" end
if type(result) ~= "table" then
return nil, (limits and limits.rootName or "save") .. " root must be a table"
end
return result
end
+8 -1
View File
@@ -76,7 +76,14 @@ local function sdlFfi()
]])
if sdlCdefOk then
local okLoad, lib = pcall(ffi.load, "SDL2")
sdlLib = okLoad and lib or ffi.C
lib = okLoad and lib or ffi.C
local okSyms = pcall(function()
return lib.SDL_InitSubSystem, lib.SDL_NumSensors, lib.SDL_SensorGetDeviceType,
lib.SDL_SensorOpen, lib.SDL_SensorUpdate, lib.SDL_SensorGetData,
lib.SDL_GL_GetCurrentWindow, lib.SDL_GetWindowDisplayIndex,
lib.SDL_GetDisplayOrientation
end)
sdlLib = okSyms and lib or false
end
end
if not sdlCdefOk or not sdlLib then return nil end
+7
View File
@@ -87,6 +87,13 @@ function SessionLifecycle.endGameSession(game)
if game and game.reset then
pcall(function() game:reset() end)
end
-- Gen1 Game is the module singleton. If teardown left it without load,
-- drop the cached module so the next require rebuilds a clean table
-- (bootGame also guards this; doing it here keeps Play-again reliable).
if game and type(game.load) ~= "function"
and package.loaded["src.core.Game"] == game then
package.loaded["src.core.Game"] = nil
end
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
+7
View File
@@ -371,6 +371,13 @@ function Sound.waitSfxDone()
curSfx = nil
end
-- SFXChannelsOff (home/audio.asm:545)
function Sound.sfxChannelsOff()
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
-4
View File
@@ -217,10 +217,6 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion.generation and GameVersion.generation() == 2 then
margin = math.max(margin, math.min(ww * 0.10, 72))
end
return {
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
+1 -1
View File
@@ -18,7 +18,7 @@ local Version = {
-- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
saveFormat = 5, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
+8 -3
View File
@@ -89,10 +89,12 @@ local function handle(cmd)
return false
end
local idleWait = false
while true do
-- drain every pending command first, so a stop/new-play is seen promptly
local quit = false
local cmd = cmdCh:pop()
local cmd = idleWait and cmdCh:demand(0.05) or cmdCh:pop()
while cmd do
if handle(cmd) then quit = true end
cmd = cmdCh:pop()
@@ -100,6 +102,7 @@ while true do
if quit then break end
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
idleWait = false
local activeGen = gen
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
if not ok then
@@ -113,8 +116,10 @@ while true do
finished = true
end
end
elseif engine and not finished and gen then
idleWait = false
love.timer.sleep(0.005)
else
-- nothing to do (idle, or the look-ahead is full): yield the core
love.timer.sleep(0.001)
idleWait = true
end
end
+6 -8
View File
@@ -164,14 +164,12 @@ local function receiveItemToPc(save, id, data)
save.pcItems = save.pcItems or {}
local pc = save.pcItems
local held = pc[id] or 0
if held == 0 then
local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY
local stacks = 0
for _ in pairs(pc) do stacks = stacks + 1 end
if stacks >= cap then return false end
elseif held + 1 > MAX_STACK then
return false
end
local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY
local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end
local used = 0
for _, count in pairs(pc) do used = used + stacksFor(count) end
-- engine/items/items.asm:156 PutItemInPocket
if used + stacksFor(held + 1) - stacksFor(held) > cap then return false end
pc[id] = held + 1
return true
end
+4 -6
View File
@@ -343,6 +343,7 @@ Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = {
touchControls = true, haptics = true, screenPos = true,
videoMode = true,
mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true,
@@ -361,12 +362,9 @@ function Save.loadOptions(fs)
end
if type(loaded) == "table" then
for key in pairs(SHARED_KEYS) do
if loaded[key] ~= nil then options[key] = loaded[key] end
end
end
if type(stored) == "table" then
for key in pairs(SHARED_KEYS) do
if options[key] == nil and stored[key] ~= nil then
if loaded[key] ~= nil then
options[key] = loaded[key]
elseif type(stored) == "table" and stored[key] ~= nil then
options[key] = stored[key]
end
end
+119 -1
View File
@@ -10,7 +10,7 @@ local CacheContract = {}
CacheContract.FORMAT = "rom-cache-v10:"
CacheContract.VERSION_FORMAT = {
crystal = "rom-cache-v10-crystal2:",
crystal = "rom-cache-v10-crystal3:",
}
CacheContract.MARKER_PATH = "rom-cache.complete"
@@ -34,6 +34,11 @@ CacheContract.VERSION_REQUIRED_FILES = {
"assets/generated/battle/trainers/jessie_james.png",
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
"assets/generated/minigame/surf_1a.png",
"assets/generated/minigame/surf_1b.png",
"assets/generated/minigame/surf_1c.png",
"assets/generated/minigame/title_bg.png",
"assets/generated/minigame/intro_pika_0.png",
},
}
@@ -133,12 +138,70 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
local SEMANTIC_MODULES = {
[1] = {
"constants", "maps", "tilesets", "text", "text_pointers",
"trainer_headers", "font", "sprites", "pokemon", "moves", "items",
"type_chart", "trainers", "encounters", "field", "battle_anims",
},
[2] = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "trainers", "encounters", "sprites", "palettes",
"icons", "battle_anims", "constants", "landmarks",
},
}
local OPTIONAL_SEMANTIC_MODULES = {
[1] = { "audio", "palettes", "icons" },
[2] = {},
}
local function copy(values)
local out = {}
for index, value in ipairs(values or {}) do out[index] = value end
return out
end
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
-- A detached, version-specific inventory for read-only consumers. Semantic
-- consumers additionally require every generated module that backs the public
-- registry surface; optional semantic roots are discovered lazily.
function CacheContract.requiredFiles(version, semantic)
local base, isOverride = CacheContract.requiredFilesFor(version)
local files = copy(base)
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
files[#files + 1] = path
end
end
if semantic then
for _, name in ipairs(SEMANTIC_MODULES[GameVersion.generation(version)] or {}) do
files[#files + 1] = "data/generated/" .. name .. ".lua"
end
end
local seen, out = {}, {}
for _, path in ipairs(files) do
if not seen[path] then
seen[path] = true
out[#out + 1] = path
end
end
return out
end
function CacheContract.semanticModules(version)
return copy(SEMANTIC_MODULES[GameVersion.generation(version)])
end
function CacheContract.optionalSemanticModules(version)
return copy(OPTIONAL_SEMANTIC_MODULES[GameVersion.generation(version)])
end
function CacheContract.formatFor(version)
return CacheContract.VERSION_FORMAT[version] or CacheContract.FORMAT
end
@@ -260,4 +323,59 @@ function CacheContract.sourceTreeHasData(version)
return true
end
-- Inspect another version without mutating CacheFs.prefix, GameVersion, mounts,
-- or the active Data table. The injected filesystem addresses exact paths.
local function readAt(fs, path)
if fs.readAt then return fs.readAt(path) end
if fs.read then return fs.read(path) end
return nil
end
local function isFileAt(fs, path)
if fs.getInfo then
local info = fs.getInfo(path, "file")
return info ~= nil and (info.type == nil or info.type == "file")
end
if fs.existsAt then return fs.existsAt(path) == true end
return false
end
local function hasExactFiles(version, fs, prefix, semantic)
for _, path in ipairs(CacheContract.requiredFiles(version, semantic)) do
if not isFileAt(fs, prefix .. path) then return false, path end
end
return true
end
local function sourceReady(version, fs, semantic)
if not (fs.getInfo and fs.getRealDirectory and fs.getSource) then return nil end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local complete = hasExactFiles(version, fs, prefix, semantic)
if not complete then return nil end
local first = prefix .. CacheContract.requiredFiles(version, semantic)[1]
if fs.getRealDirectory(first) ~= fs.getSource() then return nil end
return { kind = "source", prefix = prefix }
end
function CacheContract.inspect(version, fs, opts)
opts = opts or {}
if not (GameVersion.VERSIONS[version] and fs) then
return nil, "not_imported", "unsupported cache inspection"
end
if opts.allowSource then
local source = sourceReady(version, fs, opts.semantic)
if source then return source end
end
local prefix = GameVersion.cachePrefix(version)
local marker = readAt(fs, prefix .. CacheContract.MARKER_PATH)
if not CacheContract.markerMatches(version, marker) then
return nil, "not_imported", "completion marker is missing or stale"
end
local complete, missing = hasExactFiles(version, fs, prefix, opts.semantic)
if not complete then
return nil, "not_imported", "required cache file is missing: " .. tostring(missing)
end
return { kind = "cache", prefix = prefix, marker = marker }
end
return CacheContract
+14 -5
View File
@@ -324,9 +324,9 @@ function CacheFs.openWrite(rel)
return file
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
rel = withPrefix(rel)
-- Read an exact version-qualified path without consulting CacheFs.prefix.
-- Readiness checks use this to inspect another version without global state.
function CacheFs.readAt(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
@@ -341,6 +341,11 @@ function CacheFs.read(rel)
return love.filesystem.read(rel)
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
return CacheFs.readAt(withPrefix(rel))
end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then
@@ -386,8 +391,7 @@ function CacheFs.loadActive(rel)
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
function CacheFs.existsAt(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
@@ -398,6 +402,11 @@ function CacheFs.exists(rel)
return love.filesystem.getInfo(rel, "file") ~= nil
end
function CacheFs.exists(rel)
return CacheFs.existsAt(withPrefix(rel))
end
-- remove a single cache-relative file
function CacheFs.remove(rel)
rel = withPrefix(rel)
+12 -24
View File
@@ -23,6 +23,11 @@ local SaveData = require("src.core.SaveData")
local LauncherSettings = {}
local function bgLocked(opts)
return opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended"
end
local function wrapIndex(i, n)
i = i % n
if i < 0 then i = i + n end
@@ -156,8 +161,6 @@ local function coreRows(opts, hooks)
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
@@ -167,10 +170,6 @@ local function coreRows(opts, hooks)
end,
function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE HUD"),
@@ -185,28 +184,17 @@ local function coreRows(opts, hooks)
return false
end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE BG"),
function()
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if bgLocked(opts) then return Strings("AUTO (FILL HUD)") end
if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
if bgLocked(opts) then return false end
local order = { "white", "black", "world" }
local cur = 1
for i, mode in ipairs(order) do
@@ -563,7 +551,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts, hooks)
local function gen2Rows(opts, hooks, shared)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -669,9 +657,9 @@ local function gen2Rows(opts, hooks)
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end,
function() return VideoMode.modeLabel(shared.videoMode) end,
function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
shared.videoMode = VideoMode.cycle(shared.videoMode, dir)
return true
end)
end
@@ -690,7 +678,7 @@ local function gen2Rows(opts, hooks)
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
addTouchRows(rows, add, opts, hooks)
addTouchRows(rows, add, shared, hooks)
return rows
end
@@ -718,7 +706,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks, opts) },
}
else
sections = {
+18 -7
View File
@@ -984,6 +984,17 @@ local function modScopeChipsWidth(options, gap, m)
return need
end
local function profileState(imp)
if imp._profileCache == nil then
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local list, cur = LauncherMods.getProfiles(options)
imp._profileCache = { options = options, list = list, active = cur }
end
return imp._profileCache
end
local function buildModScopeRow(imp, x, y, w, m)
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
@@ -994,7 +1005,7 @@ local function buildModScopeRow(imp, x, y, w, m)
local options = modScopeOptions(imp)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local _, activeProf = LauncherMods.getProfiles()
local activeProf = profileState(imp).active
local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
@@ -3698,9 +3709,8 @@ local function buildSingleProfileActionsModal(imp, m)
if not pName then imp._singleProfileActions = nil return end
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local prof = profileState(imp)
local options, profiles = prof.options, prof.list
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
@@ -3715,6 +3725,7 @@ local function buildSingleProfileActionsModal(imp, m)
action = function()
LauncherMods.duplicateProfile(pName, options)
imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end
},
{
@@ -3736,6 +3747,7 @@ local function buildSingleProfileActionsModal(imp, m)
imp:pressDelete("profile", pName, nil, function()
LauncherMods.deleteProfile(pName, options)
imp._singleProfileActions = nil
if imp._refreshMods then imp:_refreshMods() end
end)
end
}
@@ -3771,9 +3783,8 @@ end
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
local function buildProfilesModal(imp, m)
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local prof = profileState(imp)
local options, profiles, active = prof.options, prof.list, prof.active
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
+108
View File
@@ -1637,6 +1637,113 @@ function RomExtractor:extractYellowTitleArt()
end
end
function RomExtractor:extractSurfingPikachuTitleArt()
-- engine/minigame/surfing_pikachu.asm
-- DrawSurfingPikachuMinigameIntroBackground: compose the 160x144
-- "Pikachu's Beach" title from SurfingMinigame_* tilemaps over
-- SurfingPikachu1Graphics3 tiles (mirrors tools/build_rom_data.py).
if not self.symbols["SurfingPikachu1Graphics3"] then return end
if not self.symbols["SurfingMinigame_BeachIntroTilemap"] then return end
local beachIntro = self:symbol("SurfingMinigame_BeachIntroTilemap")
local useCtrlPad = self:symbol("SurfingMinigame_UseControlPadTilemap")
local toSurfRad = self:symbol("SurfingMinigame_ToSurfRadTilemap")
local titleMap = self:symbol("SurfingMinigame_TitleTilemap")
local beachBytes = self.rom:bytes(
beachIntro.bank, beachIntro.address, 12 * 20)
local useCtrlBytes = self.rom:bytes(
useCtrlPad.bank, useCtrlPad.address, 15)
local toSurfRadBytes = self.rom:bytes(
toSurfRad.bank, toSurfRad.address, 13)
local titleMapBytes = self.rom:bytes(
titleMap.bank, titleMap.address, 6 * 12)
local screen = {}
for _ = 1, 20 * 18 do screen[#screen + 1] = 0xff end
for i = 1, #beachBytes do
screen[6 * 20 + i] = beachBytes[i]
end
for r = 0, 5 do
for c = 0, 11 do
screen[r * 20 + (4 + c) + 1] = titleMapBytes[r * 12 + c + 1]
end
end
for r = 0, 2 do
for c = 0, 14 do
screen[(7 + r) * 20 + (3 + c) + 1] = 0xff
end
end
for i = 1, #useCtrlBytes do
screen[7 * 20 + 3 + i] = useCtrlBytes[i]
end
for i = 1, #toSurfRadBytes do
screen[9 * 20 + 4 + i] = toSurfRadBytes[i]
end
local gfx3 = self:symbol("SurfingPikachu1Graphics3")
local rawGfx3 = self.rom:bytes(gfx3.bank, gfx3.address, 144 * 16)
local tiles = {}
for tile = 0, 143 do
local one = {}
for j = 1, 16 do one[j] = rawGfx3[tile * 16 + j] end
tiles[tile + 1] = ImageWriter.decode2bpp(one, 8, 8, false)
end
local blank = ImageWriter.blank(8, 8, 1, 1, 1, 1)
local titleBg = ImageWriter.blank(160, 144, 1, 1, 1, 1)
for r = 0, 17 do
for c = 0, 19 do
local tileId = screen[r * 20 + c + 1]
local tileImg = blank
if tileId ~= 0xff then
local idx = tileId >= 0x80 and (tileId - 0x80 + 1) or (128 + tileId + 1)
if idx >= 1 and idx <= 144 then tileImg = tiles[idx] end
end
ImageWriter.blit(titleBg, tileImg, c * 8, r * 8)
end
end
self:save(titleBg, "minigame/title_bg.png")
-- Intro paddling Pikachu frames (surfing_pikachu_oam.asm .IntroPikachu).
local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c }
local INTRO_PIKA_OAM = {
{ -12, -16, 0x03, true }, { -12, -8, 0x02, true },
{ -12, 0, 0x01, true }, { -12, 8, 0x00, true },
{ -4, -16, 0x13, true }, { -4, -8, 0x12, true },
{ -4, 0, 0x11, true }, { -4, 8, 0x10, true },
{ 4, -16, 0x23, true }, { 4, -8, 0x22, true },
{ 4, 0, 0x21, true }, { 4, 8, 0x20, true },
}
local function blitIntroTile(target, tile, tx, ty, flipX)
for y = 0, 7 do
for x = 0, 7 do
local sx = flipX and (7 - x) or x
local r, g, b, a = tile:getPixel(sx, y)
local px, py = tx + x, ty + y
if a ~= 0 and px >= 0 and py >= 0
and px < target:getWidth() and py < target:getHeight() then
target:setPixel(px, py, r, g, b, a)
end
end
end
end
for frame, vramBase in ipairs(INTRO_PIKA_FRAME_BASE) do
local pose = ImageWriter.blank(32, 24, 1, 1, 1, 0)
local sheetBase = vramBase - 0x80
for _, sp in ipairs(INTRO_PIKA_OAM) do
local dy, dx, rel, flipX = sp[1], sp[2], sp[3], sp[4]
local tileImg = tiles[sheetBase + rel + 1]
if tileImg then
blitIntroTile(pose, tileImg,
16 + dx + (flipX and 8 or 0), 12 + dy, flipX)
end
end
self:save(pose, ("minigame/intro_pika_%d.png"):format(frame - 1))
end
end
function RomExtractor:raw2bpp(label, width, height, relative, options)
options = options or {}
local expected = width * height / 4
@@ -1978,6 +2085,7 @@ function RomExtractor:extractField()
self:save(image, spec[5])
end
end
self:extractSurfingPikachuTitleArt()
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
+18 -1
View File
@@ -151,6 +151,19 @@ local TEXT_BUFFERS = {
[0xc602] = "wOTTrademonSpeciesName",
[0xc618] = "wOTTrademonSenderName",
}
-- ../pokecrystal/ram/wram.asm:1925,2333
local TEXT_BUFFERS_CRYSTAL = {
[0xd050] = "wMonOrItemNameBuffer",
[0xd073] = "wStringBuffer1",
[0xd086] = "wStringBuffer2",
[0xd099] = "wStringBuffer3",
[0xd0ac] = "wStringBuffer4",
[0xd0bf] = "wStringBuffer5",
[0xc6d1] = "wPlayerTrademonSpeciesName",
[0xc6e7] = "wPlayerTrademonSenderName",
[0xc703] = "wOTTrademonSpeciesName",
[0xc719] = "wOTTrademonSenderName",
}
-- The text commands that print nothing and carry no argument
-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL,
-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles.
@@ -2295,6 +2308,8 @@ function RomExtractorGen2:extractTitle()
trailBobAmplitude = silver and 3 or 2,
trailPhaseStep = silver and 7 or 3,
trailPhase = silver and 0 or nil,
-- TitleScreenTimer (engine/menus/intro_menu.asm:951-966).
timeoutFrames = silver and (73 * 60 + 36) or (84 * 60 + 16),
}
self:write("title", data)
return data
@@ -2805,6 +2820,8 @@ end
-- is which alongside the text lets a caller fill them in order without
-- changing a marker every screen already reads.
function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
local textBuffers = (self.edition == "crystal") and TEXT_BUFFERS_CRYSTAL
or TEXT_BUFFERS
local out = {}
local i = 0
local hops = 0
@@ -2840,7 +2857,7 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
out[#out + 1] = "{STRBUF}"
if buffers then
local target = self.rom:word(bank, address + i + 1)
buffers[#buffers + 1] = TEXT_BUFFERS[target] or target
buffers[#buffers + 1] = textBuffers[target] or target
end
i = i + 2
elseif b == 0x4e or b == 0x4f then
+1
View File
@@ -4803,6 +4803,7 @@ function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self._cartPlan = nil
self._profileCache = nil
self.findInstalled = nil
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player
+5
View File
@@ -310,6 +310,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"Nothing happened!") }
end
b.stages[stat] = cur + 1
b.hazeStatReset = nil
if battle.ruleset and battle.ruleset.badgeBoostReapplyBug
and battle.kind ~= "link" then
require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
end
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
+5 -5
View File
@@ -163,8 +163,8 @@ end
-- a module the build dropped disables its registry rather than the game:
-- the consumer still reads its own table, so vanilla keeps working
local function load(path)
local ok, module = pcall(require, path)
local function load(path, moduleLoader)
local ok, module = pcall(moduleLoader or require, path)
if ok then return module end
Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module))
return nil
@@ -187,14 +187,14 @@ local function isolate(registry)
}, { __index = registry })
end
function Builtins.install(content, data, generation)
function Builtins.install(content, data, generation, moduleLoader)
for _, entry in ipairs(registrantsFor(generation)) do
local registry = content[entry.name] and isolate(content[entry.name])
if registry then
if entry.install then
local modules, complete = {}, true
for i, path in ipairs(entry.modules) do
modules[i] = load(path)
modules[i] = load(path, moduleLoader)
if modules[i] == nil then complete = false end
end
-- data is the fourth argument, not the second, so the existing
@@ -204,7 +204,7 @@ function Builtins.install(content, data, generation)
entry.install(registry, modules, Builtins.OWNER, data)
end
else
local module = load(entry.from)
local module = load(entry.from, moduleLoader)
-- entry.fn names the entry point for a module that owns more than one
-- registry (Gold's Battle owns statuses and move_effects); the default
-- is the registerInto every single-registry module exposes
+412
View File
@@ -0,0 +1,412 @@
-- Read-only, bounded views over version-scoped generated datasets.
local CacheContract = require("src.import.CacheContract")
local DatasetHydration = require("src.core.DatasetHydration")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local SaveSerializer = require("src.core.SaveSerializer")
local Builtins = require("src.mods.Builtins")
local Registry = require("src.mods.Registry")
local Schemas = require("src.mods.Schemas")
local DatasetViews = {}
DatasetViews.__index = DatasetViews
-- A generated module is ROM-sized data, never an arbitrary program. These
-- caps bound both one malicious file and the aggregate work one open performs.
local DECODE_LIMITS = {
allowArray = true,
allowComments = true,
maxBytes = 8 * 1024 * 1024,
maxDepth = 64,
maxNodes = 500000,
maxStringBytes = 2 * 1024 * 1024,
maxTableEntries = 250000,
rootName = "generated data",
}
local MAX_AGGREGATE_BYTES = 48 * 1024 * 1024
local GEN1_ROOTS = {
constants = "constants", maps = "maps", tilesets = "tilesets",
text = "text", text_pointers = "text_pointers",
trainer_headers = "trainer_headers", font = "font", sprites = "sprites",
pokemon = "pokemon", moves = "moves", items = "items",
type_chart = "type_chart", trainers = "trainers",
encounters = "encounters", field = "field",
battle_anims = "battle_anims", audio = "audio",
palettes = "palettes", icons = "icons",
}
local GEN2_ROOTS = {
pokemon = "pokemon", moves = "moves", items = "items",
type_chart = "type_chart", audio = "audio", font = "font",
gen2Maps = "maps", gen2Tilesets = "tilesets", gen2Text = "text",
gen2Trainers = "trainers", gen2Encounters = "encounters",
gen2Sprites = "sprites", gen2Palettes = "palettes",
gen2Icons = "icons", gen2BattleAnims = "battle_anims",
gen2Constants = "constants", gen2Landmarks = "landmarks",
}
local function resolvePath(root, suffix)
local node = root
for key in suffix:gmatch("[^.]+") do
if type(node) ~= "table" then return nil end
node = node[key]
end
return node
end
local function assetRelative(path)
if type(path) ~= "string" or path == "" then
error("dataset asset path is required", 3)
end
if path:find("\\", 1, true) or path:sub(1, 1) == "/"
or path:find("[%z\1-\31]") then
error("dataset asset path must be a generated relative path", 3)
end
if path:sub(1, 17) ~= "assets/generated/" then
error("dataset assets are limited to assets/generated/", 3)
end
for segment in path:gmatch("[^/]+") do
if segment == "." or segment == ".." then
error("dataset asset path may not traverse directories", 3)
end
end
return path
end
local function copyData(value, state, depth)
state = state or { seen = {}, nodes = 0 }
state.nodes = state.nodes + 1
if state.nodes > DECODE_LIMITS.maxNodes then return nil, false end
local kind = type(value)
if kind == "nil" or kind == "boolean" or kind == "number" then return value end
if kind == "string" then
if #value > DECODE_LIMITS.maxStringBytes then return nil, false end
return value
end
if kind ~= "table" or getmetatable(value) ~= nil then return nil, false end
depth = (depth or 0) + 1
if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return nil, false end
state.seen[value] = true
local out, entries = {}, 0
for key, child in pairs(value) do
entries = entries + 1
if entries > DECODE_LIMITS.maxTableEntries then return nil, false end
local keyCopy, keyOk = copyData(key, state, depth)
local childCopy, childOk = copyData(child, state, depth)
if keyOk == false or childOk == false or keyCopy == nil then return nil, false end
out[keyCopy] = childCopy
end
state.seen[value] = nil
return out, true
end
local function isDataOnly(value, state, depth)
state = state or { seen = {}, nodes = 0 }
state.nodes = state.nodes + 1
if state.nodes > DECODE_LIMITS.maxNodes then return false end
local kind = type(value)
if kind == "nil" or kind == "boolean" or kind == "number" then return true end
if kind == "string" then return #value <= DECODE_LIMITS.maxStringBytes end
if kind ~= "table" or getmetatable(value) ~= nil then return false end
depth = (depth or 0) + 1
if depth > DECODE_LIMITS.maxDepth or state.seen[value] then return false end
state.seen[value] = true
local entries = 0
for key, child in pairs(value) do
entries = entries + 1
if entries > DECODE_LIMITS.maxTableEntries
or not isDataOnly(key, state, depth)
or not isDataOnly(child, state, depth) then
return false
end
end
state.seen[value] = nil
return true
end
function DatasetViews.new(fs, engineRequire, decoder)
assert(fs and fs.read and fs.getInfo,
"DatasetViews.new requires a readable filesystem")
return setmetatable({ fs = fs, engineRequire = engineRequire or require,
decoder = decoder or SaveSerializer.decode, datasets = {} }, DatasetViews)
end
function DatasetViews:_preflight(version, inspected)
local paths, aggregate = {}, 0
local modules = CacheContract.semanticModules(version)
for _, name in ipairs(CacheContract.optionalSemanticModules(version)) do
local path = inspected.prefix .. "data/generated/" .. name .. ".lua"
if self.fs.getInfo(path, "file") then modules[#modules + 1] = name end
end
for _, name in ipairs(modules) do
local path = inspected.prefix .. "data/generated/" .. name .. ".lua"
local info = self.fs.getInfo(path, "file")
local size = info and info.size
if size and size > DECODE_LIMITS.maxBytes then return nil, name .. ": size limit" end
aggregate = aggregate + (size or 0)
if aggregate > MAX_AGGREGATE_BYTES then return nil, "aggregate size limit" end
paths[name] = path
end
return { paths = paths, key = table.concat(modules, "\n") }
end
local function resetInternal(view)
view.moduleCache, view.data, view.registries = {}, nil, nil
end
function DatasetViews:_reject(view, moduleName, source, detail)
view.invalid = { module = moduleName, source = source, detail = detail }
view.data, view.registries = nil, nil
Logger.warn("dataset %s cache rejected: %s", view.version, tostring(detail))
return nil
end
function DatasetViews:_ready(view)
if view.invalid or view.unavailable then return false end
local inspected, _, detail = CacheContract.inspect(view.version, self.fs, {
allowSource = true, semantic = true,
})
if not inspected then
view.unavailable = true
if self.datasets[view.version] == view then self.datasets[view.version] = nil end
Logger.warn("dataset %s unavailable: %s", view.version, detail)
return false
end
local plan, invalid = self:_preflight(view.version, inspected)
if not plan then
self:_reject(view, nil, nil, invalid)
return false
end
if view.prefix ~= inspected.prefix or view.plan.key ~= plan.key then
view.prefix, view.plan = inspected.prefix, plan
resetInternal(view)
else
view.plan = plan
end
for name, cached in pairs(view.moduleCache) do
local source = self.fs.read(plan.paths[name])
if type(source) ~= "string" then
self:_reject(view, name, source, name .. ": unreadable generated module")
return false
end
if source ~= cached.source then
resetInternal(view)
break
end
end
return not view.invalid
end
function DatasetViews:_module(view, root)
local moduleName = view.modules[root]
if not moduleName then return nil end
local cached = view.moduleCache[moduleName]
if cached then return cached.value end
local path = view.plan.paths[moduleName]
if not path then return nil end
local source = self.fs.read(path)
if type(source) ~= "string" then
return self:_reject(view, moduleName, source,
moduleName .. ": unreadable generated module")
end
local aggregate = #source
for _, loaded in pairs(view.moduleCache) do aggregate = aggregate + #loaded.source end
if aggregate > MAX_AGGREGATE_BYTES then
return self:_reject(view, moduleName, source, "aggregate size limit")
end
local value, err = self.decoder(source, DECODE_LIMITS)
if type(value) ~= "table" then
return self:_reject(view, moduleName, source,
moduleName .. ": " .. tostring(err or "non-table root"))
end
view.moduleCache[moduleName] = { source = source, value = value }
return value
end
function DatasetViews:_data(view)
if view.data then return view.data end
local data = {}
setmetatable(data, {
__index = function(target, root)
local value = self:_module(view, root)
if value ~= nil then rawset(target, root, value) end
return value
end,
})
DatasetHydration.apply(data, view.version, self.engineRequire)
if view.invalid then error(view.invalid.detail, 0) end
view.data = data
return data
end
local function registryBase(data, target)
return function() return resolvePath(data, target) end
end
function DatasetViews:_registries(view)
if view.registries then return view.registries end
local data = self:_data(view)
local registries = {}
for name, catalogSpec in pairs(Schemas.REGISTRIES) do
local spec = Schemas.shapeFor(name, catalogSpec, view.generation)
local registry = Registry.new(name, spec)
local target = Schemas.targetFor(name, catalogSpec, view.generation)
if target then registry.base = registryBase(data, target) end
registries[name] = registry
end
Builtins.install(registries, data, view.generation, self.engineRequire)
for _, registry in pairs(registries) do registry:freeze() end
view.registries = registries
return registries
end
function DatasetViews:_registry(view, name)
local service = self
local function registryForRead()
if not service:_ready(view) then return nil end
local ok, registries = pcall(service._registries, service, view)
if not ok then
if not view.invalid then service:_reject(view, nil, nil, registries) end
return nil
end
if view.invalid then return nil end
return registries[name]
end
local function validate(registry, id, value)
if value == nil then return true end
local ok, detail = Schemas.check(registry.spec, registry.name, id,
value, "override")
if ok then return true end
local target = registry.spec.target
or Schemas.targetFor(registry.name, registry.spec, view.generation)
local root = target and target:match("^[^%.]+")
local moduleName = root and view.modules[root]
if root == "gen2HeldItems" then moduleName = "items" end
local cached = moduleName and view.moduleCache[moduleName]
service:_reject(view, moduleName, cached and cached.source,
"invalid " .. registry.name .. " record: " .. detail)
return false
end
local function rawAt(id)
local registry = registryForRead()
local value = registry and registry:get(id)
if value == nil or not validate(registry, id, value)
or not isDataOnly(value) then return nil end
return value
end
local function valueAt(id)
local value = rawAt(id)
if value == nil then return nil end
return (copyData(value))
end
return {
get = function(_, id)
if type(id) ~= "string" or id == "" then return nil end
return valueAt(id)
end,
has = function(_, id)
if type(id) ~= "string" or id == "" then return false end
return rawAt(id) ~= nil
end,
each = function()
local ids = {}
local registry = registryForRead()
if registry then
for id, value in registry:each() do
if not validate(registry, id, value) then
ids = {}
break
end
if type(id) == "string" and isDataOnly(value) then
ids[#ids + 1] = id
end
end
end
table.sort(ids)
local index = 0
return function()
index = index + 1
local id = ids[index]
if id == nil then return nil end
return id, valueAt(id)
end
end,
}
end
function DatasetViews:_assets(view)
local service = self
local assets = {}
function assets:path(path)
if not service:_ready(view) then return nil end
return view.prefix .. assetRelative(path)
end
function assets:info(path)
local full = self:path(path)
if not full then return nil end
local info = service.fs.getInfo and service.fs.getInfo(full, "file")
if not info or (info.type and info.type ~= "file") then return nil end
local out = { type = "file" }
if info.size ~= nil then out.size = info.size end
return out
end
return assets
end
function DatasetViews:open(version)
if type(version) ~= "string" or not GameVersion.VERSIONS[version] then
return nil, "unknown_version"
end
local inspected, reason = CacheContract.inspect(version, self.fs, {
allowSource = true, semantic = true,
})
if not inspected then
self.datasets[version] = nil
return nil, reason
end
local plan, invalid = self:_preflight(version, inspected)
if not plan then
Logger.warn("dataset %s cache rejected: %s", version, invalid)
return nil, "invalid_cache"
end
local internal = self.datasets[version]
if internal and internal.invalid then
local bad = internal.invalid
local path = bad.module and plan.paths[bad.module]
local source = path and self.fs.read(path)
if source == bad.source then return nil, "invalid_cache" end
internal = nil
self.datasets[version] = nil
end
local changed = not internal or internal.prefix ~= inspected.prefix
or internal.plan.key ~= plan.key
if changed then
if internal then internal.unavailable = true end
internal = {
version = version,
generation = GameVersion.generation(version),
prefix = inspected.prefix,
plan = plan,
modules = GameVersion.generation(version) == 2 and GEN2_ROOTS or GEN1_ROOTS,
moduleCache = {},
}
self.datasets[version] = internal
else
internal.plan = plan
end
local view = { version = version, generation = internal.generation, content = {} }
for name in pairs(Schemas.REGISTRIES) do
view.content[name] = self:_registry(internal, name)
end
for alias, canonical in pairs(Schemas.ALIASES) do
view.content[alias] = view.content[canonical]
end
view.assets = self:_assets(internal)
return view
end
return DatasetViews
+9
View File
@@ -1154,6 +1154,15 @@ function Loader:_api(mod)
developer = loader.dev == true,
-- a deep copy: what a mod does to its own view never reaches the loader
manifest = Merge.deepCopy(mod.manifest),
datasets = {
open = function(_, version)
if not loader.datasetViews then
local DatasetViews = engineRequire("src.mods.DatasetViews")
loader.datasetViews = DatasetViews.new(loader.fs, engineRequire)
end
return loader.datasetViews:open(version)
end,
},
content = {},
exports = {},
DELETE = Registry.DELETE,
+7
View File
@@ -12,6 +12,12 @@ Registry.__index = Registry
-- exposed to mods as mod.DELETE: a patch value that unsets a field
Registry.DELETE = Merge.DELETE
local function assertWritable(self, id)
if self.spec.reservedIds and self.spec.reservedIds[id] then
error(("%s id is reserved engine metadata: %s"):format(self.name, id))
end
end
-- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps
-- the v1 record behavior for standalone use in tests and tools
function Registry.new(name, spec)
@@ -33,6 +39,7 @@ local function append(self, id, op, value, owner)
error(self.name .. ": content is frozen after load")
end
assert(type(id) == "string" and id ~= "", self.name .. " id is required")
assertWritable(self, id)
local list = self.ops[id]
if not list then
list = {}
+37 -3
View File
@@ -676,9 +676,10 @@ end
--
-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry
-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` /
-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here
-- too", which is the common case and why most registries carry none of this.
-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `reservedIds` /
-- `example` / `notes` the matching `gen2*`. Absent means "the Gen 1 shape is
-- right here too", which is the common case and why most registries carry
-- none of this.
-- The registry NAME, the verbs and (wherever the id space allows it) the ids
-- stay shared, exactly as the routing table keeps them shared.
--
@@ -700,6 +701,7 @@ local GEN2_SHAPE = {
gen2KeyValue = "keyValue", gen2Extra = "extra",
gen2Semantics = "semantics", gen2Write = "write",
gen2BaseAt = "baseAt", gen2BaseIds = "baseIds",
gen2ReservedIds = "reservedIds",
gen2Example = "example", gen2Notes = "notes",
}
@@ -757,6 +759,32 @@ end
local R = {}
Schemas.REGISTRIES = R
-- Some generated Gen 2 modules keep extractor metadata beside their public
-- record maps. These callbacks are the registry normalization boundary: the
-- metadata remains available to engine consumers through Data, but is not an
-- id a mod can read or overwrite through the record registry.
local function recordMapExcept(...)
local excluded = {}
for index = 1, select("#", ...) do excluded[select(index, ...)] = true end
return function(base, id)
if excluded[id] then return nil end
return base[id]
end, function(base)
local ids = {}
for id in pairs(base) do
if not excluded[id] then ids[#ids + 1] = id end
end
return ids
end, excluded
end
local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds =
recordMapExcept("growthRates", "tmhmMoves")
local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds =
recordMapExcept("generation", "source")
local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds =
recordMapExcept("generation", "source", "pockets")
-- ------- shared Gen 2 leaves
--
-- The ROM name spaces Gold's tables key by. They are enums rather than
@@ -779,6 +807,8 @@ local gen2PaletteRow = f.list(gen2Color)
R.pokemon = {
semantics = "record", target = "pokemon",
gen2BaseAt = pokemonGen2BaseAt, gen2BaseIds = pokemonGen2BaseIds,
gen2ReservedIds = pokemonGen2ReservedIds,
fields = {
id = f.str, name = f.str, dex = f.int(1),
index = f.opt(f.int(0, 255)),
@@ -870,6 +900,8 @@ R.pokemon = {
R.moves = {
semantics = "record", target = "moves",
gen2BaseAt = movesGen2BaseAt, gen2BaseIds = movesGen2BaseIds,
gen2ReservedIds = movesGen2ReservedIds,
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
@@ -894,6 +926,8 @@ R.moves = {
R.items = {
semantics = "record", target = "items",
gen2BaseAt = itemsGen2BaseAt, gen2BaseIds = itemsGen2BaseIds,
gen2ReservedIds = itemsGen2ReservedIds,
fields = {
id = f.str, name = f.str,
index = f.opt(f.int(0, 255)),
+1 -1
View File
@@ -817,7 +817,7 @@ function Renderer:frameRects()
-- GB pixel stops being a whole number of screen pixels, which is the trade
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
-- window scales to fit instead of overflowing off both sides.
if self.uiFill then
if self.uiFill and not FaithfulRes.scaleCap() then
Up = math.min(ph / uih, pw / uiw)
end
if uiw * Up > pw or uih * Up > ph then
+17 -3
View File
@@ -264,7 +264,9 @@ ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge"
local function libNames()
local osName = (love and love.system and love.system.getOS
and love.system.getOS()) or ""
if osName == "Windows" then
if osName == "iOS" then
return {}
elseif osName == "Windows" then
return { "librashader_bridge.dll" }
elseif osName == "OS X" then
return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" }
@@ -313,11 +315,13 @@ end
-- Every place the bridge may sit, most specific first.
local function libCandidates()
local names = libNames()
if #names == 0 then return {} end
local out = {}
local override = os.getenv("LIBRASHADER_BRIDGE_DLL")
if override and override ~= "" then out[#out + 1] = override end
local dirs, save = sourceDirs(), saveDir()
for _, name in ipairs(libNames()) do
for _, name in ipairs(names) do
for _, dir in ipairs(dirs) do
out[#out + 1] = dir .. "/" .. name
out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name
@@ -353,7 +357,17 @@ local function ensureLib()
end
tried[#tried + 1] = path
end
libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ")
local okSym, sym = pcall(function()
return ffi.C and ffi.C.librashader_translate_preset
end)
if okSym and sym ~= nil then
lib = ffi.C
return lib
end
libError = "librashader bridge not found; ffi.C has no librashader_translate_preset"
if #tried > 0 then
libError = libError .. "; looked in " .. table.concat(tried, ", ")
end
return nil, libError
end
+24 -5
View File
@@ -529,6 +529,16 @@ function Commands.set_field(ctx, key, value)
ctx.save[key] = value
end
-- scripts/ChampionsRoom.asm:57
function Commands.set_option(ctx, key, value)
local o = ctx.save.options
if not o then
o = {}
ctx.save.options = o
end
o[key] = value
end
function Commands.load_player_starter_name(ctx)
local flags = ctx.save.flags or {}
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
@@ -720,7 +730,8 @@ end
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
-- skipNickname suppresses AskName for callers that name the gift themselves;
-- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013)
function Commands.give_pokemon(ctx, species, level, skipNickname)
-- gotText prints GotMonText ahead of AskName (give_pokemon.asm:46).
function Commands.give_pokemon(ctx, species, level, skipNickname, gotText)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
-- mods can use the same seam for story gifts, fossils, or custom scripts.
@@ -754,6 +765,11 @@ function Commands.give_pokemon(ctx, species, level, skipNickname)
ctx.lastCheck = true
ctx.addedToParty = addedToParty
ctx.boxNum = boxNum
-- engine/events/give_pokemon.asm:46
if gotText and ctx.runner then
Commands.text_sound(ctx, "Get_Item1")
Commands.show_text(ctx, "_GotMonText", { RAM = species })
end
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and not skipNickname and ctx.runner then
@@ -1303,11 +1319,14 @@ function FadeOverlay:update()
end
function FadeOverlay:draw()
if self.color == "white" then
love.graphics.setColor(1, 1, 1, self.alpha)
else
love.graphics.setColor(0, 0, 0, self.alpha)
local shade = (self.color == "white") and 1 or 0
-- home/fade.asm:26
local r = self.game and self.game.renderer
if r then
r.screenVeil = { shade, self.alpha }
return
end
love.graphics.setColor(shade, shade, shade, self.alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
+63 -1
View File
@@ -120,6 +120,67 @@ CallAsm.SITES = {
["04:66d1"] = "TryReceiveItem",
}
-- pokegold-symbols/pokesilver.sym: bank $03 sits two bytes earlier in Silver.
CallAsm.SITES_SILVER = {
["03:4749"] = "GetPartyNickname",
["03:4853"] = "CutDownTreeOrGrass",
["03:4b47"] = "CheckContinueWaterfall",
["03:4d13"] = "SetStrengthFlag",
["03:4d79"] = "TryStrengthOW",
["03:4e1e"] = "DisappearWhirlpool",
["03:4f7d"] = "HasRockSmash",
["03:5094"] = "PutTheRodAway",
["03:506b"] = "Fishing_CheckFacingUp",
["03:51c5"] = "AskCutScript_CheckMap",
}
-- pokecrystal-symbols/pokecrystal.sym: Crystal's own addresses for the same sites.
CallAsm.SITES_CRYSTAL = {
["25:6f76"] = "GiveItemScript_DummyFunction",
["04:65cd"] = "StartMenu",
["04:7327"] = "SelectMenu",
["05:6f5e"] = "OverworldHatchEgg",
["25:6706"] = "EnableWildEncounters",
["24:426f"] = "RingTwice_StartCall",
["24:42eb"] = "HangUp",
["04:53e5"] = "InitCallReceiveDelay",
["24:425c"] = "LoadBillScript",
["24:426a"] = "LoadElmScript",
["3f:5017"] = "MomTriesToBuySomething_ASMFunction",
["04:6599"] = "ItemfinderSound",
["14:46ef"] = "SweetScentEncounter",
["02:431e"] = "TrainerWalkToPlayer",
["14:4753"] = "CheckCanUseSquirtbottle",
["04:764f"] = "SetMemEvent",
["14:4658"] = "PlayPoisonSFX",
["14:467b"] = "CheckWhitedOut",
["04:64fa"] = "OverworldBGMap",
["04:650a"] = "BattleBGMap",
["04:6513"] = "HalveMoney",
["04:6527"] = "GetWhiteoutSpawn",
["03:4706"] = "GetPartyNickname",
["03:4810"] = "CutDownTreeOrGrass",
["23:47e1"] = "BlindingFlash",
["00:3016"] = "HideSprites",
["23:4aed"] = "FlyFromAnim",
["05:54f1"] = "SkipUpdateMapSprites",
["23:4b33"] = "FlyToAnim",
["05:4157"] = "LoadWalkingSpritesGFX",
["03:4b38"] = "CheckContinueWaterfall",
["03:4d12"] = "SetStrengthFlag",
["03:4d78"] = "TryStrengthOW",
["03:4e1d"] = "DisappearWhirlpool",
["23:480a"] = "ShakeHeadbuttTree",
["03:4f7c"] = "HasRockSmash",
["03:5095"] = "PutTheRodAway",
["03:506c"] = "Fishing_CheckFacingUp",
["2e:44b3"] = "LoadFishingGFX",
["03:51ba"] = "AskCutScript_CheckMap",
["2e:41ea"] = "TreeMonEncounter",
["2e:4219"] = "RockMonEncounter",
["04:62f8"] = "TryReceiveItem",
}
-- The three WRAM addresses `memcall` / `memcallasm` / `memjump` take instead of
-- a routine. Not sites: the pointer AT the address is written at run time
-- (LoadMemScript for the queued script, the phone engine for the other two),
@@ -714,7 +775,8 @@ end
-- at import time), and the address pair is the fallback that always works.
function CallAsm.nameFor(label, bank, addr)
if label and CallAsm.ALL[label] then return label end
return CallAsm.SITES[CallAsm.key(bank, addr)]
local key = CallAsm.key(bank, addr)
return CallAsm.SITES[key] or CallAsm.SITES_SILVER[key] or CallAsm.SITES_CRYSTAL[key]
end
-- Run a routine by name. Returns the byte the asm leaves in wScriptVar, or
+35 -14
View File
@@ -8,8 +8,13 @@ local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Strings = require("src.core.Strings")
-- engine/events/diploma.asm:65
local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 },
{ 170, 170, 170 }, { 85, 85, 85 } }
local Diploma = {}
Diploma.__index = Diploma
Diploma.isOpaque = true
@@ -49,20 +54,18 @@ local function drawFrameBox(frame, tx, ty, tw, th)
local img = frame.img
local q = frame.quads
love.graphics.setColor(1, 1, 1, 1)
-- corners
love.graphics.draw(img, q[0], tx * 8, ty * 8)
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
-- engine/link/cable_club.asm:937
love.graphics.draw(img, q[2], tx * 8, ty * 8)
love.graphics.draw(img, q[4], (tx + tw - 1) * 8, ty * 8)
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
-- horizontal edges
love.graphics.draw(img, q[7], (tx + tw - 1) * 8, (ty + th - 1) * 8)
for x = 1, tw - 2 do
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[3], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[0], (tx + x) * 8, (ty + th - 1) * 8)
end
-- vertical edges
for y = 1, th - 2 do
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[1], (tx + tw - 1) * 8, (ty + y) * 8)
end
end
@@ -91,13 +94,31 @@ function Diploma.render(game)
drawFrameBox(frame, 0, 0, 20, 18)
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
local picPath, picTrueColor = Sprites.playerPath(
game.data, "front", { kind = "diploma" })
local pic = tryImage(picPath)
-- engine/movie/title.asm:321
local title = (game.data and game.data.field and game.data.field.title) or {}
local titlePlayer = title.player
if type(titlePlayer) == "table" then titlePlayer = titlePlayer.path end
local picPath, picTrueColor = Sprites.playerPic(
titlePlayer or "assets/generated/title/player.png",
{ side = "front", kind = "diploma", data = game.data })
local pic
if picPath then
if picTrueColor then
pic = tryImage(picPath)
else
local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90,
"diploma")
pic = (ok and faded) or tryImage(picPath)
end
end
if pic then
love.graphics.setColor(1, 1, 1, 1)
local sx, sy, sw, sh = love.graphics.getScissor()
-- engine/events/diploma.asm:44
love.graphics.setScissor(8, 8, 144, 128)
love.graphics.draw(pic, 115, 80)
if sx then love.graphics.setScissor(sx, sy, sw, sh)
else love.graphics.setScissor() end
if picTrueColor then
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
end
+18 -1
View File
@@ -95,11 +95,28 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
self.t = 0
self.done = false
self.canceled = false
Music.play(game.data, Music.special(game.data, "evolution"))
-- engine/movie/evolution.asm:41-46
Music.stop()
self.crySrc = require("src.core.Sound").playCry(game.data, mon.species)
self.cryT = 0
self.cryWait = self.crySrc ~= nil
if not self.cryWait then
Music.play(game.data, Music.special(game.data, "evolution"))
end
return self
end
function EvolutionState:update(dt)
if self.cryWait then
self.cryT = self.cryT + 1
local src = self.crySrc
local playing = src and src.isPlaying and src:isPlaying()
if self.cryT >= 3 and (not playing or self.cryT > 180) then
self.cryWait, self.crySrc = false, nil
Music.play(self.game.data, Music.special(self.game.data, "evolution"))
end
return
end
self.t = self.t + 1
if self.done then return end
local game = self.game
+7 -19
View File
@@ -157,6 +157,11 @@ end
-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs
-- main's chain into secondary's, same as stacking two RetroArch presets.
local function bgLocked(o)
return o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended"
end
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
@@ -197,8 +202,6 @@ local function buildRows(game)
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
elseif o.battleFit == "fill" and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
@@ -215,10 +218,6 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
if o.battleFit == "fill" and o.battleLayout == "wide"
and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
{ id = "battleHud", label = Strings("BATTLE HUD"),
@@ -237,9 +236,6 @@ local function buildRows(game)
return false
end
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
if o.battleHud == "extended" and o.battleFit == "fill" then
o.battleBg = "white"
end
return true
end },
-- What sits behind and around the battle. WHITE is the classic paper
@@ -249,11 +245,7 @@ local function buildRows(game)
{ id = "battleBg", label = Strings("BATTLE BG"),
value = function(g)
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return Strings("AUTO")
end
if bgLocked(o) then return Strings("AUTO (FILL HUD)") end
local m = o.battleBg
if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end
@@ -261,11 +253,7 @@ local function buildRows(game)
end,
step = function(g, dir)
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return false
end
if bgLocked(o) then return false end
local order = { "white", "black", "world" }
local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
+17 -15
View File
@@ -19,17 +19,22 @@ local function itemName(game, id)
return def and def.name or id
end
local function buildItems(game, store)
local function buildItems(game, store, order)
local items = {}
local ids = {}
for id in pairs(store) do table.insert(ids, id) end
table.sort(ids)
local ids = order
if not ids then
ids = {}
for id in pairs(store) do table.insert(ids, id) end
table.sort(ids)
end
for _, id in ipairs(ids) do
table.insert(items, {
value = id,
label = itemName(game, id),
right = "x" .. store[id],
})
if store[id] then
table.insert(items, {
value = id,
label = itemName(game, id),
right = "x" .. store[id],
})
end
end
return items
end
@@ -105,12 +110,9 @@ local function deposit(game)
local pc = game.save.pcItems
local inv = game.save.inventory
local Bag = require("src.inventory.Bag")
-- badges live in save.inventory alongside items but are not depositable
local depositable = {}
for id, count in pairs(inv) do
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
local order = Bag.order(game.save, game.data)
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
kind = "pc_item_deposit",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
+3 -1
View File
@@ -687,8 +687,10 @@ function Studio.deleteEntry(id)
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
if tc.skin == id then
tc.enabled, tc.skin = false, nil
tc.enabled, tc.skin = true, nil
opts.touchControls = tc
SaveData.saveOptions(opts)
TouchControls:applyOptions(opts)
end
Studio.refreshAvailable()
setStatus("Deleted " .. id)
+701 -342
View File
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -334,10 +334,9 @@ function TownMap:update(dt)
end
end
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
-- OG RED and ADVANCED bake an OBJ palette in, so the marker replays over the TOWNMAP zone pass (#301)
function TownMap:markPlayerRedraw(x, y)
if not PaletteFX.usesSpriteObp() then return end
if not (PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack()) then return end
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
end
@@ -402,11 +401,7 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
end
end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
-- draws the box cursor CENTERED on the selected location). Drawing it at
-- the cell top-left put the square in the frame's top-left quadrant (#152).
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
local showCursor = true
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
@@ -416,7 +411,7 @@ function TownMap:draw()
if selected and showCursor then
local x, y = markerXY(selected)
if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
love.graphics.draw(self.bg.cursor, x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
+25 -4
View File
@@ -826,7 +826,10 @@ function BattleState:drawPic(mon, back)
if trainerBack then
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
-- TrainerPalettes (Chris shares Cal's).
colors = Palettes.trainerColors(self.palettes, "PLAYER") or colors
-- engine/gfx/color.asm:683-696
local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER"
colors = Palettes.trainerColors(self.palettes, row)
or Palettes.trainerColors(self.palettes, "PLAYER") or colors
elseif enemyTrainer then
-- The opponent's class row out of the same TrainerPalettes table.
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
@@ -1258,7 +1261,15 @@ function BattleState:afterAnimFor(side)
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side, param)
-- engine/battle_anims/anim_commands.asm:1200 PlayHitSound
function BattleState:playHitSound(effectiveness)
if not effectiveness or effectiveness == 0 then return end
if effectiveness > 10 then self:playSfx("Sfx_SuperEffective")
elseif effectiveness < 10 then self:playSfx("Sfx_NotVeryEffective")
else self:playSfx("Sfx_Damage") end
end
function BattleState:animForMove(moveId, side, param, effectiveness)
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
@@ -1267,7 +1278,8 @@ function BattleState:animForMove(moveId, side, param)
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
-- Queue it so stepAnim chains without waiting on the next event.
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side }
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side,
effectiveness = effectiveness }
end
return started
end
@@ -1278,6 +1290,7 @@ function BattleState:startPendingAfterAnim()
if not pending then return false end
self.pendingAfterAnim = nil
if self:animForId(pending.name, pending.side) then
self:playHitSound(pending.effectiveness)
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
self.afterAnimPlayed = true
return true
@@ -1575,6 +1588,11 @@ function BattleState:advanceQueue()
self:showPages(text)
return
end
-- data/text/battle.asm:184
if event.kind == "money" then
self:showPages(event.text or "")
return
end
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
if event.kind == "shiny-flash" then
@@ -1692,12 +1710,14 @@ function BattleState:advanceQueue()
if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side, event.animParam) then
if not self:animForMove(event.move, event.side, event.animParam,
event.effectiveness) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options
if options and options.battleScene == false then
if self:animForId(self:afterAnimFor(event.side), event.side) then
self:playHitSound(event.effectiveness)
self.afterAnimPlayed = true
end
end
@@ -1725,6 +1745,7 @@ function BattleState:advanceQueue()
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
self.afterAnimPlayed = nil
else
self:playHitSound(event.effectiveness)
self:animForId(hit, from)
end
end
+27 -14
View File
@@ -441,15 +441,25 @@ end
-- Intro_ClearBGPals blacks all 16 palettes and burns two frames
-- (engine/movie/intro.asm:1554-1571); IntroScene26 clears to white instead
-- (engine/movie/intro.asm:1058, home/tilemap.asm:168-196).
local function setup(self, white, fn)
-- (engine/movie/intro.asm:1058, home/tilemap.asm:1-9,168-196).
-- Request2bpp (home/gfx.asm:1,190-260): TILES_PER_CYCLE tiles per frame,
-- then one more frame for the final short (or empty) request.
local function requestFrames(tiles)
local frames = 0
for _, count in ipairs(tiles) do
frames = frames + math.floor(count / 8) + 1
end
return frames
end
local function setup(self, white, tiles, fn)
if self.phase == 0 then
self.phase = 1
local color = white and WHITE or BLACK
self.bgPals = flatPals(color)
self.obPals = flatPals(color)
markDirty(self)
self.hold = 1
self.hold = (white and 4 or 2) + requestFrames(tiles) - 1
return
end
self.phase = 0
@@ -556,7 +566,7 @@ local Scenes = {}
-- IntroScene1 (engine/movie/intro.asm:96-146).
Scenes[1] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 64 }, function()
loadAct(self, "unownA")
clearAnims(self)
self.scx, self.scy = 0, 0
@@ -582,7 +592,7 @@ end
-- IntroScene3 (engine/movie/intro.asm:172-218).
Scenes[3] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 64 }, function()
loadAct(self, "background")
resetLYOverrides(self)
self.scx, self.scy = 0, 0
@@ -602,7 +612,7 @@ end
-- IntroScene5 (engine/movie/intro.asm:234-285).
Scenes[5] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 64 }, function()
loadAct(self, "unownHI")
self.lyActive = false
clearAnims(self)
@@ -641,7 +651,7 @@ end
-- IntroScene7 (engine/movie/intro.asm:332-401).
Scenes[7] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 255, 128, 64 }, function()
loadAct(self, "background")
resetLYOverrides(self)
clearAnims(self)
@@ -708,7 +718,7 @@ end
-- IntroScene11 (engine/movie/intro.asm:502-550).
Scenes[11] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 64 }, function()
loadAct(self, "unowns")
self.lyActive = false
clearAnims(self)
@@ -729,7 +739,10 @@ local UNOWN_SOUNDS = {
Scenes[12] = function(self)
local a = self.counter
local sound = UNOWN_SOUNDS[a]
if sound then self:playSfx(sound) end
if sound then
Sound.sfxChannelsOff()
self:playSfx(sound)
end
self.counter = (a + 1) % 256
if a >= 0xc0 then
self.scene = 13
@@ -746,7 +759,7 @@ end
-- IntroScene13 (engine/movie/intro.asm:626-683).
Scenes[13] = function(self)
setup(self, false, function()
setup(self, false, { 64, 255, 128, 64 }, function()
loadAct(self, "background")
clearAnims(self)
self.anims:init("INTRO_SUICUNE", 11 * 8, 13 * 8 + 4)
@@ -783,7 +796,7 @@ end
-- IntroScene15 (engine/movie/intro.asm:730-792).
Scenes[15] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 1, 64 }, function()
loadAct(self, "suicuneJump")
clearAnims(self)
self.anims:init("INTRO_UNOWN_F", 5 * 8, 8 * 8)
@@ -809,7 +822,7 @@ end
-- IntroScene17 (engine/movie/intro.asm:812-859).
Scenes[17] = function(self)
setup(self, false, function()
setup(self, false, { 64, 255, 64 }, function()
loadAct(self, "suicuneClose")
clearAnims(self)
self.scx, self.scy = 0, 0
@@ -832,7 +845,7 @@ end
-- IntroScene19 (engine/movie/intro.asm:878-941).
Scenes[19] = function(self)
setup(self, false, function()
setup(self, false, { 64, 128, 128, 1, 64 }, function()
loadAct(self, "suicuneBack")
clearAnims(self)
self.anims:init("INTRO_SUICUNE_AWAY", 0, 12 * 8)
@@ -918,7 +931,7 @@ end
-- IntroScene26 (engine/movie/intro.asm:1056-1103).
Scenes[26] = function(self)
setup(self, true, function()
setup(self, true, { 64, 128, 64 }, function()
loadAct(self, "crystalUnowns")
clearAnims(self)
self.scx, self.scy = 0, 0
+22 -16
View File
@@ -41,6 +41,8 @@ ItemPcMenu.isOpaque = true
local PC_ITEM_CAPACITY = 50
local MAX_STACK = 99
local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end
-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks
-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE
-- carries DECORATION and ends on TURN OFF.
@@ -167,18 +169,24 @@ function ItemPcMenu:rebuild()
for id, count in pairs(pc) do
if (count or 0) > 0 then
local def = self:def(id)
rows[#rows + 1] = {
id = id, count = count,
name = (def and def.name) or id,
index = def and def.index or math.huge,
}
local remaining = count
while remaining > 0 do
local n = math.min(remaining, MAX_STACK)
rows[#rows + 1] = {
id = id, count = n,
name = (def and def.name) or id,
index = def and def.index or math.huge,
}
remaining = remaining - n
end
end
end
-- wPCItems keeps acquisition order; without that recorded, item id order is
-- the stable choice, the same sort the PACK uses.
table.sort(rows, function(a, b)
if a.index ~= b.index then return a.index < b.index end
return a.id < b.id
if a.id ~= b.id then return a.id < b.id end
return a.count > b.count
end)
self.rows = rows
if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end
@@ -200,20 +208,18 @@ function ItemPcMenu:ensureVisible()
math.max(0, self:listTotal() - VISIBLE_ROWS)))
end
-- ReceiveItem over wPCItems: a new id needs one of the fifty stacks, a grown
-- one may not pass 99. False is the no-carry the deposit turns into
-- ReceiveItem over wPCItems: the add tops up every existing stack of that id
-- and spills the rest into a new one, so it only needs a free stack when the
-- room in place is short. False is the no-carry the deposit turns into
-- _PlayersPCNoRoomDepositText.
-- engine/items/items.asm:156 PutItemInPocket
function ItemPcMenu:pcAdd(id, qty)
local pc = self.save.pcItems
local held = pc[id] or 0
if held == 0 then
local stacks = 0
for _, count in pairs(pc) do
if (count or 0) > 0 then stacks = stacks + 1 end
end
if stacks >= PC_ITEM_CAPACITY then return false end
end
if held + qty > MAX_STACK then return false end
local used = 0
for _, count in pairs(pc) do used = used + stacksFor(count) end
local need = stacksFor(held + qty) - stacksFor(held)
if used + need > PC_ITEM_CAPACITY then return false end
pc[id] = held + qty
return true
end
+13 -6
View File
@@ -156,7 +156,9 @@ function TitleState.new(game, opts)
self.entranceHideBelow = entrance and tonumber(entrance.hideBelow) or nil
self.gemY = entrance and (tonumber(title.gemFromY) or -50) or self.gemRestY
self.entranceSfx = title.entranceSfx
-- engine/menus/intro_menu.asm:951-966
self.timeoutFrames = tonumber(title.timeoutFrames)
or ((self.trailMode == "silver") and (73 * 60 + 36) or (84 * 60 + 16))
self.onTimeout = opts.onTimeout
-- The copyright window line is pal 7 (engine/movie/title.asm:40-43); its
-- colour 0 backs the whole band.
@@ -295,12 +297,17 @@ function TitleState:update(_dt)
return
end
-- TitleScreenTimer / TitleScreenMain's run-down back into the attract
-- loop (engine/menus/intro_menu.asm:1125-1236).
if self.timeoutFrames and self.onTimeout
and self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
self.onTimeout()
return
-- engine/menus/intro_menu.asm:1023-1059
if self.timeoutFrames and self.onTimeout then
if self.fadeStart then
if self.frameCounter - self.fadeStart >= 60 then self.onTimeout() end
return
end
if self.frameCounter - (self.timeoutStart or 0) >= self.timeoutFrames then
self.fadeStart = self.frameCounter
Music.fadeOut(8)
return
end
end
local input = self.game.input
+16 -4
View File
@@ -1232,6 +1232,10 @@ function OverworldState:update(dt)
self.player.inputLocked = false
end
end
if self.spinArrive and not self.player.spinFrames then
self.spinArrive = nil
self.player.inputLocked = false
end
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
-- spin-down has landed (player_animations.asm:40)
@@ -1311,7 +1315,7 @@ function OverworldState:update(dt)
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
or self.flyAnim or self.flyArrive or self.spinArrive
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -1321,7 +1325,7 @@ function OverworldState:update(dt)
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
or self.flyAnim or self.flyArrive or self.spinArrive
end
-- a scriptMove's onDone can push a text box on the frame it retires, and
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
@@ -4802,6 +4806,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.doorWarp = nil
local arriveWarp = self.arriveWarp
self.arriveWarp = nil
if self.spinArrive then
self.spinArrive = nil
self.player.inputLocked = false
end
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
if doorWarp then
@@ -4849,6 +4857,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinDrop = true
-- engine/overworld/player_animations.asm:19
self.spinArrive = true
self.player.inputLocked = true
end
if doorWarp then
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
@@ -5329,8 +5340,9 @@ function OverworldState:drawWorld()
-- cry with no bubble still pauses the world for its beat)
if self.emote.bubble == false then return end
local npc = self.emote.npc
local ex = npc.px - cam.x + 4
local ey = npc.py - cam.y - 14
-- engine/overworld/emotion_bubbles.asm:41
local ex = npc.px - cam.x
local ey = npc.py - cam.y - 20
local bubble = Game.data.field.emotionBubbles
local drawn = false
if bubble and bubble.path then
+4 -4
View File
@@ -551,6 +551,7 @@ function World.new(game)
lastSfx = nil,
pokePic = nil,
pendingSceneScript = false,
startedOverworld = false,
-- GBC color state (engine/gfx/color.asm). `daytime` is the resolved
-- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides
-- World:hour for drivers and tests, so the palette, the hour windows and
@@ -9045,10 +9046,9 @@ function World:setMap(mapId, cx, cy, facing, opts)
if not opts.seamless then
self.pendingSceneScript = true
end
-- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on
-- every map entry, connections included -- which is why a player who keeps
-- warping is never rung (src/core/gen2/Phone.lua's receive timer).
if self.game and self.game.save then
-- engine/overworld/events.asm:98
if not self.startedOverworld and self.game and self.game.save then
self.startedOverworld = true
require("src.core.gen2.Phone").onMapLoad(self.game.save,
self:stepContext().phone)
end
+32
View File
@@ -30,6 +30,7 @@ local MapOverview = require("src.world.MapOverview")
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local Mail = require("src.core.gen2.Mail")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
@@ -49,6 +50,11 @@ local FIELD_ACTIONS = {
{ id = "teleport", move = "TELEPORT" },
}
local function validPartySlot(party, slot)
return type(slot) == "number" and slot == math.floor(slot)
and party[slot] ~= nil
end
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
@@ -68,6 +74,32 @@ function WorldAPI:current()
facing = p and p.facing }
end
-- Keep the public party-ordering contract identical across generations.
-- Gen 2 stores mail by party slot, so it must move with the Pokemon just as
-- the native PartyMenu's SwitchPartyMons path does.
function WorldAPI:canReorderParty()
local world, game = self:overworld(), self.game
local party = game and game.save and game.save.party or {}
return #party > 1 and world ~= nil and world:acceptsMenuInput()
end
function WorldAPI:reorderParty(fromSlot, toSlot)
local world, game = self:overworld(), self.game
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local party = game.save and game.save.party or {}
if not validPartySlot(party, fromSlot)
or not validPartySlot(party, toSlot) then
return nil, "invalid party slot"
end
if fromSlot ~= toSlot then
party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot]
Mail.swapSlots(game.save, fromSlot, toSlot)
require("src.core.Sound").play(game.data, "Sfx_SwitchPokemon")
end
return true
end
local function itemLabel(game, id)
local def = game and game.data and game.data.items
and game.data.items[id]
@@ -23,10 +23,10 @@ return function(game)
fitRow.step(game, 1)
assert(options.battleFit == "fill", "battle size switched to FILL")
assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
assert(options.battleBg == "black", "FILL + EXTENDED preserves the stored background")
assert(bgRow.value(game) == "AUTO (FILL HUD)", "adaptive background is labeled AUTO")
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value")
menu.index = bgIndex
menu.scroll = math.max(0, bgIndex - 5)
@@ -37,11 +37,11 @@ return function(game)
fitRow.step(game, -1)
assert(options.battleFit == "fixed", "battle size switched back to FIXED")
assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
assert(bgRow.step(game, 1) == true and options.battleBg == "black",
"FIXED can select BLACK")
assert(bgRow.value(game) == "BLACK", "FIXED exposes the stored BLACK choice")
assert(bgRow.step(game, 1) == true and options.battleBg == "world",
"FIXED can select WORLD")
assert(bgRow.step(game, 1) == true and options.battleBg == "white",
"FIXED can select WHITE")
U.wait(2)
local fixedPath = DIR .. "/fixed_extended_background_choices.png"
os.remove(fixedPath)
@@ -99,4 +99,19 @@ do
check(type(Game.load) == "function", "Game:reset keeps methods")
end
-- 6. Play-again after endGameSession: Game.load must still be callable
-- (Android crash: main.lua bootGame → Game:load with load == nil)
do
local Game = require("src.core.Game")
local SessionLifecycle = require("src.core.SessionLifecycle")
Game.save = {}
Game.stack = { clear = function() end }
-- Mimic a shared net module parked on the singleton (handle-style release).
Game.net = { release = function(id) end }
SessionLifecycle.endGameSession(Game)
local again = require("src.core.Game")
check(type(again.load) == "function",
"Play-again can call Game:load after endGameSession")
end
T.finish("android_exit_to_launcher_test")
@@ -0,0 +1,46 @@
-- Imported semantic modules are opened without eager reads and are decoded
-- once, on first use, through the same public facade mods receive.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local DatasetViews = require("src.mods.DatasetViews")
local GameVersion = require("src.core.GameVersion")
local SaveSerializer = require("src.core.SaveSerializer")
local files = {}
Fixture.cache(files, "gold")
local fs = T.sdk.memfs(files)
local rawRead = fs.read
local reads = {}
fs.read = function(path)
reads[path] = (reads[path] or 0) + 1
return rawRead(path)
end
local decodes = 0
local service = DatasetViews.new(fs, require, function(source, limits)
decodes = decodes + 1
return SaveSerializer.decode(source, limits)
end)
local pokemonPath = GameVersion.cachePrefix("gold")
.. "data/generated/pokemon.lua"
local movesPath = GameVersion.cachePrefix("gold")
.. "data/generated/moves.lua"
local view, reason = service:open("gold")
T.eq(reason, nil, "marker-valid Gold dataset opens")
T.check(view ~= nil, "open returns the lazy view")
T.eq(reads[pokemonPath] or 0, 0, "open does not read an unused root")
T.eq(reads[movesPath] or 0, 0, "open does not read another unused root")
T.eq(decodes, 0, "open decodes no semantic root")
local first = view and view.content.pokemon:get("FIXMON")
T.eq(first and first.name, "FIXMON", "first access decodes a valid root")
T.check(decodes > 0, "first root access performs bounded decoding")
T.eq(reads[movesPath] or 0, 0, "pokemon access leaves moves unused")
local afterFirst = decodes
local second = view and view.content.pokemon:get("FIXMON")
T.eq(second and second.name, "FIXMON", "repeated access remains available")
T.eq(decodes, afterFirst, "repeated access does not re-decode cached roots")
T.finish("dataset_views_lazy_validation")
@@ -0,0 +1,22 @@
-- Dedicated Route B no-mod parity: the additive facade remains cold.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local reads = 0
local fs = T.sdk.memfs({})
local read = fs.read
fs.read = function(path)
if path:match("^[a-z]+/data/generated/")
or path:match("^[a-z]+/rom%-cache%.complete$") then
reads = reads + 1
end
return read(path)
end
local data = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } }
local run = T.sdk.loadNone({ fs = fs, data = data, generation = 1 })
T.eq(#run.errors, 0, "no-mod load remains clean")
T.eq(run.loader.datasetViews, nil, "no-mod load does not allocate dataset service")
T.eq(reads, 0, "no-mod load performs no imported dataset reads")
T.eq(data.pokemon.ACTIVE.nested.n, 1, "no-mod data remains unchanged")
run.release()
T.finish("dataset_views_no_mod_parity")
@@ -0,0 +1,56 @@
-- Optional dataset absence is a handled API result, while a previously valid
-- view disappearing and malformed cache content remain actionable warnings.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local DatasetViews = require("src.mods.DatasetViews")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local previousWarn = Logger.warn
local warnings = {}
Logger.warn = function(fmt, ...)
warnings[#warnings + 1] = select("#", ...) > 0
and string.format(fmt, ...) or fmt
end
local ok, err = xpcall(function()
local unavailable = DatasetViews.new(T.sdk.memfs({}))
local unknown, unknownReason = unavailable:open("missing-version")
T.eq(unknown, nil, "unknown version has no view")
T.eq(unknownReason, "unknown_version", "unknown version returns its reason")
local absent, absentReason = unavailable:open("gold")
T.eq(absent, nil, "missing optional Gold dataset has no view")
T.eq(absentReason, "not_imported", "missing optional Gold returns its reason")
T.eq(#warnings, 0, "unknown and initially absent datasets do not warn")
local files = {}
Fixture.cache(files, "gold")
local service = DatasetViews.new(T.sdk.memfs(files))
local view = assert(service:open("gold"))
files[GameVersion.cachePrefix("gold") .. "rom-cache.complete"] = nil
T.eq(view.assets:path("assets/generated/missing.png"), nil,
"a stale view closes when its imported dataset disappears")
T.eq(#warnings, 1, "a previously valid view disappearing still warns")
T.check(warnings[1]:find("dataset gold unavailable", 1, true) ~= nil,
"the stale-view warning identifies the unavailable dataset")
warnings = {}
local malformedFiles = {}
Fixture.cache(malformedFiles, "gold", { pokemon = "not generated data" })
local malformed = assert(DatasetViews.new(T.sdk.memfs(malformedFiles)):open("gold"))
T.eq(#warnings, 0, "lazy open does not warn before malformed data is read")
T.eq(malformed.content.pokemon:get("FIXMON"), nil,
"malformed generated data fails closed")
T.eq(#warnings, 1, "malformed generated data still warns")
T.check(warnings[1]:find("dataset gold cache rejected", 1, true) ~= nil,
"the malformed-cache warning identifies cache rejection")
T.check(warnings[1]:find("pokemon:", 1, true) ~= nil,
"the malformed-cache warning keeps actionable module detail")
end, debug.traceback)
Logger.warn = previousWarn
if not ok then error(err, 0) end
T.finish("dataset_views_warning_policy")
@@ -48,7 +48,7 @@ end
-- #1461: saves that passed through 0.1.82-0.1.9x already have the player's
-- own id written onto traded mons, so stamping correctly from now on does
-- not help them. The repair runs on every load, not behind a format gate.
-- not help them. The repair is a one-shot pre-format-5 migration.
do
local SaveData = require("src.core.SaveData")
local save = newSave()
+125
View File
@@ -0,0 +1,125 @@
-- Extractor metadata hidden from record id spaces remains engine-owned: mods
-- cannot claim, update, or remove it through the public content registries.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Mon = require("src.battle.gen2.Mon")
local function goldData()
local data = T.fixtures.fresh()
data.pokemon.growthRates = {
GROWTH_MEDIUM_FAST = {
numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0,
},
}
data.pokemon.tmhmMoves = { "FIX_TACKLE", "FIX_CUT" }
data.moves.generation, data.moves.source = 2, "ROM:Moves"
data.items.generation, data.items.source = 2, "ROM:Items"
data.items.pockets = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" }
return data
end
local function assertMetadata(data, refs, label)
T.eq(data.pokemon.growthRates, refs.growthRates,
label .. ": growth-rate table identity is preserved")
T.eq(data.pokemon.tmhmMoves, refs.tmhmMoves,
label .. ": TM/HM order identity is preserved")
T.eq(data.moves.generation, 2, label .. ": move generation is preserved")
T.eq(data.moves.source, "ROM:Moves", label .. ": move source is preserved")
T.eq(data.items.generation, 2, label .. ": item generation is preserved")
T.eq(data.items.source, "ROM:Items", label .. ": item source is preserved")
T.eq(data.items.pockets, refs.pockets,
label .. ": item pocket order identity is preserved")
T.eq(Mon.experienceForLevel(
Mon.growthFor(data, "GROWTH_MEDIUM_FAST"), 10), 1000,
label .. ": active Gold growth behavior is preserved")
end
-- A no-mod load characterizes the unchanged active-boot behavior.
do
local data = goldData()
local refs = {
growthRates = data.pokemon.growthRates,
tmhmMoves = data.pokemon.tmhmMoves,
pockets = data.items.pockets,
}
local run = T.sdk.loadNone({ data = data, generation = 2 })
T.eq(#run.errors, 0, "no-mod Gold load remains clean")
assertMetadata(run.data, refs, "no-mod Gold")
run.release()
end
local files, paths = {}, {}
local attempts = {
register_growth_rates = [[
local mod = ...
mod.content.pokemon:register("growthRates", {
id = "growthRates", name = "CLAIMED", dex = 999,
types = {},
baseStats = { hp = 1, attack = 1, defense = 1, speed = 1,
specialAttack = 1, specialDefense = 1 },
catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST",
levelMoves = {}, evolutions = {},
spriteFront = "assets/generated/battle/front/claimed.png",
spriteBack = "assets/generated/battle/back/claimed.png", picSize = 5,
})
]],
replace_growth_rates = [[
local mod = ...
mod.content.pokemon:register("growthRates", {
id = "growthRates", name = "REPLACED", dex = 999,
types = {},
baseStats = { hp = 1, attack = 1, defense = 1, speed = 1,
specialAttack = 1, specialDefense = 1 },
catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST",
levelMoves = {}, evolutions = {},
spriteFront = "assets/generated/battle/front/replaced.png",
spriteBack = "assets/generated/battle/back/replaced.png", picSize = 5,
}, { replace = true })
]],
override_move_generation = [[
local mod = ...
mod.content.moves:override("generation", {
id = "generation", name = "CLAIMED", type = "NORMAL",
power = 1, accuracy = 100, pp = 1, effect = "NO_ADDITIONAL_EFFECT",
})
]],
patch_item_pockets = [[
local mod = ...
mod.content.items:patch("pockets", { price = 999 })
]],
remove_tmhm_moves = [[
local mod = ...
mod.content.pokemon:remove("tmhmMoves")
]],
}
for id, body in pairs(attempts) do
files["mods/" .. id .. "/manifest.json"] = ([[{
"id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua",
"api": 2, "gen2compat": true
}]]):format(id, id)
files["mods/" .. id .. "/main.lua"] = body
paths[#paths + 1] = "mods/" .. id
end
table.sort(paths)
local data = goldData()
local refs = {
growthRates = data.pokemon.growthRates,
tmhmMoves = data.pokemon.tmhmMoves,
pockets = data.items.pockets,
}
local run = T.sdk.loadMods(paths, {
fs = T.sdk.memfs(files), data = data, generation = 2,
})
for id in pairs(attempts) do
local mod = run.loader.mods[id]
T.eq(mod and mod.state, "failed", id .. " is rejected")
T.check(mod and tostring(mod.failure):match("reserved") ~= nil,
id .. " reports the reserved metadata boundary")
end
assertMetadata(run.data, refs, "rejected metadata writes")
run.release()
T.finish("gen2_reserved_metadata_ids")
@@ -0,0 +1,37 @@
-- Restricted generated-data grammar and resource limits.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local SaveSerializer = require("src.core.SaveSerializer")
local limits = {
allowArray = true, allowComments = true,
maxBytes = 256, maxDepth = 4, maxNodes = 7,
maxStringBytes = 8, maxTableEntries = 5, rootName = "generated data",
}
local value, err = SaveSerializer.decode(
'-- Generated by tools/build_data.py. DO NOT EDIT.\n'
.. 'return { rows = { "A", "B" }, [3] = true }\n', limits)
T.same(value, { rows = { "A", "B" }, [3] = true },
"LuaWriter array and keyed-table grammar decodes as data")
T.eq(err, nil, "valid generated data has no error")
local invalid = {
{ "return function() end", "function literal" },
{ "owned = true; return {}", "global assignment" },
{ "return {}; print('x')", "trailing executable syntax" },
{ string.char(27) .. "Lua", "binary chunk" },
{ "return " .. string.rep(" ", 260) .. "{}", "source-size limit" },
{ 'return { a = { b = { c = { d = { e = {} } } } } }', "depth limit" },
{ 'return { a = "123456789" }', "string limit" },
{ 'return { 1, 2, 3, 4, 5, 6 }', "table-entry limit" },
{ 'return { a = 1, b = 2, c = 3, d = 4, e = { f = 6, g = 7 } }',
"node limit" },
{ "return 7", "non-table root" },
}
for _, row in ipairs(invalid) do
local got = SaveSerializer.decode(row[1], limits)
T.eq(got, nil, row[2] .. " fails closed")
end
T.finish("generated_data_decoder")
+10 -15
View File
@@ -64,22 +64,17 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do
voidFill.step(-1)
eq(model.opts.gold.voidFill, "fade", version .. " left restores fade")
-- Every write has to land in the gen2 block: the flat keys beside it are
-- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2
-- rows leave them exactly as they found them.
local flatPad = model.opts.touchControls
local flatBuzz = model.opts.haptics
-- The pad and the buzz are one device-wide setting: Gen 2 reads them from
-- the flat keys, not its block (src/core/gen2/Save.lua SHARED_KEYS), so the
-- rows write the flat keys and must never grow a copy inside `gold`.
local pad = findRow(model, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it")
check(type(model.opts.gold) == "table", version .. " into the gold block")
eq(model.opts.gold.touchControls.enabled, false,
version .. " which now carries enabled")
eq(model.opts.touchControls, flatPad,
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.touchControls.enabled, false,
version .. " on the flat shared key")
eq(model.opts.gold.touchControls, nil,
version .. " without a copy in the gold block")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
eq(model.opts.crystal, nil, version .. " nor a third")
@@ -88,10 +83,10 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level")
eq(model.opts.gold.haptics,
TouchControls.normalizeHaptics(model.opts.gold.haptics),
eq(model.opts.haptics,
TouchControls.normalizeHaptics(model.opts.haptics),
version .. " VIBRATION stores a level the shared module knows")
eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's")
eq(model.opts.gold.haptics, nil, version .. " on the flat key only")
edited = 0
findRow(model, "TOUCH CONTROLS").action()
@@ -28,8 +28,15 @@ do
Game.network = { live = true } -- future field: must not need a whitelist
Game.stack = StateStack
Game.renderer = Renderer
Game.SKIN_FAST_FORWARD = 4
Renderer.canvas = love.graphics.newCanvas(8, 8)
-- Handle-style :release (Fetch/SyncClient) must not run as instance teardown.
local shared = {
release = function(self) self.killed = true end,
}
Game.sharedNet = shared
Game:reset()
check(type(Game.load) == "function", "Game:reset keeps methods")
@@ -40,6 +47,29 @@ do
check(Game.stack == nil, "Game:reset clears stack reference")
check(Game.renderer == nil, "Game:reset clears renderer reference")
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
check(shared.killed ~= true,
"Game:reset does not call handle-style :release on session fields")
check(Game.SKIN_FAST_FORWARD == 4,
"Game:reset preserves module scalars like SKIN_FAST_FORWARD")
end
-- ---- endGameSession must leave Gen1 Game.load callable for Play-again ------
do
Game.save = { money = 1 }
Game.stack = { clear = function() end }
-- Poison pattern from the Android crash: a field whose :release is a
-- job-handle API. Old reset called it as value:release() and could leave
-- the singleton unbootable (Game.load nil → main.lua bootGame crash).
local jobs = {}
Game.linkFetch = {
release = function(id) jobs[id] = nil end,
}
SessionLifecycle.endGameSession(Game)
check(type(Game.load) == "function",
"endGameSession leaves Game.load intact for the next bootGame")
check(package.loaded["src.core.Game"] == Game
or type((package.loaded["src.core.Game"] or {}).load) == "function",
"Gen1 Game module remains require-able after endGameSession")
end
-- ---- Game2:reset releases world GPU and present canvases ------------------
+85
View File
@@ -0,0 +1,85 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local NAME = "src.render.ShaderFX"
local function withFfi(fakeFfi, os, fn)
local oldModule = package.loaded[NAME]
local oldFfi = package.loaded.ffi
local oldPreload = package.preload.ffi
local oldGetOS = love.system.getOS
if os then love.system.getOS = function() return os end end
package.loaded[NAME] = nil
package.loaded.ffi = nil
package.preload.ffi = function() return fakeFfi end
local ShaderFX = require(NAME)
local can, err = ShaderFX.canConvert(), ShaderFX.bridgeError()
if fn then fn(ShaderFX) end
package.loaded[NAME] = oldModule
package.loaded.ffi = oldFfi
package.preload.ffi = oldPreload
love.system.getOS = oldGetOS
return can, err, ShaderFX
end
local function baseFfi()
return {
cdef = function() end,
load = function() error("no such library") end,
string = function(value) return value end,
}
end
local staticFfi = baseFfi()
staticFfi.C = {
librashader_translate_preset = function() return "{}" end,
librashader_free_string = function() end,
}
local can, err = withFfi(staticFfi)
T.eq(can, true, "a statically linked bridge is found through ffi.C")
T.eq(err, nil, "the ffi.C fallback leaves no bridge error behind")
local emptyFfi = baseFfi()
emptyFfi.C = setmetatable({}, {
__index = function() error("undefined symbol") end,
})
local missing, missingErr = withFfi(emptyFfi)
T.eq(missing, false, "no library and no ffi.C symbol means no conversion")
T.check(missingErr and missingErr:find("ffi.C has no librashader_translate_preset", 1, true) ~= nil,
"libError says ffi.C was probed too (got " .. tostring(missingErr) .. ")")
T.check(missingErr and missingErr:find("looked in", 1, true) ~= nil,
"libError still lists the paths that were tried")
local iosMissing, iosErr = withFfi(baseFfi(), "iOS")
T.eq(iosMissing, false, "iOS with no static symbol cannot convert")
T.check(iosErr and iosErr:find("ffi.C has no librashader_translate_preset", 1, true) ~= nil,
"iOS libError names the ffi.C probe")
T.check(iosErr and iosErr:find("looked in", 1, true) == nil,
"iOS lists no file candidates (got " .. tostring(iosErr) .. ")")
local iosCan = withFfi(staticFfi, "iOS")
T.eq(iosCan, true, "iOS resolves the bridge through ffi.C alone")
local freed = false
local translateFfi = baseFfi()
translateFfi.C = {
librashader_translate_preset = function(path, es)
return ('{"pass_count":1,"passes":[{"name":"%s","es":%d}]}'):format(path, es)
end,
librashader_free_string = function() freed = true end,
}
withFfi(translateFfi, "iOS", function(ShaderFX)
local preset, terr = ShaderFX.translate("/presets/a.slangp", true)
T.check(preset ~= nil, "translate works with lib == ffi.C (" .. tostring(terr) .. ")")
T.eq(preset and preset.passes[1].name, "/presets/a.slangp",
"the ffi.C symbol receives the preset path")
T.eq(preset and preset.passes[1].es, 1, "the es flag reaches the ffi.C symbol")
T.eq(freed, true, "the returned string is freed through ffi.C")
end)
T.finish("shaderfx ffi.C fallback")
+1
View File
@@ -139,6 +139,7 @@ stub.graphics = {
end,
translate = noop, scale = noop,
rotate = noop, origin = noop, setScissor = noop,
getScissor = function() return nil end,
getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
+98
View File
@@ -0,0 +1,98 @@
-- A sandboxed mod can take custody of a catch the party cannot hold
-- (catch.party_full): the mon goes to the mod instead of a PC box, which is
-- the difference between "choose who to release" and a catch that vanishes
-- into storage the mode has locked away.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Boxes = require("src.pokemon.Boxes")
local FIXTURE = {
["mods/catch_probe/manifest.json"] = [[{
"id": "catch_probe",
"name": "Catch Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/catch_probe/main.lua"] = [[
local mod = ...
mod.exports.answer = nil
mod.exports.ctx = nil
mod.hooks:wrap("catch.party_full", function(next, ctx)
mod.exports.ctx = ctx
if mod.exports.answer == nil then return next(ctx) end
return mod.exports.answer
end)
]],
}
local function fullBattle(species)
local data = { pokemon = {}, text = {} }
local party = {}
for _ = 1, 6 do party[#party + 1] = { species = "RATTATA", moves = {} } end
local save = {
party = party,
player = { name = "RED" },
options = { battleStyle = "shift" },
flags = {},
}
return setmetatable({
game = { save = save, stack = { push = function() end }, data = data },
data = data,
queue = {}, nextInsert = 0,
enemy = { mon = { species = species or "PIDGEY", level = 5, moves = {} },
name = species or "PIDGEY" },
}, { __index = BattleState })
end
local function boxTotal(save)
local n = 0
for _, box in ipairs(Boxes.ensure(save)) do n = n + #box end
return n
end
-- ------- no mod: the cart's silence, reproduced
local vanilla = T.sdk.loadNone({})
local battle = fullBattle()
battle:storeCaughtMon()
T.eq(battle.result, "caught", "no mod: the catch still lands")
T.eq(#battle.game.save.party, 6, "no mod: the party is untouched")
T.eq(boxTotal(battle.game.save), 1, "no mod: the mon was deposited")
T.eq(battle.queue[#battle.queue].text, "PIDGEY was\ntransferred to\nsomeone's PC!",
"no mod: with the transfer text")
vanilla.release()
-- ------- a mod claims custody
local run = T.sdk.loadMods({ "mods/catch_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0, "the catch probe loads clean (" .. tostring(run.errors[1]) .. ")")
local probe = run.loader.exports.catch_probe
probe.answer = true
local claimed = fullBattle()
claimed:storeCaughtMon()
T.eq(claimed.result, "caught", "claimed: the catch still lands")
T.eq(#claimed.game.save.party, 6, "claimed: the party is untouched")
T.eq(boxTotal(claimed.game.save), 0, "claimed: nothing reached a box")
T.eq(probe.ctx and probe.ctx.name, "PIDGEY", "the hook was handed the display name")
T.check(probe.ctx and probe.ctx.battle == claimed, "and the battle")
T.check(probe.ctx and probe.ctx.mon == claimed.enemy.mon, "and the caught mon")
T.check(probe.ctx and probe.ctx.game == claimed.game, "and the game")
probe.answer = false
local declined = fullBattle()
declined:storeCaughtMon()
T.eq(boxTotal(declined.game.save), 1, "declined: the box path runs as always")
probe.answer = nil
local fell = fullBattle()
fell:storeCaughtMon()
T.eq(boxTotal(fell.game.save), 1, "falling through deposits, as today")
run.release()
T.finish("catch party full")
+394
View File
@@ -0,0 +1,394 @@
-- Sandboxed public-API coverage for imported semantic dataset views.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Schemas = require("src.mods.Schemas")
local originalVersion = GameVersion.get()
local originalPrefix = CacheFs.prefix
GameVersion.set("red")
local files = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
Fixture.cache(files, version)
end
local probe = Fixture.addMod(files, "dataset_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
local view, reason = mod.datasets:open(version)
local mon = view and view.content.pokemon:get("FIXMON")
if mon then mon.name = "MUTATED" end
local ids = {}
if view then
for id, value in view.content.pokemon:each() do
ids[#ids + 1] = id
if value then value.name = "EACH_MUTATED" end
end
end
out[version] = {
reason = reason, generation = view and view.generation,
name = view and view.content.pokemon:get("FIXMON").name,
has = view and view.content.pokemon:has("FIXMON"), ids = ids,
aliasScripts = view and view.content.scripts == view.content.map_scripts,
aliasUi = view and view.content.ui == view.content.screens,
readOnly = view and view.content.pokemon.register == nil
and view.content.pokemon.patch == nil and view.content.pokemon.remove == nil,
}
end
local yellow = assert(mod.datasets:open("yellow"))
out.yellowOldMan = yellow.content.field:get("oldManBattle")
out.yellowBoot = yellow.content.field:get("boot")
local red = assert(mod.datasets:open("red"))
out.redBoot = red.content.field:get("boot")
out.redConstants = red.content.constants:get("dexSize")
local gold = assert(mod.datasets:open("gold"))
out.registryCount = 0
for _, registry in pairs(gold.content) do
if type(registry) == "table" then out.registryCount = out.registryCount + 1 end
end
out.foresight = gold.content.type_chart:get("NORMAL>GHOST")
out.held = gold.content.held_items:get("LEFTOVERS")
out.continuations, out.moves, out.assets = {}, {}, {}
for _, id in ipairs({ "CROBAT", "BELLOSSOM", "POLITOED", "SLOWKING",
"STEELIX", "SCIZOR", "KINGDRA", "PORYGON2", "BLISSEY" }) do
local row = gold.content.pokemon:get(id)
out.continuations[id] = row and row.dex
out.assets[id] = row and {
gold.assets:info(row.spriteFront), gold.assets:info(row.spriteBack) }
end
for _, id in ipairs({ "IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE",
"SUNNY_DAY", "SANDSTORM", "SLUDGE_BOMB", "SHADOW_BALL" }) do
out.moves[id] = gold.content.moves:has(id)
end
out.steel = gold.content.type_chart:has("STEEL")
out.metadataHidden = not gold.content.pokemon:has("growthRates")
and not gold.content.pokemon:has("tmhmMoves")
and not gold.content.moves:has("generation")
and not gold.content.moves:has("source")
and not gold.content.items:has("generation")
and not gold.content.items:has("source")
and not gold.content.items:has("pockets")
out.executableBuiltinHidden = gold.content.statuses:get("sleep") == nil
out.assetDirectory = gold.assets:info("assets/generated/battle/front")
out.assetRejects = {}
for _, path in ipairs({ "/assets/generated/x", "assets\\generated\\x",
"assets/generated/../x", "assets/generated/./x", "other/x",
"assets/generated/x\1" }) do
out.assetRejects[#out.assetRejects + 1] = pcall(gold.assets.path, gold.assets, path)
end
local unknown, unknownReason = mod.datasets:open("missing-version")
out.unknown = { unknown ~= nil, unknownReason }
mod.exports.result = out
]])
local active = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } }
local run = T.sdk.loadMods({ probe }, {
fs = T.sdk.memfs(files), data = active, generation = 1,
})
T.eq(#run.errors, 0, "dataset public probe loads clean")
local out = run.loader.exports.dataset_probe.result
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
T.eq(out[version].reason, nil, version .. " opens")
T.eq(out[version].generation,
GameVersion.generation(version),
version .. " reports selected generation")
T.eq(out[version].name, "FIXMON", version .. " get/each values are detached")
T.eq(out[version].has, true, version .. " has reads without exposing a copy")
T.eq(out[version].aliasScripts, true, version .. " scripts alias is canonical")
T.eq(out[version].aliasUi, true, version .. " ui alias is canonical")
T.eq(out[version].readOnly, true, version .. " registry has no write verbs")
end
T.eq(out.redConstants, 1, "Red derives dexSize through canonical defaults")
T.eq(out.redBoot and out.redBoot.screens and out.redBoot.screens.splash,
"IntroMovie", "Red uses canonical boot defaults")
T.eq(out.yellowOldMan and out.yellowOldMan.species, "RATTATA",
"Yellow applies canonical correction")
T.eq(out.yellowBoot and out.yellowBoot.screens and out.yellowBoot.screens.splash,
"YellowIntro",
"Yellow uses its canonical splash")
T.eq(out.foresight and out.foresight.multiplier, 0,
"Gold appends Foresight matchup rows")
T.eq(out.held and out.held.heldEffect, "HELD_LEFTOVERS", "Gold derives held_items")
for _, id in ipairs(Fixture.CONTINUATIONS) do
T.check(out.continuations[id] ~= nil, "Gold exposes " .. id)
T.same(out.assets[id], { { type = "file" }, { type = "file" } },
"Gold exposes namespaced front/back assets for " .. id)
end
for _, id in ipairs(Fixture.MOVES) do T.eq(out.moves[id], true, "Gold exposes " .. id) end
T.eq(out.steel, true, "Gold exposes Steel")
T.eq(out.metadataHidden, true,
"Gold extractor metadata stays outside record registry id spaces")
local registryCount = 0
for _ in pairs(Schemas.REGISTRIES) do registryCount = registryCount + 1 end
for _ in pairs(Schemas.ALIASES) do registryCount = registryCount + 1 end
T.eq(out.registryCount, registryCount,
"view exposes every canonical registry and alias")
T.eq(out.executableBuiltinHidden, true,
"engine records containing closures do not cross the data-only facade")
T.eq(out.assetDirectory, nil, "asset info does not expose directories")
for index, accepted in ipairs(out.assetRejects) do
T.eq(accepted, false, "asset rejection " .. index)
end
T.same(out.unknown, { false, "unknown_version" }, "unknown version fails stably")
T.eq(active.pokemon.ACTIVE.nested.n, 1, "active Data stays unchanged")
T.eq(active.pokemon.FIXMON, nil, "inactive records do not merge into active Data")
T.eq(GameVersion.get(), "red", "active GameVersion stays unchanged")
T.eq(CacheFs.prefix, originalPrefix, "CacheFs prefix stays unchanged")
run.release()
-- Facade replacement and nested record mutation do not cross mod boundaries.
local isolation = {}
Fixture.cache(isolation, "red")
local mutator = Fixture.addMod(isolation, "dataset_mutator", [[
local mod = ...
local view = assert(mod.datasets:open("red"))
view.content.pokemon.get = function() return { name = "POISONED" } end
local row = view.content.pokemon:get("FIXMON")
if row then row.name = "CHANGED" end
]])
local observer = Fixture.addMod(isolation, "dataset_observer", [[
local mod = ...
local view = assert(mod.datasets:open("red"))
mod.exports.name = view.content.pokemon:get("FIXMON").name
]])
isolation["mods/dataset_observer/manifest.json"] = [[{
"id": "dataset_observer", "name": "dataset_observer", "version": "1.0.0",
"entry": "main.lua", "api": 2, "games": ["all"],
"dependencies": ["dataset_mutator"]
}]]
local isolationRun = T.sdk.loadMods({ mutator, observer }, {
fs = T.sdk.memfs(isolation), data = { pokemon = {} }, generation = 1,
})
T.eq(isolationRun.loader.exports.dataset_observer.name, "FIXMON",
"facade and record mutation cannot cross mods")
isolationRun.release()
-- Marker-only, empty, partial, and stale-after-first-open caches fail closed.
local readiness = {}
local emptyPrefix = GameVersion.cachePrefix("red")
readiness[emptyPrefix .. "rom-cache.complete"] =
"rom-cache-v10:" .. GameVersion.info("red").sha1
Fixture.cache(readiness, "blue")
readiness[GameVersion.cachePrefix("blue") .. "data/generated/moves.lua"] = nil
Fixture.cache(readiness, "gold")
local staleReads = 0
local readinessFs = T.sdk.memfs(readiness)
local rawRead = readinessFs.read
readinessFs.read = function(path)
if path == GameVersion.cachePrefix("gold") .. "rom-cache.complete" then
staleReads = staleReads + 1
if staleReads > 1 then return "rom-cache-v9:stale" end
end
return rawRead(path)
end
local readinessMod = Fixture.addMod(readiness, "readiness_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue" }) do
local view, reason = mod.datasets:open(version)
out[version] = { view ~= nil, reason }
end
local first, firstReason = mod.datasets:open("gold")
local second, secondReason = mod.datasets:open("gold")
out.gold = { first ~= nil, firstReason, second ~= nil, secondReason }
mod.exports.result = out
]])
local readinessRun = T.sdk.loadMods({ readinessMod }, {
fs = readinessFs, data = { pokemon = {} }, generation = 1,
})
local readyOut = readinessRun.loader.exports.readiness_probe.result
T.same(readyOut.red, { false, "not_imported" }, "marker-only empty cache fails")
T.same(readyOut.blue, { false, "not_imported" }, "partial cache fails")
T.same(readyOut.gold, { true, nil, false, "not_imported" },
"cached view is invalidated when marker becomes stale")
readinessRun.release()
-- Removal followed by a fresh import cannot resurrect the pre-removal view.
local transition = {}
Fixture.cache(transition, "red")
local transitionFs = T.sdk.memfs(transition)
local transitionRead = transitionFs.read
local transitionMarkers = 0
local reimportedPokemon = require("src.import.LuaWriter").encode({
FIXMON = { id = "FIXMON", name = "REIMPORTED", dex = 1,
types = {}, catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST",
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
special = 65 },
level1Moves = {}, tmhm = {}, learnset = {}, evolutions = {},
spriteFront = "assets/generated/battle/front/fixmon.png",
spriteBack = "assets/generated/battle/back/fixmon.png", frontSize = 5 },
})
transitionFs.read = function(path)
if path == GameVersion.cachePrefix("red") .. "rom-cache.complete" then
transitionMarkers = transitionMarkers + 1
-- open and the first facade read each recheck readiness; remove the cache
-- for the next open, then make the following open the reimport.
if transitionMarkers == 3 then return nil end
end
local body = transitionRead(path)
if transitionMarkers >= 4
and path == GameVersion.cachePrefix("red") .. "data/generated/pokemon.lua" then
return reimportedPokemon
end
return body
end
local transitionMod = Fixture.addMod(transition, "transition_probe", [[
local mod = ...
local out = {}
for index = 1, 3 do
local view, reason = mod.datasets:open("red")
out[index] = { view and view.content.pokemon:get("FIXMON").name, reason }
end
mod.exports.result = out
]])
local transitionRun = T.sdk.loadMods({ transitionMod }, {
fs = transitionFs, data = { pokemon = {} }, generation = 1,
})
T.same(transitionRun.loader.exports.transition_probe.result, {
{ "FIXMON" }, { nil, "not_imported" }, { "REIMPORTED" },
}, "remove and reimport transition rebuilds the semantic view")
transitionRun.release()
-- Every hostile source is rejected as data, with one stable public reason.
local hostile = {
{ "red", "return { BAD = function() return 1 end }" },
{ "blue", "owned = true; return {}" },
{ "yellow", "return 7" },
{ "gold", string.char(27) .. "Lua" },
{ "silver", "return {}; while true do end" },
}
local hostileFiles, hostilePaths = {}, {}
for _, row in ipairs(hostile) do
Fixture.cache(hostileFiles, row[1], { pokemon = row[2] })
end
local hostileMod = Fixture.addMod(hostileFiles, "hostile_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
local view, reason = mod.datasets:open(version)
local value = view and view.content.pokemon:get("BAD")
local reopened, reopenedReason = mod.datasets:open(version)
out[version] = {
first = view ~= nil, firstReason = reason, value = value,
reopened = reopened ~= nil, reason = reopenedReason,
}
end
mod.exports.result = out
]])
hostilePaths[1] = hostileMod
local previousHook, previousMask, previousCount
local beforeHook
if debug.gethook and debug.sethook then
previousHook, previousMask, previousCount = debug.gethook()
beforeHook = function() end
debug.sethook(beforeHook, "", 1000)
end
local hostileRun = T.sdk.loadMods(hostilePaths, {
fs = T.sdk.memfs(hostileFiles), data = { pokemon = {} }, generation = 1,
})
local hostileOut = hostileRun.loader.exports.hostile_probe.result
for _, row in ipairs(hostile) do
T.same(hostileOut[row[1]], {
first = true, reopened = false, reason = "invalid_cache",
}, row[1] .. " hostile generated source fails closed on first root access")
end
T.eq(debug.gethook and debug.gethook(), beforeHook,
"dataset decoding preserves the caller debug hook")
if debug.sethook then
if previousHook then debug.sethook(previousHook, previousMask, previousCount)
else debug.sethook() end
end
hostileRun.release()
-- A syntactically valid semantic root with a primitive record must fail closed
-- whichever public read verb encounters it first.
local malformedFiles = {}
for _, version in ipairs({ "red", "blue", "yellow" }) do
Fixture.cache(malformedFiles, version, {
pokemon = "return { FIXMON = 7 }",
})
end
local malformedMod = Fixture.addMod(malformedFiles, "malformed_probe", [[
local mod = ...
local out = {}
local getView = assert(mod.datasets:open("red"))
out.get = getView.content.pokemon:get("FIXMON")
local getAgain, getReason = mod.datasets:open("red")
out.getAgain = { getAgain ~= nil, getReason }
local hasView = assert(mod.datasets:open("blue"))
out.has = hasView.content.pokemon:has("FIXMON")
local hasAgain, hasReason = mod.datasets:open("blue")
out.hasAgain = { hasAgain ~= nil, hasReason }
local eachView = assert(mod.datasets:open("yellow"))
for id, value in eachView.content.pokemon:each() do
out.each = { id, value }
break
end
local eachAgain, eachReason = mod.datasets:open("yellow")
out.eachAgain = { eachAgain ~= nil, eachReason }
mod.exports.result = out
]])
local malformedRun = T.sdk.loadMods({ malformedMod }, {
fs = T.sdk.memfs(malformedFiles), data = { pokemon = {} }, generation = 1,
})
T.eq(#malformedRun.errors, 0, "malformed-record probe stays sandboxed")
local malformedOut = malformedRun.loader.exports.malformed_probe.result
T.eq(malformedOut.get, nil, "get never exposes a malformed semantic record")
T.eq(malformedOut.has, false, "has never affirms a malformed semantic record")
T.eq(malformedOut.each, nil, "each never exposes a malformed semantic record")
T.same(malformedOut.getAgain, { false, "invalid_cache" },
"get invalidates the malformed dataset")
T.same(malformedOut.hasAgain, { false, "invalid_cache" },
"has invalidates the malformed dataset")
T.same(malformedOut.eachAgain, { false, "invalid_cache" },
"each invalidates the malformed dataset")
malformedRun.release()
-- The same Gold view stays independent while each Gen 1 version is the
-- actual active runtime version and cache namespace, not just a fixture label.
for _, activeVersion in ipairs({ "red", "blue", "yellow" }) do
local matrixFiles = {}
Fixture.cache(matrixFiles, "gold")
local matrixMod = Fixture.addMod(matrixFiles,
"active_" .. activeVersion .. "_gold_probe", [[
local mod = ...
local gold = assert(mod.datasets:open("gold"))
mod.exports.result = {
species = gold.content.pokemon:get("FIXMON").name,
foresight = gold.content.type_chart:get("NORMAL>GHOST").multiplier,
held = gold.content.held_items:get("LEFTOVERS").heldEffect,
}
]])
GameVersion.set(activeVersion)
CacheFs.prefix = GameVersion.cachePrefix(activeVersion)
local activeData = {
pokemon = { ACTIVE = { id = "ACTIVE_" .. activeVersion,
nested = { version = activeVersion } } },
}
local matrixRun = T.sdk.loadMods({ matrixMod }, {
fs = T.sdk.memfs(matrixFiles), data = activeData, generation = 1,
})
T.eq(#matrixRun.errors, 0, activeVersion .. "-active Gold probe loads")
T.same(matrixRun.loader.exports["active_" .. activeVersion .. "_gold_probe"].result,
{ species = "FIXMON", foresight = 0, held = "HELD_LEFTOVERS" },
activeVersion .. "-active runtime sees canonical Gold semantics")
T.eq(GameVersion.get(), activeVersion,
activeVersion .. " remains the active GameVersion")
T.eq(CacheFs.prefix, GameVersion.cachePrefix(activeVersion),
activeVersion .. " remains the active cache namespace")
T.eq(activeData.pokemon.ACTIVE.nested.version, activeVersion,
activeVersion .. " active Data remains unchanged")
T.eq(activeData.pokemon.FIXMON, nil,
activeVersion .. " active Data receives no Gold record")
matrixRun.release()
end
GameVersion.set(originalVersion)
CacheFs.prefix = originalPrefix
T.finish("dataset_views")
@@ -0,0 +1,27 @@
-- A generated chunk that would loop if executed must be rejected as syntax.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local files = {}
Fixture.cache(files, "red", {
pokemon = "while true do end; return {}",
})
local modPath = Fixture.addMod(files, "nontermination_probe", [[
local mod = ...
local view, reason = mod.datasets:open("red")
local value = view and view.content.pokemon:get("FIXMON")
local reopened, reopenedReason = mod.datasets:open("red")
mod.exports.result = {
first = view ~= nil, firstReason = reason, value = value,
reopened = reopened ~= nil, reason = reopenedReason,
}
]])
local run = T.sdk.loadMods({ modPath }, {
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
})
T.same(run.loader.exports.nontermination_probe.result,
{ first = true, reopened = false, reason = "invalid_cache" },
"generated code is never executed and invalidates on first root access")
run.release()
T.finish("dataset_views_nontermination")
@@ -0,0 +1,59 @@
-- Gen 2 parity for the public party-ordering contract. The fixture also
-- carries slot-based mail because a reorder must move that state with its mon.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod world party reorder gen2")
local WorldAPI = require("src.world.gen2.WorldAPI")
local first = { species = "CHIKORITA" }
local second = { species = "CYNDAQUIL" }
local firstMail = { message = "FIRST" }
local secondMail = { message = "SECOND" }
local world = { map = { id = "NEW_BARK_TOWN" }, accepts = true }
function world:acceptsMenuInput() return self.accepts end
local game = {
data = { audio = { sfx = {} } },
save = {
party = { first, second },
mail = { party = { firstMail, secondMail }, box = {} },
},
world = world,
}
local api = WorldAPI.new(game, "fixture")
T.check(api:canReorderParty(), "idle free roam allows party reordering")
local Sound = require("src.core.Sound")
local realPlay, played = Sound.play
Sound.play = function(_, name) played = name end
T.check(api:reorderParty(1, 2) == true, "valid slots reorder")
Sound.play = realPlay
T.check(game.save.party[1] == second and game.save.party[2] == first,
"the live party is swapped")
T.check(game.save.mail.party[1] == secondMail
and game.save.mail.party[2] == firstMail,
"slot-based mail follows its Pokemon")
T.eq(played, "Sfx_SwitchPokemon", "the native Gen 2 swap sound is used")
local value, err = api:reorderParty(1.5, 2)
T.check(value == nil and err == "invalid party slot",
"non-integer slots are rejected")
value, err = api:reorderParty("1", 2)
T.check(value == nil and err == "invalid party slot",
"string slots are rejected")
world.accepts = false
T.check(not api:canReorderParty(), "busy free roam blocks reordering")
value, err = api:reorderParty(1, 2)
T.check(value == nil and err == "world is busy",
"reordering refuses while the world owns input")
game.world = nil
value, err = api:reorderParty(1, 2)
T.check(value == nil and err == "no overworld",
"reordering outside the overworld fails closed")
T.finish()
+138
View File
@@ -0,0 +1,138 @@
local GameVersion = require("src.core.GameVersion")
local LuaWriter = require("src.import.LuaWriter")
local CacheContract = require("src.import.CacheContract")
local Fixture = {}
local GEN1_MODULES = {
"constants", "maps", "tilesets", "text", "text_pointers",
"trainer_headers", "font", "sprites", "pokemon", "moves", "items",
"type_chart", "trainers", "encounters", "field", "battle_anims",
}
local GEN1_OPTIONAL_MODULES = { "audio", "palettes", "icons" }
local GEN2_MODULES = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "trainers", "encounters", "sprites", "palettes",
"icons", "battle_anims", "constants", "landmarks",
}
local CONTINUATIONS = {
"CROBAT", "BELLOSSOM", "POLITOED", "SLOWKING", "STEELIX",
"SCIZOR", "KINGDRA", "PORYGON2", "BLISSEY",
}
local MOVES = {
"IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE", "SUNNY_DAY",
"SANDSTORM", "SLUDGE_BOMB", "SHADOW_BALL",
}
local function species(id, dex, generation)
local row = {
id = id, name = id, dex = dex,
types = {}, catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST",
tmhm = {}, evolutions = {},
spriteFront = "assets/generated/battle/front/" .. id:lower() .. ".png",
spriteBack = "assets/generated/battle/back/" .. id:lower() .. ".png",
}
if generation == 2 then
row.baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
specialAttack = 65, specialDefense = 65 }
row.levelMoves = {}
row.picSize = 5
else
row.baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
special = 65 }
row.level1Moves = {}
row.learnset = {}
row.frontSize = 5
end
return row
end
local function defaults(version)
local generation = GameVersion.generation(version)
local pokemon = { FIXMON = species("FIXMON", 1, generation) }
local moves = {}
local items = {}
local typeChart = {
matchups = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 } },
types = {},
}
if generation == 2 then
pokemon.growthRates = {}
pokemon.tmhmMoves = {}
moves.generation, moves.source = 2, "fixture moves"
items.generation, items.source, items.pockets = 2, "fixture items", {}
for index, id in ipairs(CONTINUATIONS) do
pokemon[id] = species(id, 168 + index, generation)
end
for index, id in ipairs(MOVES) do
moves[id] = { id = id, name = id, index = index, type = "STEEL",
power = 50, accuracy = 100, pp = 15, effect = "NO_ADDITIONAL_EFFECT" }
end
typeChart.types.STEEL = { name = "STEEL", category = "physical", index = 9 }
typeChart.foresightMatchups = {
{ attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
}
items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", price = 0,
heldEffect = "HELD_LEFTOVERS", heldParameter = 0 }
end
return {
constants = {}, maps = {}, tilesets = {}, text = {}, text_pointers = {},
trainer_headers = {}, font = {}, sprites = {}, pokemon = pokemon,
moves = moves, items = items, type_chart = typeChart, trainers = {},
encounters = {}, field = { oakSpeech = {} }, battle_anims = {},
audio = {}, palettes = {}, icons = {}, landmarks = {},
}
end
function Fixture.cache(files, version, overrides)
local prefix = GameVersion.cachePrefix(version)
local generation = GameVersion.generation(version)
local values = defaults(version)
for name, value in pairs(overrides or {}) do values[name] = value end
files[prefix .. "rom-cache.complete"] =
CacheContract.markerFor(version)
for _, name in ipairs(generation == 2 and GEN2_MODULES or GEN1_MODULES) do
local value = values[name]
files[prefix .. "data/generated/" .. name .. ".lua"] =
type(value) == "string" and value or LuaWriter.encode(value or {})
end
if generation == 1 then
for _, name in ipairs(GEN1_OPTIONAL_MODULES) do
files[prefix .. "data/generated/" .. name .. ".lua"] =
LuaWriter.encode(values[name] or {})
end
end
for _, path in ipairs(CacheContract.requiredFiles(version)) do
if files[prefix .. path] == nil then
files[prefix .. path] = path:match("%.lua$") and LuaWriter.encode({}) or "fixture"
end
end
if type(values.pokemon) == "table" then
for _, row in pairs(values.pokemon) do
if type(row) == "table" and row.spriteFront then
files[prefix .. row.spriteFront] = "front"
files[prefix .. row.spriteBack] = "back"
end
end
end
return files
end
function Fixture.addMod(files, id, body)
files["mods/" .. id .. "/manifest.json"] = ([[{
"id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua",
"api": 2, "games": ["all"]
}]]):format(id, id)
files["mods/" .. id .. "/main.lua"] = body
return "mods/" .. id
end
Fixture.GEN1_MODULES = GEN1_MODULES
Fixture.GEN2_MODULES = GEN2_MODULES
Fixture.CONTINUATIONS = CONTINUATIONS
Fixture.MOVES = MOVES
return Fixture
+9 -9
View File
@@ -229,16 +229,16 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL")
"DUX post-trade script completes")
shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset")
-- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText,
-- ball hidden (GivePokemon -> AddPartyMon AskName; script still
-- prints GotMonText after the silent give_pokemon row) ===
-- === 9) Celadon Eevee: no confirm prompt, GotMonText then AskName,
-- ball hidden (GivePokemon -> SetPokedexOwnedFlag prints GotMonText
-- before AddPartyMon's AskName) ===
local EEVEE_MAP, EEVEE_BALL =
"CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
Game.save = SaveData.newGame()
check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes")
shownIs({ "_DoYouWantToNicknameText", "_GotMonText" },
"Eevee gives immediately (nickname ask, then GotMonText)")
shownIs({ "_GotMonText", "_DoYouWantToNicknameText" },
"Eevee gives immediately (GotMonText, then nickname ask)")
eq(#Game.save.party, 1, "Eevee joins the party")
eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE")
eq(Game.save.party[1].level, 25, "Eevee is level 25")
@@ -261,13 +261,13 @@ shownIs({}, "old save: silent")
eq(#Game.save.party, 0, "old save: no Eevee re-gift")
eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden")
-- === 11) GivePokemon party full, box has room: AskName + SentToBoxText
-- (SendNewMonToBox), then script GotMonText; ball hidden ===
-- === 11) GivePokemon party full, box has room: GotMonText, then AskName
-- + SentToBoxText (SendNewMonToBox); ball hidden ===
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-party Eevee script completes")
shownIs({ "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText" },
"full-party gift: nickname, sent-to-box, then GotMonText")
shownIs({ "_GotMonText", "_DoYouWantToNicknameText", "_SentToBoxText" },
"full-party gift: GotMonText, nickname, then sent-to-box")
eq(#Game.save.party, 6, "full-party gift leaves party size at 6")
local Boxes = require("src.pokemon.Boxes")
local boxed = false
+11 -1
View File
@@ -20,7 +20,8 @@ local yellow = GameVersion.isYellow()
-- pokered/scripts/*.asm, PlayCry line in brackets:
-- SSAnne1FRooms.asm:66 [:70], SSAnneB1FRooms.asm:82 [:86],
-- VermilionPidgeyHouse.asm:15 [:19], VermilionCity.asm:224 [:228],
-- PokemonFanClub.asm:71 [:76] and :84 [:89].
-- PokemonFanClub.asm:71 [:76] and :84 [:89],
-- MrFujisHouse.asm:56 [:60] and :63 [:67], LavenderCuboneHouse.asm:10 [:14].
local PETS = {
{ map = "SS_ANNE_1F_ROOMS", const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF",
object = "SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF",
@@ -40,6 +41,15 @@ local PETS = {
{ map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_SEEL",
object = "POKEMONFANCLUB_SEEL", species = "SEEL",
label = "_PokemonFanClubSeelText" },
{ map = "MR_FUJIS_HOUSE", const = "TEXT_MRFUJISHOUSE_PSYDUCK",
object = "MRFUJISHOUSE_PSYDUCK", species = "PSYDUCK",
label = "_MrFujisHousePsyduckText" },
{ map = "MR_FUJIS_HOUSE", const = "TEXT_MRFUJISHOUSE_NIDORINO",
object = "MRFUJISHOUSE_NIDORINO", species = "NIDORINO",
label = "_MrFujisHouseNidorinoText" },
{ map = "LAVENDER_CUBONE_HOUSE", const = "TEXT_LAVENDERCUBONEHOUSE_CUBONE",
object = "LAVENDERCUBONEHOUSE_CUBONE", species = "CUBONE",
label = "_LavenderCuboneHouseCuboneText" },
}
if yellow then
+144 -9
View File
@@ -44,18 +44,22 @@ print("Running SurfingMinigame unit tests...")
local mg = SurfingMinigame.new(mockGame)
assert_eq(mg.routine, -1, "Initial routine must be ROUTINE_TITLE (-1)")
mg:startFromTitle()
assert_eq(mg.routine, 0, "Routine must advance to ROUTINE_START_GAME (0) after startFromTitle()")
assert_eq(mg.routine, 0, "Routine must be ROUTINE_START_GAME (0) after startFromTitle()")
assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)")
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25")
assert_eq(mg.distance, 0, "Initial distance must be 0")
assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)")
print("✓ Initial state & Title transition test passed")
-- Test 2: Start banner transition to RunGame
for _ = 1, 40 do
-- Test 2: StartGame one-shot then RunGame; banner slides during play (pret-accurate)
mg:update()
assert_eq(mg.routine, 1, "First tick should advance to ROUTINE_RUN_GAME (1)")
assert_true(mg.startBannerX > 80, "START banner should begin off-screen")
for _ = 1, 36 do
mg:update()
end
assert_eq(mg.routine, 1, "Routine should advance to ROUTINE_RUN_GAME (1)")
assert_eq(mg.routine, 1, "Routine should remain ROUTINE_RUN_GAME (1) while banner slides")
assert_eq(mg.startBannerX, 80, "START banner should finish centered after 36 frames")
print("✓ Start banner transition test passed")
-- Test 3: Automatic acceleration and HP countdown
@@ -68,7 +72,7 @@ print("✓ Auto acceleration and HP countdown test passed")
-- Test 4: Landing Evaluation Matrix
local old_getWaveTile = mg.getWaveTileUnderPika
mg.getWaveTileUnderPika = function() return 0x01 end -- force open water
mg.getWaveTileUnderPika = function() return 0x01 end -- force open water (flat branch)
mg.frameSet = 5
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
mg.frameSet = 6
@@ -82,6 +86,17 @@ for f = 8, 14 do
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
end
mg.getWaveTileUnderPika = old_getWaveTile
-- TileInteraction keys off chr tile ids, not pattern metatile ids
mg.getWaveTileUnderPika = function() return 0x06 end
mg.frameSet = 6
assert_eq(mg:evaluateLanding(), "clean", "Frame 6 on chr tile $06 rising slope must be clean")
mg.getWaveTileUnderPika = function() return 0x0b end
mg.frameSet = 6
assert_eq(mg:evaluateLanding(), "hard", "Frame 6 on chr tile $0b open water must be hard")
mg.getWaveTileUnderPika = function() return 0x08 end
mg.frameSet = 6
assert_eq(mg:evaluateLanding(), "hard", "Frame 6 on chr tile $08 foam must use flat rules (hard)")
mg.getWaveTileUnderPika = old_getWaveTile
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
-- Test 5: Stunt Scoring
@@ -129,13 +144,15 @@ assert_eq(mg.pikaState, 0, "Pikachu should recover and return to PIKA_STATE_RIDI
print("✓ Wipeout crash recovery test passed")
-- Test 7: Results tally countdown sequence
mg.showResultsCard = true
mg.routine = 7 -- ROUTINE_WRITE_TOTAL
mg.hp = 100
mg.radness = 200
mg.totalScore = 0
mg.routineTimer = 1
mg.routineTimer = 0
mg:update()
assert_eq(mg.routine, 8, "Routine should advance to ROUTINE_ADD_HP_TOTAL (8)")
mg.routineTimer = 0 -- pret waits 64 frames before tally; skip for unit test
while mg.routine == 8 do
mg:update()
@@ -143,6 +160,7 @@ end
assert_eq(mg.hp, 0, "HP should be tallied down to 0")
assert_eq(mg.totalScore, 100, "Total score should include 100 from HP")
assert_eq(mg.routine, 9, "Routine should advance to ROUTINE_ADD_RAD_TOTAL (9)")
mg.routineTimer = 0
while mg.routine == 9 do
mg:update()
@@ -153,7 +171,8 @@ assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)")
-- Test 8: Crossing finish line while jumping upside-down crashes into water and rights Pikachu before results
local mg8 = SurfingMinigame.new(mockGame, nil, true)
mg8.routine = 1 -- ROUTINE_RUN_GAME
mg8.distanceFixed = (24 * 128 - 2) * 256
mg8.distanceSection = 24
mg8.distanceAcc = 0
mg8.speedFixed = 512
mg8.pikaState = 1 -- PIKA_STATE_JUMPING
mg8.frameSet = 11 -- Upside down
@@ -193,7 +212,8 @@ print("✓ Mid-air upside-down finish line crossing crash & recovery test passed
-- Test 9: Crossing finish line while upright jumping lands cleanly and proceeds
local mg9 = SurfingMinigame.new(mockGame, nil, true)
mg9.routine = 1
mg9.distanceFixed = (24 * 128 - 2) * 256
mg9.distanceSection = 24
mg9.distanceAcc = 0
mg9.speedFixed = 512
mg9.pikaState = 1
mg9.frameSet = 4 -- Clean flat
@@ -221,7 +241,8 @@ print("✓ Mid-air upright finish line crossing test passed")
-- Test 10: Crossing finish line while already crashed recovers before results
local mg10 = SurfingMinigame.new(mockGame, nil, true)
mg10.routine = 1
mg10.distanceFixed = (24 * 128 - 2) * 256
mg10.distanceSection = 24
mg10.distanceAcc = 0
mg10.speedFixed = 512
mg10.pikaState = 3 -- PIKA_STATE_CRASHED
mg10.crashTimer = 50
@@ -260,12 +281,39 @@ mg12.landingTimer = 20
mg12.speedFixed = 256
-- Place on a rising wave pattern
mg12.cols[5] = { pat = SurfingMinigame.WAVE_PATTERNS[0x06], hl = 110, hr = 100 }
mg12.bgMap[5] = mg12.cols[5]
mg12.waveHeight[8] = 110
mg12.waveHeight[9] = 100
mg12.bgMapReadTile = 0x06
mg12.advanceScx = function() end
mg12.generateBgMapIfNeeded = function() end
mg12.distanceFixed = (5 * 16 - 80) * 256
local startY = mg12.pikaY
mg12:update()
assert(mg12.pikaY ~= startY, "pikaY must continuously follow wave surface height while in PIKA_STATE_LANDING")
print("✓ Landing slope height tracking continuity test passed")
-- Test 12b: Clean flat landing preserves speed; rough/hard use pret penalties
local mg12b = SurfingMinigame.new(mockGame, nil, true)
mg12b.routine = 1
mg12b.frameSet = 4
mg12b.bgMapReadTile = 0x0b
mg12b.speedFixed = 320
local cleanSpeed = mg12b.speedFixed
mg12b:handleLanding()
assert_eq(mg12b.speedFixed, cleanSpeed, "Clean flat landing must not reduce speed")
mg12b.speedFixed = 320
mg12b.frameSet = 5
mg12b.bgMapReadTile = 0x0b
mg12b:handleLanding()
assert_eq(mg12b.speedFixed, 320 - 64, "Rough flat landing must reduce speed by 0.25")
mg12b.speedFixed = 96
mg12b.frameSet = 6
mg12b.bgMapReadTile = 0x0b
mg12b:handleLanding()
assert_eq(mg12b.speedFixed, 0, "Hard landing below 0.5 must zero speed (pret underflow guard)")
print("✓ Landing speed penalty test passed")
-- Test 13: Fixed speed enforcement (minigames must always run at 1X speed)
assert(mg12.isFixedSpeed == true, "SurfingMinigame must have isFixedSpeed flag enabled")
assert(mg12.isMinigame == true, "SurfingMinigame must have isMinigame flag enabled")
@@ -274,4 +322,91 @@ local Game = require("src.core.Game")
assert(Game.isFixedSpeedInStack(mockStack) == true, "Game.isFixedSpeedInStack must return true for SurfingMinigame")
print("✓ Minigame fixed speed enforcement test passed")
-- Test 14: Course duration matches pret distance model (~24 sections, 6000 HP cap)
local mg14 = SurfingMinigame.new(mockGame, nil, true)
mg14.routine = 1
local runFrames = 0
for _ = 1, 7000 do
mg14:update()
runFrames = runFrames + 1
if mg14.distanceSection >= 24 then break end
end
assert(runFrames >= 2500 and runFrames <= 5500,
"Full course should finish in pret-like frame window (got " .. runFrames .. ")")
assert(mg14.hp > 0, "Typical run should reach shore before HP timer expires")
print("✓ Course duration window test passed (" .. runFrames .. " frames)")
-- Test 15: Pret 2-frame joy sampling allows triple flips at max jump arc
mockInput.keysDown = { left = true }
local mg15 = SurfingMinigame.new(mockGame, nil, true)
mg15.routine = 1
mg15.speedFixed = 512
mg15.pikaState = 1
mg15.jumpArcMagnitude = 16
mg15.jumpArcFraction = 0
mg15.jumpDescending = false
mg15.pikaY = 84
mg15.frameSet = 4
mg15.radnessMeter = 0
mg15.trickFlags = 0
local airFrames = 0
while mg15.pikaState == 1 and airFrames < 200 do
mg15:update()
airFrames = airFrames + 1
end
assert_true(mg15.radnessMeter >= 3,
"Max-arc jump with held left must register at least 3 flips (got " .. tostring(mg15.radnessMeter) .. ")")
assert_true(airFrames >= 60, "Max-arc jump should stay airborne long enough for triple flips")
print("✓ Triple flip airtime and joy sampling test passed")
-- Test 16: Pret music tempo tiers (index = high byte of ((speed & $3ff) << 1))
local mg16 = SurfingMinigame.new(mockGame, nil, true)
mg16.routine = 1
local tempoCases = {
{ speed = 64, tier = 1 },
{ speed = 127, tier = 1 },
{ speed = 128, tier = 2 },
{ speed = 255, tier = 2 },
{ speed = 256, tier = 3 },
{ speed = 383, tier = 3 },
{ speed = 384, tier = 4 },
{ speed = 511, tier = 4 },
{ speed = 512, tier = 5 },
}
for _, c in ipairs(tempoCases) do
mg16.speedFixed = c.speed
mg16:updateTempo()
local wantPitch = ({ 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 })[c.tier]
assert_eq(mg16.currentPitch, wantPitch,
string.format("Tempo tier %d at speed %d/256", c.tier, c.speed))
end
print("✓ Pret music tempo tier boundaries test passed")
-- Test 17: GetJoypad_3FrameBuffer duty cycle (hFrameCounter reload $2 → sample when counter hits 0)
mockInput.keysDown = { left = true }
local mg17 = SurfingMinigame.new(mockGame, nil, true)
mg17.routine = 1
local samples = 0
for _ = 1, 9 do
mg17:update()
if mg17.joy5Left then samples = samples + 1 end
end
-- Counter reloads to 2 then VBlank decrements twice → active on frames 1,3,5,7,9 of each 9-frame window
assert_eq(samples, 5, "Held input must register 5 sample frames per 9 ticks (got " .. samples .. ")")
print("✓ Pret joypad 3-frame buffer duty cycle test passed")
-- Test 18: Results card keeps Pikachu on the beach (pret DrawResultsScreen + UpdateResultsPikachu)
local mg18 = SurfingMinigame.new(mockGame, nil, true)
mg18:beginResultsCard()
assert_eq(mg18.pikaY, 116 - 16, "Results Pikachu Y must anchor at flat waterline")
assert_eq(mg18.frameSet, 4, "Results pose must use flat ride frameset")
assert_true(mg18.showResultsCard, "Results card must be active")
assert_eq(mg18.cloudSpriteX, nil, "Cloud OAM must be cleared on results screen")
mg18.pikaState = 6 -- PIKA_STATE_RESULTS (pret sets this on hi-score)
mg18.resultsBobTimer = 62
mg18:updateResultsPikachu()
assert_true(mg18.pikaYOffset ~= 0 or mg18.resultsBobTimer >= 64,
"Results bob must run once PIKA_STATE_RESULTS is active")
print("✓ Results beach Pikachu visibility test passed")
print("All SurfingMinigame unit tests passed successfully!")
+8 -4
View File
@@ -2064,10 +2064,14 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
raw_2bpp("SurfingPikachu1Graphics2", 128, 128, "minigame/surf_1b.png", transparent=True)
raw_2bpp("SurfingPikachu1Graphics3", 96, 96, "minigame/surf_1c.png", transparent=True)
beach_intro = rom.bytes(62, 0x50bc, 240)
use_ctrl_pad = rom.bytes(62, 0x51ac, 15)
to_surf_rad = rom.bytes(62, 0x51bb, 13)
title_map = rom.bytes(62, 0x51c8, 72)
beach_sym = _symbol(symbols, "SurfingMinigame_BeachIntroTilemap")
use_ctrl_sym = _symbol(symbols, "SurfingMinigame_UseControlPadTilemap")
to_surf_sym = _symbol(symbols, "SurfingMinigame_ToSurfRadTilemap")
title_sym = _symbol(symbols, "SurfingMinigame_TitleTilemap")
beach_intro = rom.bytes(beach_sym.bank, beach_sym.address, 240)
use_ctrl_pad = rom.bytes(use_ctrl_sym.bank, use_ctrl_sym.address, 15)
to_surf_rad = rom.bytes(to_surf_sym.bank, to_surf_sym.address, 13)
title_map = rom.bytes(title_sym.bank, title_sym.address, 72)
screen = [0xff] * (20 * 18)
for i in range(240):
screen[6 * 20 + i] = beach_intro[i]
+41
View File
@@ -98,6 +98,12 @@ YELLOW_EXTRA_SYMBOLS = (
"SurfingPikachu1Graphics1",
"SurfingPikachu1Graphics2",
"SurfingPikachu1Graphics3",
# Surfing Pikachu title screen tilemaps
# (engine/minigame/surfing_pikachu.asm DrawSurfingPikachuMinigameIntroBackground)
"SurfingMinigame_BeachIntroTilemap",
"SurfingMinigame_TitleTilemap",
"SurfingMinigame_ToSurfRadTilemap",
"SurfingMinigame_UseControlPadTilemap",
# Oak's own battle back pic. LoadPlayerBackPic (engine/battle/core.asm)
# picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
# BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557).
@@ -505,6 +511,41 @@ def derive(red, pokeyellow, symbols_path):
if "SUMMER_BEACH_HOUSE" not in locations and "ROUTE_19" in locations:
locations["SUMMER_BEACH_HOUSE"] = dict(locations["ROUTE_19"])
# Surfing Pikachu minigame asset index (src/ui/SurfingMinigame.lua).
yellow["field"]["surfingPikachu"] = {
"music": "Music_SurfingPikachu",
"sheets": {
"bg": {
"path": "assets/generated/minigame/surf_1a.png",
"width": 40, "height": 104,
},
"oam": {
"path": "assets/generated/minigame/surf_1b.png",
"width": 128, "height": 128,
},
"intro": {
"path": "assets/generated/minigame/surf_1c.png",
"width": 96, "height": 96,
"introPikaFrames": [
"assets/generated/minigame/intro_pika_0.png",
"assets/generated/minigame/intro_pika_1.png",
"assets/generated/minigame/intro_pika_2.png",
"assets/generated/minigame/intro_pika_3.png",
],
"source": "data/sprite_anims/surfing_pikachu_oam.asm .IntroPikachu",
},
"titleBg": {
"path": "assets/generated/minigame/title_bg.png",
"width": 160, "height": 144,
},
},
"source": (
"engine/minigame/surfing_pikachu.asm "
"(DrawSurfingPikachuMinigameIntroBackground), "
"gfx/surfing_pikachu.asm"
),
}
meta = {
"aliasCount": alias_hits,
"omittedIntro": list(OMIT_INTRO_SYMBOLS),
+49
View File
@@ -8861,6 +8861,39 @@
}
]
},
"surfingPikachu": {
"music": "Music_SurfingPikachu",
"sheets": {
"bg": {
"height": 104,
"path": "assets/generated/minigame/surf_1a.png",
"width": 40
},
"intro": {
"height": 96,
"introPikaFrames": [
"assets/generated/minigame/intro_pika_0.png",
"assets/generated/minigame/intro_pika_1.png",
"assets/generated/minigame/intro_pika_2.png",
"assets/generated/minigame/intro_pika_3.png"
],
"path": "assets/generated/minigame/surf_1c.png",
"source": "data/sprite_anims/surfing_pikachu_oam.asm .IntroPikachu",
"width": 96
},
"oam": {
"height": 128,
"path": "assets/generated/minigame/surf_1b.png",
"width": 128
},
"titleBg": {
"height": 144,
"path": "assets/generated/minigame/title_bg.png",
"width": 160
}
},
"source": "engine/minigame/surfing_pikachu.asm (DrawSurfingPikachuMinigameIntroBackground), gfx/surfing_pikachu.asm"
},
"tilePairs": {
"land": [
{
@@ -26288,6 +26321,22 @@
32,
25380
],
"SurfingMinigame_BeachIntroTilemap": [
62,
20668
],
"SurfingMinigame_TitleTilemap": [
62,
20936
],
"SurfingMinigame_ToSurfRadTilemap": [
62,
20923
],
"SurfingMinigame_UseControlPadTilemap": [
62,
20908
],
"SurfingPikachuSprite": [
63,
28143
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2024"
[lib]
name = "librashader_bridge"
crate-type = ["cdylib", "rlib"]
crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
librashader-preprocess = "0.12.0"