diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bd82d7fc..1aeb4cd3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,8 @@ body: - Blue - Yellow - Gold + - Silver + - Crystal - N/A validations: required: true diff --git a/.gitignore b/.gitignore index 16d74675..5053e814 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index d7982e97..c34e38d2 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -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 }, }, } diff --git a/data/scripts/flavor/celadon_city.lua b/data/scripts/flavor/celadon_city.lua index 5d0cb328..06add996 100644 --- a/data/scripts/flavor/celadon_city.lua +++ b/data/scripts/flavor/celadon_city.lua @@ -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"}, }, }, diff --git a/data/scripts/flavor/celadon_mansion_1f.lua b/data/scripts/flavor/celadon_mansion_1f.lua index 9e8c3f3b..3e77b45c 100644 --- a/data/scripts/flavor/celadon_mansion_1f.lua +++ b/data/scripts/flavor/celadon_mansion_1f.lua @@ -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"}, }, }, diff --git a/data/scripts/flavor/lavender_cubone_house.lua b/data/scripts/flavor/lavender_cubone_house.lua index a5981cbe..54aa3d04 100644 --- a/data/scripts/flavor/lavender_cubone_house.lua +++ b/data/scripts/flavor/lavender_cubone_house.lua @@ -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" }, }, diff --git a/data/scripts/flavor/mr_fujis_house.lua b/data/scripts/flavor/mr_fujis_house.lua index 6e1a7ae0..4022add5 100644 --- a/data/scripts/flavor/mr_fujis_house.lua +++ b/data/scripts/flavor/mr_fujis_house.lua @@ -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" }, }, }, diff --git a/data/scripts/flavor/route_16_fly_house.lua b/data/scripts/flavor/route_16_fly_house.lua index 563c1a30..6f348382 100644 --- a/data/scripts/flavor/route_16_fly_house.lua +++ b/data/scripts/flavor/route_16_fly_house.lua @@ -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" }, }, }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 372a1836..ec5435a2 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -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 diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 18c6658c..8069bf09 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -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, }, diff --git a/docs/modding.md b/docs/modding.md index 28a38c1d..55f8ee82 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 = , mon = , name = , + -- 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 diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 929299f7..96693e63 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -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` diff --git a/docs/rfcs/0015-imported-dataset-views.md b/docs/rfcs/0015-imported-dataset-views.md new file mode 100644 index 00000000..829c8a8f --- /dev/null +++ b/docs/rfcs/0015-imported-dataset-views.md @@ -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. diff --git a/docs/rfcs/0018-catch-party-full.md b/docs/rfcs/0018-catch-party-full.md new file mode 100644 index 00000000..6658c434 --- /dev/null +++ b/docs/rfcs/0018-catch-party-full.md @@ -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 = , mon = , name = , + -- 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. diff --git a/docs/shaderfx.md b/docs/shaderfx.md index 03e34c5b..3fe60c70 100644 --- a/docs/shaderfx.md +++ b/docs/shaderfx.md @@ -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//` 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 +`/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 diff --git a/main.lua b/main.lua index 8220c9c2..52a4a1f2 100644 --- a/main.lua +++ b/main.lua @@ -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) diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 8718896b..0b29e2db 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -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`. diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 37f48b4e..3eb908e8 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -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. diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 7451e87a..49917a19 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -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" diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 9c7fac93..55688193 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -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" diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 300cc345..0130903c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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)) diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 894bfd44..929fff89 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -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 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 39d96387..d6abf537 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -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 diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index 499093e8..d681ecf5 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -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 diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index ad459574..32508356 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -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 diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 2b0b0543..b3efe15b 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -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 diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index a36f8ea3..2e2b87a4 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -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 diff --git a/src/battle/gen2/Prize.lua b/src/battle/gen2/Prize.lua index 54fef6ae..cdd92e95 100644 --- a/src/battle/gen2/Prize.lua +++ b/src/battle/gen2/Prize.lua @@ -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. diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 68f171ab..7c0c57ba 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -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, } diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index b933bd7c..8a62eb4e 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -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", } diff --git a/src/core/Data.lua b/src/core/Data.lua index 31d7932a..935739a9 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -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 diff --git a/src/core/DatasetHydration.lua b/src/core/DatasetHydration.lua new file mode 100644 index 00000000..003cc3bf --- /dev/null +++ b/src/core/DatasetHydration.lua @@ -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 diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 792df70a..6a03da6a 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -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 diff --git a/src/core/Game.lua b/src/core/Game.lua index 2cc890d7..72a13157 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22d914ff..44390f1a 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7263d2b0..dde0f760 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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 diff --git a/src/core/SaveSerializer.lua b/src/core/SaveSerializer.lua index 9f821f67..7adbae86 100644 --- a/src/core/SaveSerializer.lua +++ b/src/core/SaveSerializer.lua @@ -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 diff --git a/src/core/Sensors.lua b/src/core/Sensors.lua index 446eb283..940f71f6 100644 --- a/src/core/Sensors.lua +++ b/src/core/Sensors.lua @@ -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 diff --git a/src/core/SessionLifecycle.lua b/src/core/SessionLifecycle.lua index 84508a67..815a00d4 100644 --- a/src/core/SessionLifecycle.lua +++ b/src/core/SessionLifecycle.lua @@ -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") diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 01a82299..efb59f2f 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -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 diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 50c28048..3df41e2c 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -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 }, diff --git a/src/core/Version.lua b/src/core/Version.lua index 0424bc82..184d8431 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -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) } diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 21c59f8b..88de0199 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -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 diff --git a/src/core/gen2/MomShopping.lua b/src/core/gen2/MomShopping.lua index a061f9b5..1af7cad0 100644 --- a/src/core/gen2/MomShopping.lua +++ b/src/core/gen2/MomShopping.lua @@ -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 diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index dd10851c..ad84293a 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -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 diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 1937cb21..3e4c46b4 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -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 diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 4b86d5cb..deedb517 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -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) diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 3739dd8a..ecba935c 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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 = { diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index dbe4778e..1c54d58b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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) diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 6c4319cc..a1b59884 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -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 diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 151ffd71..2719dc76 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cff73978..c385786b 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 2915b4eb..f0ecadcd 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -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 diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 66695a17..b5cd4143 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -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 diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua new file mode 100644 index 00000000..ef44d6e2 --- /dev/null +++ b/src/mods/DatasetViews.lua @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5b24bcd7..ab10fb2e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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, diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 3c7ff4bb..a98e0ca6 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -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 = {} diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index f07c03c9..2b6e8d38 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -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)), diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index fa9250df..6b956889 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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 diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 5862e66d..4a4a7739 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -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 diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 3646939a..627e701a 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -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 diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index 4feb0155..57472ba2 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -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 diff --git a/src/ui/Diploma.lua b/src/ui/Diploma.lua index ddcd6c73..e957e12c 100644 --- a/src/ui/Diploma.lua +++ b/src/ui/Diploma.lua @@ -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 diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 5f4fc75f..1ca02256 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -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 diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 366cfb86..7e2fdc3d 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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 diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index bc951f98..207d4aaf 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -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) diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index d7a111d8..4787f13c 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -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) diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 91e236e1..846bd1a3 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -9,7 +9,7 @@ -- - Input accumulator eliminating the 3-frame polling blind spot -- - GB hardware VBlank execution order (collision boundary checked before position update) -- - Deterministic Game Boy DIV/Random LFSR wave sequence generator --- - 14-angle rotation model with 3-frame buffered input (Right = frontflip, Left = backflip) +-- - 14-angle rotation model with 3-frame joy duty cycle (Right = frontflip, Left = backflip) -- - Stunt scoring: +50 (single), +150 (double same), +350 (triple same), +180 (mixed), +500 (triple mixed) -- - Tile interaction landing matrix (Clean, Rough -64, Hard -128, Crash/Wipeout) -- - Non-fatal crash recovery: Pikachu wipes out for 96 frames, resets speed to 64 (0.25), and continues @@ -52,18 +52,57 @@ end SurfingMinigame.isOpaque = true -- Fixed-point physics constants (256 = 1.0 px/frame) +-- Pret metric audit (surfing_pikachu.asm): +-- Speed: init 0.25, max 2.0 (high byte cp $2), +1/128/frame, jump min GetSpeedDividedBy32 >= $a +-- Penalties: rough -0.25, hard -0.5, wipeout reset 0.25 (underflow guards at 0.25 / 0.5) +-- HP: $6000 BCD, -1/frame; course: 24 sections (distance byte cp $18); Big Kahuna at section $16 +-- BG scroll: 1.5 px/frame; coast: 9.0 px/frame for 192 frames; crash $60 frames; landing splash $20/4 +-- Flips: left cp $b, right cp $d; radness meter caps at $3; trick flags bits 0=left 1=right +-- Joypad: SurfingPikachu_GetJoypad_3FrameBuffer reloads hFrameCounter with $2 (1 sample + 2 blank frames) +-- Music tempo index: high byte of ((speed & $3ff) << 1) → tiers at speed 128/256/384/512 local SPEED_INITIAL = 64 -- 0.25 * 256 -local SPEED_MAX = 512 -- 2.00 * 256 -local SPEED_ACCEL = 2 -- (1/128) * 256 +local SPEED_MAX = 512 -- 2.00 * 256 (SpeedUpPikachu: high byte cp $2) +local SPEED_ACCEL = 2 -- 0.0078125 = 1/128 px/frame in 8.8 fixed local SPEED_ROUGH_PENALTY = 64 -- 0.25 * 256 local SPEED_HARD_PENALTY = 128 -- 0.50 * 256 -local SPEED_JUMP_THRESHOLD = 320 -- 1.25 * 256 (10/32) +local PIKA_SPRITE_OFFSET = 16 -- sprite anchor above wSurfingMinigamePikachuObjectHeight +local SPEED_JUMP_MIN_DIV32 = 10 -- TryStartJump: GetSpeedDividedBy32 cp $a (speed >= 1.25) +local FLIP_LEFT_FRAMES = 11 -- DPadAction .dLeft cp $b +local FLIP_RIGHT_FRAMES = 13 -- DPadAction .dRight cp $d +local RADNESS_METER_MAX = 3 -- IncreaseRadnessMeter cap +local JOY_FRAME_RELOAD = 2 -- GetJoypad_3FrameBuffer: ld a, $2 +local CRASH_FRAMES = 96 -- UpdateCrashedPikachu initial timer $60 +local COAST_FRAMES = 192 -- WaitToShowResults routine delay +local GAME_OVER_DELAY = 128 -- Game over before accepting A ($80) +local LANDING_SPLASH_FRAMES = 8 -- FIELD_C += 4 until cp $20 +local RESULTS_FRAMESET_INIT = 0x0f -- InitResultsPikachu: frameset ID written to ANIM_OBJ_FRAME_SET +local RESULTS_BOB_CYCLE = 64 -- UpdateResultsPikachu FIELD_C & $3f +local RESULTS_BOB_START = 32 -- sine bob only when FIELD_C >= $20 +local RESULTS_BOB_AMP = 2 -- SurfingPikachu_Sine scale ($10 table; ~2px on screen) +local RESULTS_PIKA_BASES = { 0xa0, 0xa3 } -- .ResultsPikachu OAM frame pair -- Original constants (surfing_pikachu.asm) local FLAT_WATER_Y = 116 -- OAM Y 0x74 = screen Y 100 (PIKA_Y = FLAT_WATER_Y - 16) local PIKA_X = 68 -- Fixed screen X while riding (center 80, 24px pose) -local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course +local TOTAL_SECTIONS = 24 -- wSurfingMinigameDistance byte 0 reaches $18 (24) +local SECTION_ACC_MAX = 65536 -- 16-bit distance acc overflow (256px per section in 8.8 fixed) +local SECTION_PX = 256 -- pixels advanced per section (65536 >> 8) local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144) +local COAST_SPEED_FIXED = 9 * 256 -- SurfingMinigame_CoastAfterGoal: 9.0 px/frame +local BG_SCROLL_STEP = 384 -- ScrollAndGenerateBGMap: 1.5 px/frame (8.8 fixed) +local RDIV_PER_FRAME = 17 -- rDIV @ 16384 Hz ≈ 273 ticks per 59.7275 Hz frame (mod 256) +local COAST_X_OFFSET = 160 -- $a0 ahead of viewport (CoastAfterGoal) +local OUTRO_SCROLL_X_OFFSET = 224 -- TILEMAP_WIDTH_PX - 32 (ScrollToResultsScreen) +local OUTRO_SCROLL_START = 144 -- hSCX $90 at results transition +local OUTRO_SCROLL_STEP = 4 -- hSCX -= 4 per frame (36 frames total) +local FINISH_DISTANCE_FIXED = TOTAL_SECTIONS * SECTION_PX * 256 +local MAP_COLS = 16 -- vBGMap0 metatile columns (256px / 16) +-- SurfingPikachuMinigame_InitStaticSpriteLayout cloud OAM X coords (9 sprites) +local CLOUD_SPRITE_X_INIT = { 32, 40, 48, 56, 64, 128, 136, 144, 152 } + +local function scrollPx(self) + return math.floor((self.scrollFixed or 0) / 256) +end -- Routine numbers (wSurfingMinigameRoutineNumber) local ROUTINE_TITLE = -1 @@ -81,6 +120,95 @@ local ROUTINE_WAIT_LAST = 10 local ROUTINE_EXIT_ON_PRESS_A = 11 local ROUTINE_GAME_OVER = 12 +local function isOutroScroll(self) + return self.routine == ROUTINE_WAIT_RESULTS or self.routine == ROUTINE_SCROLL_RESULTS +end + +local function displayScx(self) + return self.hScx or 0 +end + +-- SurfingMinigame_UpdateMusicTempo: index = high byte of ((speed & $3ff) << 1) +local function pretTempoTier(speedFixed) + local lo = bit.band(speedFixed, 0xFF) + local hi = bit.band(math.floor(speedFixed / 256), 3) + local shifted = bit.band((lo + hi * 256) * 2, 0xFFFF) + return math.min(4, math.floor(shifted / 256)) + 1 +end + +-- SurfingMinigame_GetSpeedDividedBy32 (speed * 8, high byte) +local function getSpeedDividedBy32(speedFixed) + return math.floor((speedFixed * 8) / 256) +end + +local function pikaSpriteYFromObjectHeight(objectHeightPx) + return math.floor(objectHeightPx or FLAT_WATER_Y) - PIKA_SPRITE_OFFSET +end + +-- SurfingMinigame_ReduceSpeedBy64 / ReduceSpeedBy128 +local function reduceSpeedBy64(speedFixed) + if speedFixed >= 256 then + return speedFixed - SPEED_ROUGH_PENALTY + elseif speedFixed >= SPEED_INITIAL then + return speedFixed - SPEED_ROUGH_PENALTY + end + return 0 +end + +local function reduceSpeedBy128(speedFixed) + if speedFixed >= 256 then + return speedFixed - SPEED_HARD_PENALTY + elseif speedFixed >= SPEED_HARD_PENALTY then + return speedFixed - SPEED_HARD_PENALTY + end + return 0 +end + +local function jumpArcCombined(self) + return (self.jumpArcMagnitude or 0) * 256 + (self.jumpArcFraction or 0) +end + +local function setJumpArcCombined(self, value) + if value < 0 then value = 0 end + self.jumpArcMagnitude = math.floor(value / 256) + self.jumpArcFraction = value % 256 +end + +local function applyJumpArcDelta(self, delta) + setJumpArcCombined(self, jumpArcCombined(self) + delta) +end + +local function applyJumpVerticalDelta(self, sign) + local a = self.jumpArcMagnitude or 0 + if a == 0 and (self.jumpArcFraction or 0) == 0 then return end + self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 + local intDelta = math.floor(self.pikaSubY / 256) + self.pikaSubY = self.pikaSubY % 256 + if sign < 0 then + self.pikaY = self.pikaY - intDelta + else + self.pikaY = self.pikaY + intDelta + end +end + +-- pret GenerateBGMap write column: ((hSCX + XOffset) & $f0) / 16 +local function mapColWrite(self) + local xOffset = COAST_X_OFFSET + if self.routine == ROUTINE_SCROLL_RESULTS then + xOffset = OUTRO_SCROLL_X_OFFSET + end + local sum = (displayScx(self) + xOffset) % 256 + return math.floor(bit.band(sum, 0xF0) / 16) % MAP_COLS +end + +local function mapColScreen(scx, screenCol) + return (math.floor(scx / 16) + screenCol) % MAP_COLS +end + +local function mapColAtX(scx, x) + return math.floor((scx + x) / 16) % MAP_COLS +end + -- Pikachu states (wSurfingMinigamePikachuState) local PIKA_STATE_RIDING = 0 local PIKA_STATE_JUMPING = 1 @@ -270,6 +398,25 @@ local OAM_LARGE_SPLASH = { { dy = 4, dx = 8, tile = 0xc8, xflip = true }, } +-- Intro title Pikachu (surfing_pikachu_oam.asm .IntroPikachu, frames $20-$23). +-- Each animation frame adds four to the VRAM tile base ($80, $84, $88, $8c); +-- relative tile ids use the usual 16-wide OBJ row stride ($10 per row). +local INTRO_PIKA_FRAME_BASE = { 0x80, 0x84, 0x88, 0x8c } +local INTRO_PIKA_OAM = { + { dy = -12, dx = -16, tile = 0x03, xflip = true }, + { dy = -12, dx = -8, tile = 0x02, xflip = true }, + { dy = -12, dx = 0, tile = 0x01, xflip = true }, + { dy = -12, dx = 8, tile = 0x00, xflip = true }, + { dy = -4, dx = -16, tile = 0x13, xflip = true }, + { dy = -4, dx = -8, tile = 0x12, xflip = true }, + { dy = -4, dx = 0, tile = 0x11, xflip = true }, + { dy = -4, dx = 8, tile = 0x10, xflip = true }, + { dy = 4, dx = -16, tile = 0x23, xflip = true }, + { dy = 4, dx = -8, tile = 0x22, xflip = true }, + { dy = 4, dx = 0, tile = 0x21, xflip = true }, + { dy = 4, dx = 8, tile = 0x20, xflip = true }, +} + -- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10) local BEACH_OUTRO = { { 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b }, @@ -291,17 +438,28 @@ local PIKACHUS_BEACH_PAL = { { 88, 168, 248 }, -- 2: Ocean Blue Sea Water { 25, 25, 25 }, -- 3: Black Outlines } +-- Title intro uses BG palette slot 1 (PalPacket_PikachusBeachTitle / +-- UnknownPacket_72751) so the logo's shade-2 pixels tint yellow, not sea blue. +local PIKACHUS_BEACH_TITLE_PAL = { + { 255, 255, 255 }, + { 132, 132, 132 }, + { 255, 206, 74 }, + { 25, 25, 25 }, +} -- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm) local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 } function SurfingMinigame.new(game, onDone, skipTitle) local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame) - self.routine = skipTitle and ROUTINE_START_GAME or ROUTINE_TITLE + self.routine = skipTitle and ROUTINE_RUN_GAME or ROUTINE_TITLE self.pikaState = PIKA_STATE_RIDING self.t = 0 self.routineTimer = 0 - self.distanceFixed = 0 -- 16.8 fixed-point (256 = 1 pixel) + self.distanceFixed = 0 -- 16.8 fixed-point course progress (256 = 1 pixel) + self.distanceSection = 0 -- wSurfingMinigameDistance byte 0 (pret big-endian) + self.distanceAcc = 0 -- wSurfingMinigameDistance bytes 1-2 (16-bit, big-endian) + self.scrollFixed = 0 -- BG scroll (hSCX); diverges from distance during outro coast self.speedFixed = SPEED_INITIAL -- 64 = 0.25 px/frame self.hp = 6000 -- starts at 6000 (60.00 seconds) self.radness = 0 -- accumulated trick stunt points @@ -312,24 +470,38 @@ function SurfingMinigame.new(game, onDone, skipTitle) self.isMinigame = true self.isFixedSpeed = true - -- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV) + -- Hardware RNG simulation (pret never resets hRandomAdd/hRandomSub on minigame entry) self.rDiv = 0 self.rAdd = 0x55 self.rSub = 0xaa - -- Wave height tracking & column ring buffer + -- Wave height tracking: pret vBGMap0 (32 metatile columns, circular) self.waveFn = 0 + self.bgMap = {} + for c = 0, MAP_COLS - 1 do + self.bgMap[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } + end + -- Linear cols mirror kept for unit tests only self.cols = {} for c = 0, 10 do self.cols[c] = { pat = WAVE_PATTERNS[0x00], hl = FLAT_WATER_Y, hr = FLAT_WATER_Y } end self.colTail = 10 + -- wSurfingMinigameWaveHeight (20 entries, shifted each GenerateBGMap) + self.waveHeight = {} + for i = 1, 20 do + self.waveHeight[i] = FLAT_WATER_Y + end + self.bgMapReadTile = 0x0b -- wSurfingMinigameBGMapReadBuffer (updated each ReadBGMapBuffer) + self.waveRandomValue = 0 -- wSurfingMinigameWaveRandomValue (Random each RunGame frame) + -- Jumping, Arc Physics & Air Rotation self.pikaY = FLAT_WATER_Y - 16 -- integer screen Y of Pikachu (sea waterline) self.pikaSubY = 0 -- 8.8 subpixel carry (0..255) self.pikaYOffset = 0 -- sine offset (splash/bobbing) - self.jumpArcMagnitude = 0 -- SpeedDividedBy32 (range 10..16) + self.jumpArcMagnitude = 0 -- wSurfingMinigameJumpArcMagnitude (GetSpeedDividedBy32, >= 10) + self.jumpArcFraction = 0 -- wSurfingMinigameJumpArcFraction (8.8 sub-byte) self.jumpDescending = false self.frameSet = 4 -- Starts at Frame 4 (flat horizontal ride) self.boardAngleOffset = 0 -- wobbling 0..2 @@ -338,9 +510,18 @@ function SurfingMinigame.new(game, onDone, skipTitle) self.crashTimer = 0 self.landingTimer = 0 - -- 3-frame buffered D-Pad rotation & input accumulator - self.joyCounter = 0 - self.inputAccum = 0 -- bit 0 = Right, bit 1 = Left + -- Outro coast / results card (SurfingMinigame_WaitToShowResults .. WaitLast) + self.hScx = 0 -- hSCX register (8-bit, wraps) + self.scxFrac = 0 -- wSurfingMinigameSCX fractional byte + self.scxHi = 0 -- wSurfingMinigameSCXHi (zero-init WRAM; band 0 until hSCX >= $10) + self.scxLast = 0 -- wSurfingMinigameSCX2 (last hSCX seen by GenerateBGMap) + self.showResultsCard = false + self.outroLines = { hp = false, rad = false, total = false, hiScore = false } + + -- SurfingPikachu_GetJoypad_3FrameBuffer (hJoy5 + hFrameCounter countdown) + self.joyFrameCounter = 0 + self.joy5Left = false + self.joy5Right = false self.rotCountLeft = 0 self.rotCountRight = 0 self.radnessMeter = 0 -- consecutive flips (capped at 3) @@ -348,11 +529,16 @@ function SurfingMinigame.new(game, onDone, skipTitle) -- Sprites & Popups self.startBannerX = 224 -- slides from 224 to 80 (center) + self.introPikaX = 80 -- intro Pikachu walks off-screen before the run self.ohNoBanner = false self.trickPopups = {} -- { text = "+150", x, y, timer } self.waterSprays = {} -- { x, y, timer } self.sprayTimer = 0 - self.cloudOffsetFixed = 0 + self.cloudScrollFrac = 0 -- wSurfingMinigameCloudScrollFraction + self.cloudSpriteX = {} + for i, x in ipairs(CLOUD_SPRITE_X_INIT) do + self.cloudSpriteX[i] = x + end -- Results Tally Animation self.tallyStep = 0 @@ -403,10 +589,6 @@ function SurfingMinigame.new(game, onDone, skipTitle) for n = 0, 143 do self.iq[n] = love.graphics.newQuad((n % 12) * 8, math.floor(n / 12) * 8, 8, 8, iW, iH) end - -- Pika Intro Poses (24x32 px each in top 32px: X=0, 24, 48) - self.introPikaQuad1 = love.graphics.newQuad(0, 0, 24, 32, iW, iH) - self.introPikaQuad2 = love.graphics.newQuad(24, 0, 24, 32, iW, iH) - self.introPikaQuad3 = love.graphics.newQuad(48, 0, 24, 32, iW, iH) -- Title Banner Logo ("PIKACHU'S BEACH") (96x32 px at Y=32..64) self.introLogoQuad = love.graphics.newQuad(0, 32, 96, 32, iW, iH) -- Instruction Text ("Use Control Pad to Surf") (96x32 px at Y=64..96) @@ -445,33 +627,38 @@ end -- Transition cleanly from Title Screen to active run, flushing input bleed-through function SurfingMinigame:startFromTitle() self.routine = ROUTINE_START_GAME - self.inputAccum = 0 - self.joyCounter = 0 + self.joyFrameCounter = 0 + self.joy5Left = false + self.joy5Right = false self.rotCountLeft = 0 self.rotCountRight = 0 + -- Title screen elapsed frames churn rDIV/LFSR like overworld play before entry. + for _ = 1, self.t + math.floor(self.introPikaX / 2) do + self:getGBRandom() + end self:resetTempo() if self.game and self.game.input and self.game.input.clearPressed then self.game.input:clearPressed() end - Sound.play(self.game and self.game.data, "Press_AB") end --- Authentic Game Boy LFSR Random Number Generator +-- Authentic Game Boy LFSR Random Number Generator (engine/math/random.asm Random_) function SurfingMinigame:getGBRandom() - self.rDiv = (self.rDiv + 13) % 256 - local tempAdd = self.rAdd + self.rDiv - self.rAdd = tempAdd % 256 - local tempSub = self.rSub - self.rDiv - self.rSub = (tempSub + 256) % 256 + local div1 = self.rDiv + self.rDiv = (self.rDiv + 1) % 256 + local div2 = self.rDiv + self.rAdd = (self.rAdd + div1) % 256 + self.rSub = (self.rSub - div2 + 256) % 256 return self.rAdd end function SurfingMinigame:chooseSequence() - local distPx = math.floor(self.distanceFixed / 256) - local section = math.floor(distPx / 128) + local section = self.distanceSection or 0 if section == 0x16 then self.waveFn = 0x6a elseif section < 0x16 then + -- Random at selection time (when waveFn==0 column generates), not frame-start + -- waveRandomValue. Fixed LFSR init otherwise phase-locks to two small sequences. local r = self:getGBRandom() if r ~= 0 then self.waveFn = SEQ_STARTS[bit.band(r - 1, 0x07) + 1] @@ -480,11 +667,35 @@ function SurfingMinigame:chooseSequence() return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y end +-- SurfingMinigame_UpdatePikachuDistance (3-byte big-endian distance + section counter) +function SurfingMinigame:updatePikachuDistance() + local acc = (self.distanceAcc or 0) + self.speedFixed + while acc >= SECTION_ACC_MAX do + acc = acc - SECTION_ACC_MAX + self.distanceSection = (self.distanceSection or 0) + 1 + end + self.distanceAcc = acc + self.distanceFixed = self.distanceSection * SECTION_ACC_MAX + acc +end + +function SurfingMinigame:moveClouds() + -- SurfingMinigame_MoveClouds: add speed high byte to each cloud OAM X (8-bit wrap) + local sum = (self.cloudScrollFrac or 0) + (self.speedFixed or 0) + self.cloudScrollFrac = sum % 256 + local delta = math.floor(sum / 256) + if delta == 0 then return end + for i = 1, #CLOUD_SPRITE_X_INIT do + self.cloudSpriteX[i] = (self.cloudSpriteX[i] + delta) % 256 + end +end + function SurfingMinigame:pushColumn() local pat, hl, hr - local distPx = math.floor(self.distanceFixed / 256) - if distPx >= (TOTAL_SECTIONS * 128) or self.waveFn >= 0x74 then - -- Lock finish line to solid beach sand without cycling back to ocean waves + + if self.routine == ROUTINE_WAIT_RESULTS then + -- CoastAfterGoal with wSurfingMinigameWaveRandomValue = 0 → flat slice + pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y + elseif self.waveFn >= 0x74 then pat, hl, hr = WAVE_PATTERNS["beach"], FLAT_WATER_Y, FLAT_WATER_Y self.waveFn = 0x74 elseif self.waveFn == 0 then @@ -500,55 +711,101 @@ function SurfingMinigame:pushColumn() elseif step[4] == RESET then self.waveFn = 0 end end end - self.colTail = self.colTail + 1 - self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr } - self.cols[self.colTail - 24] = nil + + local col = { pat = pat, hl = hl, hr = hr } + self.bgMap[mapColWrite(self)] = col + + -- Shift wSurfingMinigameWaveHeight left, append new column heights + for i = 1, 18 do + self.waveHeight[i] = self.waveHeight[i + 2] + end + self.waveHeight[19] = hl + self.waveHeight[20] = hr + + if not isOutroScroll(self) then + self.colTail = self.colTail + 1 + self.cols[self.colTail] = col + self.cols[self.colTail - 24] = nil + end end +function SurfingMinigame:advanceScx(deltaFixed) + local sum = (self.hScx or 0) * 256 + (self.scxFrac or 0) + deltaFixed + self.hScx = math.floor(sum / 256) % 256 + self.scxFrac = sum % 256 +end + +-- SurfingMinigame_GenerateBGMap: new column only when hSCX changes and $f0 band changes +function SurfingMinigame:generateBgMapIfNeeded() + local h = self.hScx or 0 + if h == self.scxLast then return end + self.scxLast = h + local band = bit.band(h, 0xF0) + if band == self.scxHi then return end + self.scxHi = band + self:pushColumn() +end + +-- Legacy linear buffer kept for unit tests; pret uses generateBgMapIfNeeded only. function SurfingMinigame:generateAhead() - local distPx = math.floor(self.distanceFixed / 256) - while self.colTail * 16 < distPx + 176 do self:pushColumn() end + self:generateBgMapIfNeeded() +end + +local function columnAt(self, x) + local scx = displayScx(self) + return self.bgMap[mapColAtX(scx, x)] +end + +-- SurfingMinigame_SetPikachuHeight: wave height with slope from prior BGMapReadBuffer +function SurfingMinigame:pikaObjectHeight(tileForSlope) + local scx = displayScx(self) + local idx = (bit.band(scx, 8) ~= 0) and 9 or 8 + local h = self.waveHeight[idx] + local tile = tileForSlope or self.bgMapReadTile or 0x0b + if tile == 0x06 or tile == 0x14 then + return h - bit.band(scx, 7) + elseif tile == 0x07 then + return h + bit.band(scx, 7) + end + return h +end + +local function pikaWaterY(self) + return self:pikaObjectHeight(self.bgMapReadTile) end -- Get water surface Y for a screen X coordinate (X=80 under Pikachu center) function SurfingMinigame:seaY(x) - local distPx = math.floor(self.distanceFixed / 256) - local tile = math.floor((distPx + x) / 8) - local col = self.cols[math.floor(tile / 2)] + if x == 80 then + return pikaWaterY(self) + end + local col = columnAt(self, x) if not col then return FLAT_WATER_Y end + local scx = displayScx(self) + local tile = math.floor((scx + x) / 8) return tile % 2 == 0 and col.hl or col.hr end --- Get the tile ID of the wave under Pikachu (sample 9 tiles / 72 pixels into viewport) -function SurfingMinigame:getWaveTileUnderPika() - local distPx = math.floor(self.distanceFixed / 256) - local tile_col = math.floor((distPx + 72) / 8) - local waterY = math.floor(self:seaY(80)) - local tile_row = math.floor(waterY / 8) +-- Chr tile at Pikachu's object height (SurfingMinigame_ReadBGMapBuffer). +function SurfingMinigame:sampleBgTileAt(scx, objectHeightPx) + local tile_col = math.floor((scx + 72) / 8) + local tile_row = math.floor(objectHeightPx / 8) - local c = math.floor(tile_col / 2) - local col = self.cols[c] - if not col or not col.pat then return 0x01 end + local col = self.bgMap[mapColAtX(scx, 72)] + if not col or not col.pat then return 0x0b end local i = math.floor(tile_row / 2) + 1 - if i < 1 or i > 8 then return 0x01 end + if i < 1 or i > 8 then return 0x0b end local mt = BG_METATILES[col.pat[i]] - if not mt then return 0x01 end + if not mt then return 0x0b end local sub_x = (tile_col % 2 == 0) and 0 or 1 local sub_y = (tile_row % 2 == 0) and 0 or 1 + return mt[1 + sub_x + sub_y * 2] or 0x0b +end - local sub_idx = 1 + sub_x + sub_y * 2 - local mt_tile = mt[sub_idx] or 0x01 - - if mt_tile == 0x02 or mt_tile == 0x04 or mt_tile == 0x06 or mt_tile == 0x0a or mt_tile == 0x11 then - return 0x06 -- rising slope - elseif mt_tile == 0x03 or mt_tile == 0x05 or mt_tile == 0x07 or mt_tile == 0x0d or mt_tile == 0x13 then - return 0x07 -- falling slope - elseif mt_tile == 0x08 or mt_tile == 0x09 or mt_tile == 0x0f or mt_tile == 0x10 or mt_tile == 0x12 or mt_tile == 0x14 or mt_tile == 0x15 then - return 0x14 -- wave crest / face - end - return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.) +function SurfingMinigame:getWaveTileUnderPika() + return self.bgMapReadTile or 0x0b end function SurfingMinigame:spawnTrickPopup(text) @@ -593,52 +850,44 @@ end -- Slope/tile interaction matrix upon landing (SurfingMinigame_TileInteraction) function SurfingMinigame:evaluateLanding() local f = self.frameSet - -- Flipped / upside-down frames (8..14) ALWAYS wipeout unconditionally! if f >= 8 or f < 1 then return "wipeout" end local tile = self:getWaveTileUnderPika() - if tile == 0x06 then -- risingSlope - if f == 6 then return "clean" + if tile == 0x06 then -- rising slope + if f <= 3 then return "wipeout" + elseif f == 4 then return "hard" elseif f == 5 or f == 7 then return "rough" - elseif f == 4 then return "hard" - else return "wipeout" end -- 1, 2, 3 + elseif f == 6 then return "clean" + else return "wipeout" end - elseif tile == 0x07 then -- fallingSlope - if f == 2 then return "clean" - elseif f == 1 or f == 3 then return "rough" + elseif tile == 0x07 then -- falling slope + if f == 1 then return "rough" + elseif f == 2 then return "clean" + elseif f == 3 then return "rough" elseif f == 4 then return "hard" - else return "wipeout" end -- 5, 6, 7 + else return "wipeout" end - elseif tile == 0x14 or tile == 0x12 then -- waveCrest / waveFace - if f == 4 or f == 5 then return "clean" - elseif f == 3 or f == 6 then return "rough" + elseif tile == 0x12 or tile == 0x14 then -- wave face / crest + if f == 1 then return "wipeout" elseif f == 2 or f == 7 then return "hard" - else return "wipeout" end -- 1 + elseif f == 3 or f == 6 then return "rough" + elseif f == 4 or f == 5 then return "clean" + else return "wipeout" end - else -- flat open water - if f == 4 then return "clean" - elseif f == 3 or f == 5 then return "rough" + else -- flat open water (every other metatile id) + if f == 1 or f == 7 then return "wipeout" elseif f == 2 or f == 6 then return "hard" - else return "wipeout" end -- 1, 7 + elseif f == 3 or f == 5 then return "rough" + elseif f == 4 then return "clean" + else return "wipeout" end end end function SurfingMinigame:updateTempo() - local tier = 1 - if self.speedFixed >= 416 then - tier = 5 - elseif self.speedFixed >= 320 then - tier = 4 - elseif self.speedFixed >= 224 then - tier = 3 - elseif self.speedFixed >= 128 then - tier = 2 - else - tier = 1 - end + local tier = pretTempoTier(self.speedFixed or SPEED_INITIAL) local targetPitch = TEMPO_TIERS[tier] or 1.0 if self.currentPitch ~= targetPitch then self.currentPitch = targetPitch @@ -655,60 +904,68 @@ function SurfingMinigame:resetTempo() end end -function SurfingMinigame:updateRiding() - -- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0) - if self.speedFixed < SPEED_MAX then - self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) +-- Pret RunDelayTimer: count down routineTimer; return true when it hits zero. +local function outroDelayExpired(self) + if self.routineTimer > 0 then + self.routineTimer = self.routineTimer - 1 + return false end - self:updateTempo() + return true +end - -- Follow wave surface height - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 +function SurfingMinigame:beginResultsCard() + self.showResultsCard = true + self.outroLines = { hp = false, rad = false, total = false, hiScore = false } + self:initResultsPikachu() + self.speedFixed = 0 + -- DrawResultsScreen clears cloud OAM (sprites 5–13); hide parallax clouds on the beach card + self.cloudSpriteX = nil +end - -- Water spray every 4 frames - self.sprayTimer = self.sprayTimer + 1 - if self.sprayTimer % 4 == 0 then - table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) - end +-- SurfingMinigame_InitResultsPikachu: flat results pose at shore Y, reset bob counter +function SurfingMinigame:initResultsPikachu() + self.pikaState = PIKA_STATE_INIT_RESULTS + self.frameSet = 4 -- flat ride (visible pose; pret writes frameset $0f to anim struct) + self.pikaY = FLAT_WATER_Y - PIKA_SPRITE_OFFSET + self.pikaSubY = 0 + self.pikaYOffset = 0 + self.resultsBobTimer = 0 +end - -- Board angle wobbling every 8 frames - self.boardAngleTimer = self.boardAngleTimer + 1 - if self.boardAngleTimer % 8 == 0 then - if self.boardAngleDecreasing then - if self.boardAngleOffset > 0 then - self.boardAngleOffset = self.boardAngleOffset - 1 - else - self.boardAngleDecreasing = false - end - else - if self.boardAngleOffset < 2 then - self.boardAngleOffset = self.boardAngleOffset + 1 - else - self.boardAngleDecreasing = true - end - end - end - - -- Select frame based on slope (lock flat open water to frame 4 without wobble) - local tile = self:getWaveTileUnderPika() - if tile == 0x06 or tile == 0x14 then - self.frameSet = 6 + (self.boardAngleOffset - 1) - elseif tile == 0x07 then - self.frameSet = 2 + (self.boardAngleOffset - 1) +-- SurfingMinigame_UpdateResultsPikachu (hi-score path only sets PIKA_STATE_RESULTS in pret) +function SurfingMinigame:updateResultsPikachu() + self.resultsBobTimer = (self.resultsBobTimer or 0) + 2 + local phase = bit.band(self.resultsBobTimer, RESULTS_BOB_CYCLE - 1) + if phase >= RESULTS_BOB_START then + self.pikaYOffset = math.floor( + math.sin((phase - RESULTS_BOB_START) / (RESULTS_BOB_CYCLE / 2) * math.pi * 2) * RESULTS_BOB_AMP + ) else - self.frameSet = 4 -- flat open water: steady horizontal ride + self.pikaYOffset = 0 end - self.frameSet = math.max(1, math.min(14, self.frameSet)) +end - -- Automatic jump off wave crest ($14) if speed >= 1.25 (SPEED_JUMP_THRESHOLD = 320) - local distPx = math.floor(self.distanceFixed / 256) - local subX = distPx % 8 - if (subX >= 3 and subX <= 4) and tile == 0x14 and self.speedFixed >= SPEED_JUMP_THRESHOLD then +function SurfingMinigame:drawResultsPikachu() + if not (self.ob and self.oq) then return end + local cx, cy = 80, self.pikaY + (self.pikaYOffset or 0) + self.pikaScreenY = cy + local toggle = math.floor(self.t / 8) % 2 + 1 + local base = RESULTS_PIKA_BASES[toggle] + if not self.oq[base] then + base = ANGLE_BASES[4][toggle] + end + self:draw3x3(base, cx, cy, false, false) +end + +function SurfingMinigame:updateRiding() + local tile = self.bgMapReadTile or 0x0b + local subX = bit.band(displayScx(self), 7) + + -- SurfingMinigame_TryStartJump (before SpeedUpPikachu; uses pre-accel speed) + if (subX >= 3 and subX <= 4) and tile == 0x14 and getSpeedDividedBy32(self.speedFixed) >= SPEED_JUMP_MIN_DIV32 then self.pikaState = PIKA_STATE_JUMPING - local spd = self.speedFixed / 256 - self.jumpArcMagnitude = math.min(16, math.max(10, math.floor(spd * 8))) + self.jumpArcMagnitude = getSpeedDividedBy32(self.speedFixed) + self.jumpArcFraction = 0 self.pikaSubY = 0 self.jumpDescending = false self.radnessMeter = 0 @@ -716,6 +973,59 @@ function SurfingMinigame:updateRiding() self.rotCountLeft = 0 self.rotCountRight = 0 Sound.play(self.game.data, "Ledge_Jump") + -- SurfingMinigame_UpdateSurfingFrame still runs on .startedJump + if subX >= 3 and subX <= 4 then + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + end + return + end + + -- SurfingMinigame_UpdateSurfingFrame (only updates frame when subX is 3..4) + if subX >= 3 and subX <= 4 then + if tile == 0x06 or tile == 0x14 then + self.frameSet = 6 + (self.boardAngleOffset - 1) + elseif tile == 0x07 then + self.frameSet = 2 + (self.boardAngleOffset - 1) + else + self:updateBoardAngle() + self.frameSet = 4 + end + self.frameSet = math.max(1, math.min(14, self.frameSet)) + end + + -- SurfingMinigame_SpeedUpPikachu (+2/256 = +1/128 per frame up to max 512 = 2.0) + if self.speedFixed < SPEED_MAX then + self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL) + end + self:updateTempo() + + -- Water spray every 4 frames + self.sprayTimer = self.sprayTimer + 1 + if self.sprayTimer % 4 == 0 then + table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) + end +end + +function SurfingMinigame:updateBoardAngle() + self.boardAngleTimer = (self.boardAngleTimer or 0) + 1 + if self.boardAngleTimer % 8 ~= 0 then return end + if self.boardAngleDecreasing then + if self.boardAngleOffset > 0 then + self.boardAngleOffset = self.boardAngleOffset - 1 + else + self.boardAngleDecreasing = false + end + else + if self.boardAngleOffset < 2 then + self.boardAngleOffset = self.boardAngleOffset + 1 + else + self.boardAngleDecreasing = true + end end end @@ -723,79 +1033,70 @@ function SurfingMinigame:handleLanding() local result = self:evaluateLanding() if result == "wipeout" then self.pikaState = PIKA_STATE_CRASHED - self.crashTimer = 96 + self.crashTimer = CRASH_FRAMES self.speedFixed = SPEED_INITIAL self.frameSet = 4 Sound.play(self.game.data, "Faint_Fall") else if result == "rough" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY) + self.speedFixed = reduceSpeedBy64(self.speedFixed) elseif result == "hard" then - self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY) + self.speedFixed = reduceSpeedBy128(self.speedFixed) end if self.routine == ROUTINE_RUN_GAME then self:calculateStuntPoints() end self.pikaState = PIKA_STATE_LANDING - self.landingTimer = 32 + self.landingTimer = LANDING_SPLASH_FRAMES self.frameSet = 4 Sound.play(self.game.data, "Cut") end end function SurfingMinigame:updateJumping() - -- Process accumulated input on the 3-frame buffer boundary (only during active run) + -- SurfingMinigame_DPadAction (hJoy5 sampled every 2 frames via GetJoypad) if self.routine == ROUTINE_RUN_GAME then - self.joyCounter = (self.joyCounter + 1) % 3 - if self.joyCounter == 0 then - local rightHeld = bit.band(self.inputAccum, 1) ~= 0 - local leftHeld = bit.band(self.inputAccum, 2) ~= 0 - self.inputAccum = 0 - - -- Game Boy priority: Left D-pad checked first, then Right D-pad - if leftHeld then - self.rotCountRight = 0 - self.rotCountLeft = self.rotCountLeft + 1 - if self.rotCountLeft >= 11 then - self.rotCountLeft = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 1) - Sound.play(self.game.data, "Tink") - end - self.frameSet = (self.frameSet % 14) + 1 - elseif rightHeld then + if self.joy5Left then + self.rotCountRight = 0 + self.rotCountLeft = self.rotCountLeft + 1 + if self.rotCountLeft >= FLIP_LEFT_FRAMES then self.rotCountLeft = 0 - self.rotCountRight = self.rotCountRight + 1 - if self.rotCountRight >= 13 then - self.rotCountRight = 0 - self.radnessMeter = math.min(3, self.radnessMeter + 1) - self.trickFlags = bit.bor(self.trickFlags, 2) - Sound.play(self.game.data, "Tink") - end + self.radnessMeter = math.min(RADNESS_METER_MAX, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 1) + Sound.play(self.game.data, "Tink") + end + if self.frameSet >= 14 then + self.frameSet = 1 + else + self.frameSet = self.frameSet + 1 + end + elseif self.joy5Right then + self.rotCountLeft = 0 + self.rotCountRight = self.rotCountRight + 1 + if self.rotCountRight >= FLIP_RIGHT_FRAMES then + self.rotCountRight = 0 + self.radnessMeter = math.min(RADNESS_METER_MAX, self.radnessMeter + 1) + self.trickFlags = bit.bor(self.trickFlags, 2) + Sound.play(self.game.data, "Tink") + end + if self.frameSet <= 1 then + self.frameSet = 14 + else self.frameSet = self.frameSet - 1 - if self.frameSet < 1 then self.frameSet = 14 end end end - else - self.inputAccum = 0 end - -- Authentic Game Boy collision boundary & integer fixed-point jump physics + -- SurfingMinigame_UpdatePikachuHeight (arc delta before velocity each phase) if not self.jumpDescending then - local a = math.floor(self.jumpArcMagnitude) - self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 - local intDelta = math.floor(self.pikaSubY / 256) - self.pikaSubY = self.pikaSubY % 256 - self.pikaY = self.pikaY - intDelta - - self.jumpArcMagnitude = self.jumpArcMagnitude - 0.5 - if self.jumpArcMagnitude <= 0 then - self.jumpArcMagnitude = 0 + if (self.jumpArcMagnitude or 0) == 0 and (self.jumpArcFraction or 0) == 0 then self.jumpDescending = true + else + applyJumpArcDelta(self, -128) -- -0.5 px/frame in 8.8 fixed + applyJumpVerticalDelta(self, -1) end else - -- Hardware execution order: evaluate boundary before adding velocity - local waveY = math.floor(self:seaY(80)) - 16 + local waveY = pikaSpriteYFromObjectHeight(self.pikaObjectHeightPx) if self.pikaY >= waveY then self.pikaY = waveY self.pikaSubY = 0 @@ -803,12 +1104,8 @@ function SurfingMinigame:updateJumping() return end - local a = math.floor(self.jumpArcMagnitude) - self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4 - local intDelta = math.floor(self.pikaSubY / 256) - self.pikaSubY = self.pikaSubY % 256 - self.pikaY = self.pikaY + intDelta - self.jumpArcMagnitude = self.jumpArcMagnitude + 0.5 + applyJumpArcDelta(self, 128) -- +0.5 px/frame in 8.8 fixed + applyJumpVerticalDelta(self, 1) if self.pikaY >= waveY then self.pikaY = waveY @@ -821,12 +1118,9 @@ end function SurfingMinigame:updateLanding() self.landingTimer = (self.landingTimer or 0) - 1 - -- Follow wave surface height continuously while landing so slopes don't cause position jumps! - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 + -- pikaY already updated by SetPikachuHeight in tick (pre-scroll object height). - -- Sine wave splash offset + -- Sine wave splash offset (FIELD_C 0..$20 by +4/frame) self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4) if self.landingTimer % 4 == 0 then table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 }) @@ -842,10 +1136,7 @@ end function SurfingMinigame:updateCrashed() self.crashTimer = self.crashTimer - 1 self:resetTempo() - -- Follow water surface while wiped out - local targetY = self:seaY(80) - self.pikaY = math.floor(targetY) - 16 - self.pikaSubY = 0 + -- pikaY already updated by SetPikachuHeight in tick. if self.crashTimer <= 0 then self.pikaState = PIKA_STATE_RIDING self.frameSet = 4 @@ -856,22 +1147,34 @@ end function SurfingMinigame:tick() local input = self.game and self.game.input self.t = self.t + 1 - self.rDiv = (self.rDiv + 1) % 256 + self.rDiv = (self.rDiv + RDIV_PER_FRAME) % 256 - -- Title Screen State + -- Title Screen State (auto-advances when intro Pikachu walks off-screen) if self.routine == ROUTINE_TITLE then - if input and (input:wasPressed("start") or input:wasPressed("a")) then + if self.t % 2 == 0 then + self.introPikaX = self.introPikaX + 1 + end + if self.introPikaX >= 192 then self:startFromTitle() end return end - -- Accumulate physical button presses on every frame (only during active run) + -- SurfingPikachu_GetJoypad_3FrameBuffer: hJoy5 = hJoyHeld when hFrameCounter==0, then reload $2; + -- VBlank decrements counter → 1 sample frame + 2 blank frames (3-frame duty cycle). if self.routine == ROUTINE_RUN_GAME then - if input and input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end - if input and input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end + if (self.joyFrameCounter or 0) == 0 then + self.joy5Left = input and input:isDown("left") + self.joy5Right = input and input:isDown("right") + self.joyFrameCounter = JOY_FRAME_RELOAD + else + self.joy5Left = false + self.joy5Right = false + end else - self.inputAccum = 0 + self.joy5Left = false + self.joy5Right = false + self.joyFrameCounter = 0 end -- Update trick popups @@ -889,40 +1192,59 @@ function SurfingMinigame:tick() if s.timer <= 0 then table.remove(self.waterSprays, i) end end + -- Slide START banner during RunGame (pret animates it while gameplay runs) + if self.routine == ROUTINE_RUN_GAME and self.startBannerX > 80 then + self.startBannerX = math.max(80, self.startBannerX - 4) + end + -- Routine state machine if self.routine == ROUTINE_START_GAME then - if self.startBannerX > 80 then - self.startBannerX = math.max(80, self.startBannerX - 4) - else - self.routine = ROUTINE_RUN_GAME - end + -- SurfingMinigame_StartGame: spawn banner, inc routine; RunGame begins next frame + self.routine = ROUTINE_RUN_GAME + return elseif self.routine == ROUTINE_RUN_GAME then - -- Deduct 1 HP per frame (stamina countdown from 6000 BCD) - if self.hp > 0 then - self.hp = self.hp - 1 - else - -- Game Over when HP hits 0 + -- SurfingMinigame_RunGame: distance check first (cp $18) + if (self.distanceSection or 0) >= TOTAL_SECTIONS then + self.distanceSection = TOTAL_SECTIONS + self.routine = ROUTINE_WAIT_RESULTS + self.routineTimer = COAST_FRAMES + self.scxHi = bit.band(self.hScx or 0, 0xF0) + self.scxLast = self.hScx or 0 + self.waveFn = 0 + self:resetTempo() + return + end + + -- HP dead check before frame logic (pret or [hl] on wSurfingMinigamePikachuHP) + if (self.hp or 0) <= 0 then self.routine = ROUTINE_GAME_OVER - self.routineTimer = 128 + self.routineTimer = GAME_OVER_DELAY self.speedFixed = 0 self.ohNoBanner = true Sound.play(self.game and self.game.data, "Faint_Fall") return end - -- Scroll distance & generate wave columns (authentic 1:1 GB pace: distance += speedFixed) - self.distanceFixed = self.distanceFixed + self.speedFixed - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor(self.speedFixed * 0.25) - self:generateAhead() + self.waveRandomValue = self:getGBRandom() - -- Check if course goal reached (24 sections) - local distPx = math.floor(self.distanceFixed / 256) - if distPx >= (TOTAL_SECTIONS * 128) then - self.routine = ROUTINE_WAIT_RESULTS - self.routineTimer = 192 - self.waveFn = 0x72 - return + -- Pret RunGame order: SetPikachuHeight, ReadBGMapBuffer, Scroll, Distance, Deduct1HP + local objectHeight = self:pikaObjectHeight(self.bgMapReadTile) + self.pikaObjectHeightPx = objectHeight + if self.pikaState ~= PIKA_STATE_JUMPING then + self.pikaY = pikaSpriteYFromObjectHeight(objectHeight) + self.pikaSubY = 0 end + local preScrollScx = displayScx(self) + self.bgMapReadTile = self:sampleBgTileAt(preScrollScx, objectHeight) + + self:advanceScx(BG_SCROLL_STEP) + self:generateBgMapIfNeeded() + + self:updatePikachuDistance() + self.scrollFixed = self.distanceFixed + + -- SurfingMinigame_Deduct1HP (after UpdatePikachuDistance) + self.hp = self.hp - 1 -- Update Pikachu by state if self.pikaState == PIKA_STATE_RIDING then @@ -936,12 +1258,14 @@ function SurfingMinigame:tick() end elseif self.routine == ROUTINE_WAIT_RESULTS then - -- Coasting past the goal line - self.distanceFixed = self.distanceFixed + (2 * 256) - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25) - self:generateAhead() + if self.routineTimer > 0 then + -- RunDelayTimer then CoastAfterGoal (192 coast frames, not 193) + self.routineTimer = self.routineTimer - 1 + self:advanceScx(COAST_SPEED_FIXED) + self:generateBgMapIfNeeded() + self:resetTempo() + end - -- Run Pikachu state machine so mid-air jumps and wipeout crashes complete if self.pikaState == PIKA_STATE_JUMPING then self:updateJumping() elseif self.pikaState == PIKA_STATE_LANDING then @@ -949,101 +1273,101 @@ function SurfingMinigame:tick() elseif self.pikaState == PIKA_STATE_CRASHED then self:updateCrashed() else - -- Riding: follow water surface local targetY = self:seaY(80) self.pikaY = math.floor(targetY) - 16 self.pikaSubY = 0 self.frameSet = 4 end - if self.routineTimer > 0 then - self.routineTimer = self.routineTimer - 1 - end - - -- Only advance to scroll results when delay has finished AND Pikachu is upright - if self.routineTimer <= 0 and self.pikaState == PIKA_STATE_RIDING then + if self.routineTimer <= 0 then + -- pret .doneDelay: enter results scroll immediately (no wait for landing/crash) self.routine = ROUTINE_SCROLL_RESULTS - self.routineTimer = 36 + self.hScx = OUTRO_SCROLL_START + self.scxFrac = 0 + self.scxHi = 0 + self.scxLast = 0 + self.waveFn = 0x72 self.pikaState = PIKA_STATE_GAME_END end elseif self.routine == ROUTINE_SCROLL_RESULTS then - self.distanceFixed = self.distanceFixed + (1 * 256) - self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25) - self:generateAhead() - self.pikaY = math.floor(self:seaY(80)) - 16 - self.pikaSubY = 0 - self.frameSet = 4 - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if (self.hScx or 0) <= 0 then self.routine = ROUTINE_DRAW_RESULTS - self.routineTimer = 64 - self.pikaState = PIKA_STATE_RESULTS + self.pikaState = PIKA_STATE_INIT_RESULTS + else + self.hScx = self.hScx - OUTRO_SCROLL_STEP + self:generateBgMapIfNeeded() + local targetY = self:seaY(80) + self.pikaY = math.floor(targetY) - 16 + self.pikaSubY = 0 + self.frameSet = 4 end elseif self.routine == ROUTINE_DRAW_RESULTS then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then - self.routine = ROUTINE_WRITE_HP_LEFT - self.routineTimer = 32 - end + -- DrawResultsScreenAndWait: one-shot static beach tilemap + textbox frame + self:beginResultsCard() + self.routineTimer = 32 + self.routine = ROUTINE_WRITE_HP_LEFT elseif self.routine == ROUTINE_WRITE_HP_LEFT then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.hp = true + self.routineTimer = 64 self.routine = ROUTINE_WRITE_RADNESS - self.routineTimer = 32 end elseif self.routine == ROUTINE_WRITE_RADNESS then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.rad = true + self.routineTimer = 64 self.routine = ROUTINE_WRITE_TOTAL - self.routineTimer = 32 end elseif self.routine == ROUTINE_WRITE_TOTAL then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then + self.outroLines.total = true + self.routineTimer = 64 self.routine = ROUTINE_ADD_HP_TOTAL - self.tallyStep = 0 end elseif self.routine == ROUTINE_ADD_HP_TOTAL then - -- Tally remaining HP into total score (99 pts/frame matching ld c, 99) - if self.hp > 0 then + if not outroDelayExpired(self) then + -- waiting before tally starts + elseif self.hp > 0 then local step = math.min(self.hp, 99) self.hp = self.hp - step self.totalScore = self.totalScore + step Sound.play(self.game and self.game.data, "Press_AB") else + self.routineTimer = 64 self.routine = ROUTINE_ADD_RAD_TOTAL end elseif self.routine == ROUTINE_ADD_RAD_TOTAL then - -- Tally Radness into total score (99 pts/frame matching ld c, 99) - if self.radness > 0 then + if not outroDelayExpired(self) then + -- waiting before tally starts + elseif self.radness > 0 then local step = math.min(self.radness, 99) self.radness = self.radness - step self.totalScore = self.totalScore + step Sound.play(self.game and self.game.data, "Press_AB") else - self.routine = ROUTINE_WAIT_LAST - self.routineTimer = 64 - -- High score check self.newRecord = self.totalScore > ((self.game and self.game.save and self.game.save.surfingHighScore) or 0) if self.newRecord then if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end + self.outroLines.hiScore = true + self.pikaState = PIKA_STATE_RESULTS Sound.play(self.game and self.game.data, "Get_Item1") Sound.playPikaCry(self.game and self.game.data, 34) else Sound.playPikaCry(self.game and self.game.data, 28) end + self.routineTimer = GAME_OVER_DELAY + self.routine = ROUTINE_WAIT_LAST end elseif self.routine == ROUTINE_WAIT_LAST then - self.routineTimer = self.routineTimer - 1 - if self.routineTimer <= 0 then + if outroDelayExpired(self) then self.routine = ROUTINE_EXIT_ON_PRESS_A end @@ -1060,6 +1384,26 @@ function SurfingMinigame:tick() if self.onDone then self.onDone(0) end end end + + if self.showResultsCard then + if self.pikaState == PIKA_STATE_RESULTS then + self:updateResultsPikachu() + else + self.pikaYOffset = 0 + end + end + + -- Pret SurfingPikachuLoop calls MoveClouds every frame while gameplay is active + if self.routine == ROUTINE_RUN_GAME + or self.routine == ROUTINE_WAIT_RESULTS + or self.routine == ROUTINE_GAME_OVER then + self:moveClouds() + end + + -- hFrameCounter decrements after game logic (VBlank) + if self.routine == ROUTINE_RUN_GAME and (self.joyFrameCounter or 0) > 0 then + self.joyFrameCounter = self.joyFrameCounter - 1 + end end -- Decoupled timestep accumulator for modern multi-refresh-rate displays @@ -1084,31 +1428,36 @@ end -- Draw scrolling wave background with authentic GPU shader HBlank wave distortion function SurfingMinigame:drawBackground() - local scx = math.floor(self.distanceFixed / 256) - local first = math.floor(scx / 16) + local scx = displayScx(self) local function renderTiles() love.graphics.setColor(1, 1, 1, 1) if not self.bg then return end - for c = first, first + 10 do - local col = self.cols[c] + for i = 0, 10 do + local col = self.bgMap[mapColScreen(scx, i)] + local x = i * 16 - (scx % 16) if col then - local x = c * 16 - scx - for i = 1, 8 do - local mt = BG_METATILES[col.pat[i]] + for row = 1, 8 do + local mt = BG_METATILES[col.pat[row]] if mt then - local y = (i - 1) * 16 - love.graphics.draw(self.bg, self.tq[mt[1]], x, y) - love.graphics.draw(self.bg, self.tq[mt[2]], x + 8, y) - love.graphics.draw(self.bg, self.tq[mt[3]], x, y + 8) - love.graphics.draw(self.bg, self.tq[mt[4]], x + 8, y + 8) + local y = (row - 1) * 16 + local function drawTile(tid, tx, ty) + if tid ~= 0x00 and self.tq[tid] then + love.graphics.draw(self.bg, self.tq[tid], tx, ty) + end + end + drawTile(mt[1], x, y) + drawTile(mt[2], x + 8, y) + drawTile(mt[3], x, y + 8) + drawTile(mt[4], x + 8, y + 8) end end end end end - if self.bgCanvas and self.waveShader and love.graphics.setCanvas and love.graphics.getCanvas then + if self.bgCanvas and self.waveShader and not isOutroScroll(self) and not self.showResultsCard + and love.graphics.setCanvas and love.graphics.getCanvas then -- 1. Capture the framework's active canvas local prevCanvas = love.graphics.getCanvas() @@ -1139,6 +1488,27 @@ function SurfingMinigame:drawBackground() end end +-- Draw intro title Pikachu (SurfingPikachu1Graphics3 via .IntroPikachu OAM) +function SurfingMinigame:drawIntroPikachu(cx, cy) + if not (self.intro and self.iq) then return end + local frame = math.floor(self.t / 7) % 4 + 1 + local vramBase = INTRO_PIKA_FRAME_BASE[frame] + local sheetBase = vramBase - 0x80 + for _, sp in ipairs(INTRO_PIKA_OAM) do + local tileId = sheetBase + sp.tile + local q = self.iq[tileId] + if q then + local x = cx + sp.dx + (sp.xflip and 8 or 0) + local y = cy + sp.dy + if sp.xflip then + love.graphics.draw(self.intro, q, x, y, 0, -1, 1) + else + love.graphics.draw(self.intro, q, x, y) + end + end + end +end + -- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy) function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY) local sx = flipX and -1 or 1 @@ -1164,48 +1534,38 @@ function SurfingMinigame:drawHUD() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 16) - -- Track progress line (tiles $15..$1c) + -- Window tilemap (SurfingPikachuMinigame_DrawStaticTilemapLayout): + -- row 0 cols 1-2: $15,$16 island; row 1 cols 1-9: $17,$18,$19×7; + -- row 1 cols 12-13: $1b,$1c "HP:"; digits are OAM sprites at X=$80. if self.bg and self.tq then - -- Top row (Y=128) love.graphics.draw(self.bg, self.tq[0x15], 8, BG_HEIGHT) love.graphics.draw(self.bg, self.tq[0x16], 16, BG_HEIGHT) - -- Bottom row (Y=136) love.graphics.draw(self.bg, self.tq[0x17], 8, BG_HEIGHT + 8) love.graphics.draw(self.bg, self.tq[0x18], 16, BG_HEIGHT + 8) - local trackTiles = { 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19 } - for i = 1, #trackTiles do - love.graphics.draw(self.bg, self.tq[trackTiles[i]], 16 + i * 8, BG_HEIGHT + 8) + for i = 1, 7 do + love.graphics.draw(self.bg, self.tq[0x19], 16 + i * 8, BG_HEIGHT + 8) end love.graphics.draw(self.bg, self.tq[0x1b], 96, BG_HEIGHT + 8) love.graphics.draw(self.bg, self.tq[0x1c], 104, BG_HEIGHT + 8) - - -- "HP:" label tiles on right (X=112, 120) - love.graphics.draw(self.bg, self.tq[0x20], 112, BG_HEIGHT + 8) - love.graphics.draw(self.bg, self.tq[0x21], 120, BG_HEIGHT + 8) end -- Mini-Pikachu progress marker (tile $fe) -- Game Boy: initial OAM X = $50 (80) = screen X 72, decrements by 2 per section. - -- 24 sections × 2 = 48 px total travel, ending at screen X 24. - -- We interpolate continuously across those same 48 pixels. - local distPx = math.floor(self.distanceFixed / 256) - local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128))) - local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset = screen X 72 - local markerTrack = 48 -- 24 sections × 2 px per section - local markerX = markerStartX - math.floor(progressRatio * markerTrack) + local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset + local markerX = markerStartX - (self.distanceSection or 0) * 2 if self.ob and self.oq and self.oq[0xfe] then love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6) end - -- 4 HP countdown digits starting at X=128 (right after HP:) + -- 4 HP countdown digits (OAM X $80..$98) local s = string.format("%04d", math.max(0, math.floor(self.hp))) for i = 1, 4 do local d = tonumber(s:sub(i, i)) or 0 if self.ob and self.oq and self.oq[0xd0 + d] then - love.graphics.draw(self.ob, self.oq[0xd0 + d], 120 + i * 8, BG_HEIGHT + 8) + love.graphics.draw(self.ob, self.oq[0xd0 + d], 128 + (i - 1) * 8, BG_HEIGHT + 8) else - Font.draw(tostring(d), 120 + i * 8, BG_HEIGHT + 8) + Font.draw(tostring(d), 128 + (i - 1) * 8, BG_HEIGHT + 8) end end end @@ -1248,29 +1608,27 @@ function SurfingMinigame:drawResultsOutro() end drawBoxRow(9, 0x3d, 0x40, 0x3e) - -- Text lines matching Game Boy screen memory coordinates (cols 2, 10, 15; rows 2, 4, 6, 8) - Font.draw(Strings("HP Left"), 16, 16) - if self.routine >= ROUTINE_WRITE_HP_LEFT then + -- Text lines appear as each Write* routine fires in pret + if self.outroLines.hp then + Font.draw(Strings("HP Left"), 16, 16) Font.draw(string.format("%04d", self.hp), 80, 16) Font.draw(Strings("Pts"), 120, 16) end - if self.routine >= ROUTINE_WRITE_RADNESS then + if self.outroLines.rad then Font.draw(Strings("Radness"), 16, 32) Font.draw(string.format("%04d", self.radness), 80, 32) Font.draw(Strings("Pts"), 120, 32) end - if self.routine >= ROUTINE_WRITE_TOTAL then + if self.outroLines.total then Font.draw(Strings("Total"), 16, 48) Font.draw(string.format("%04d", self.totalScore), 80, 48) Font.draw(Strings("Pts"), 120, 48) end - if self.routine >= ROUTINE_WAIT_LAST then - if self.newRecord then - Font.draw(Strings("Hi-Score!!"), 48, 64) - end + if self.outroLines.hiScore then + Font.draw(Strings("Hi-Score!!"), 48, 64) end love.graphics.setColor(1, 1, 1, 1) end @@ -1287,18 +1645,8 @@ function SurfingMinigame:drawTitleScreen() Font.draw("PIKACHU'S BEACH", 20, 32) end - -- 2. Draw 3x3 Pikachu intro sprite on the water using authentic OAM tile indices - if self.ob and self.oq then - local animBase = (math.floor(self.t / 32) % 2 == 0) and 0x0c or 0x09 - self:draw3x3(animBase, 80, 100, false, false) - end - - -- 3. High score display at bottom - Font.draw(string.format("Hi-Score %4d Pt", self.hiScore), 16, 120) - - if math.floor(self.t / 30) % 2 == 0 then - Font.draw("PRESS START", 36, 132) - end + -- 2. Intro Pikachu paddling out (SurfingPikachu1Graphics3 / surf_1c.png) + self:drawIntroPikachu(self.introPikaX, FLAT_WATER_Y) end function SurfingMinigame:draw() @@ -1310,26 +1658,31 @@ function SurfingMinigame:draw() return end - if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then + if self.showResultsCard then self:drawResultsOutro() + self:drawResultsPikachu() + self:drawHUD() return end -- Draw scrolling BG waves self:drawBackground() - -- Parallax clouds in sky (scrolling left at uniform 0.25x camera speed) - local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256) - local c1x = (160 - (cloudOffsetPx % 200)) - 40 - local c2x = (240 - (cloudOffsetPx % 200)) - 40 - if self.ob and self.oq then - -- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef) - for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do - love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12) + -- Parallax clouds (SurfingMinigame_MoveClouds: 9 OAM sprites, 8-bit X wrap) + if self.ob and self.oq and self.cloudSpriteX then + local wideTiles = { 0xec, 0xed, 0xed, 0xee, 0xef } + for i, tid in ipairs(wideTiles) do + local x = self.cloudSpriteX[i] + if x < 168 then + love.graphics.draw(self.ob, self.oq[tid], x, 12) + end end - -- Narrow cloud (4 tiles: $ec, $ed, $ee, $ef) - for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do - love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20) + local narrowTiles = { 0xec, 0xed, 0xee, 0xef } + for i, tid in ipairs(narrowTiles) do + local x = self.cloudSpriteX[i + 5] + if x < 168 then + love.graphics.draw(self.ob, self.oq[tid], x, 20) + end end end -- Helper function to draw OAM multi-sprite composite objects with palette and X-flipping @@ -1388,8 +1741,8 @@ function SurfingMinigame:draw() Font.draw(p.text, p.x, p.y) end - -- "START" banner - if self.routine == ROUTINE_START_GAME then + -- "START" banner (slides in during early RunGame frames) + if self.startBannerX > 80 then if self.ob and self.oq then for r = 0, 1 do for c = 0, 5 do @@ -1418,8 +1771,14 @@ end function SurfingMinigame:sgbPalettes(game) local P = require("src.render.PaletteFX") - local pal = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL - return { P.whole(pal) } + local beach = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH")) or PIKACHUS_BEACH_PAL + if self.routine == ROUTINE_TITLE then + local title = (game and game.data and P.pal(game.data, "PIKACHUS_BEACH_TITLE")) + or PIKACHUS_BEACH_TITLE_PAL + -- SurfingMinigame_TitleTilemap at (4,0), 12x6; ATTR_BLK pal 1 over that rect. + return { P.whole(beach), P.zone(title, 4, 0, 15, 5) } + end + return { P.whole(beach) } end return SurfingMinigame diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 323958ee..38157f41 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -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) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 64eed26f..cabda30f 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -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 diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua index 50afead6..9611403b 100644 --- a/src/ui/gen2/CrystalIntro.lua +++ b/src/ui/gen2/CrystalIntro.lua @@ -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 diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 10c5ab1b..0bdfaec8 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -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 diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 23d33dc5..1dfdbe75 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3c503fc9..4554dc5d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index c6c9a58f..ce3b82ec 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -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 diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index b5f1dbd9..52bdcfe6 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -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] diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua index 94311bab..c9745fc1 100644 --- a/tests/drivers/fill_extended_auto_menu_test.lua +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -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) diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua index 64ae7165..04e69531 100644 --- a/tests/engine/android_exit_to_launcher_test.lua +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -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") diff --git a/tests/engine/dataset_views_lazy_validation.lua b/tests/engine/dataset_views_lazy_validation.lua new file mode 100644 index 00000000..fa9ff9d0 --- /dev/null +++ b/tests/engine/dataset_views_lazy_validation.lua @@ -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") diff --git a/tests/engine/dataset_views_no_mod_parity.lua b/tests/engine/dataset_views_no_mod_parity.lua new file mode 100644 index 00000000..1b7a4cbe --- /dev/null +++ b/tests/engine/dataset_views_no_mod_parity.lua @@ -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") diff --git a/tests/engine/dataset_views_warning_policy.lua b/tests/engine/dataset_views_warning_policy.lua new file mode 100644 index 00000000..66fc92b2 --- /dev/null +++ b/tests/engine/dataset_views_warning_policy.lua @@ -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") diff --git a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua index 8cdd3917..73436f5c 100644 --- a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua +++ b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -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() diff --git a/tests/engine/gen2_reserved_metadata_ids.lua b/tests/engine/gen2_reserved_metadata_ids.lua new file mode 100644 index 00000000..78e35d04 --- /dev/null +++ b/tests/engine/gen2_reserved_metadata_ids.lua @@ -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") diff --git a/tests/engine/generated_data_decoder_test.lua b/tests/engine/generated_data_decoder_test.lua new file mode 100644 index 00000000..dd0672f3 --- /dev/null +++ b/tests/engine/generated_data_decoder_test.lua @@ -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") diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index de78bc71..da01a37a 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -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() diff --git a/tests/engine/launcher_session_teardown_test.lua b/tests/engine/launcher_session_teardown_test.lua index e63d3fd7..5e3b4b66 100644 --- a/tests/engine/launcher_session_teardown_test.lua +++ b/tests/engine/launcher_session_teardown_test.lua @@ -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 ------------------ diff --git a/tests/engine/shaderfx_ffi_c_fallback.lua b/tests/engine/shaderfx_ffi_c_fallback.lua new file mode 100644 index 00000000..25d7e49d --- /dev/null +++ b/tests/engine/shaderfx_ffi_c_fallback.lua @@ -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") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 28ad9244..5e9379aa 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -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, diff --git a/tests/modkit/cases/catch_party_full.lua b/tests/modkit/cases/catch_party_full.lua new file mode 100644 index 00000000..5dbdd918 --- /dev/null +++ b/tests/modkit/cases/catch_party_full.lua @@ -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") diff --git a/tests/modkit/cases/dataset_views.lua b/tests/modkit/cases/dataset_views.lua new file mode 100644 index 00000000..3384f8f2 --- /dev/null +++ b/tests/modkit/cases/dataset_views.lua @@ -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") diff --git a/tests/modkit/cases/dataset_views_nontermination.lua b/tests/modkit/cases/dataset_views_nontermination.lua new file mode 100644 index 00000000..3a0da2e6 --- /dev/null +++ b/tests/modkit/cases/dataset_views_nontermination.lua @@ -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") diff --git a/tests/modkit/cases/world_party_reorder_gen2.lua b/tests/modkit/cases/world_party_reorder_gen2.lua new file mode 100644 index 00000000..35a4b030 --- /dev/null +++ b/tests/modkit/cases/world_party_reorder_gen2.lua @@ -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() diff --git a/tests/modkit/dataset_view_fixture.lua b/tests/modkit/dataset_view_fixture.lua new file mode 100644 index 00000000..9d75e2e2 --- /dev/null +++ b/tests/modkit/dataset_view_fixture.lua @@ -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 diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 6ed91552..5fb84893 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -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 diff --git a/tests/pet_cries_test.lua b/tests/pet_cries_test.lua index 89f07aaa..ee53b278 100644 --- a/tests/pet_cries_test.lua +++ b/tests/pet_cries_test.lua @@ -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 diff --git a/tests/test_surfing_minigame.lua b/tests/test_surfing_minigame.lua index af4cb669..b3d1a10a 100644 --- a/tests/test_surfing_minigame.lua +++ b/tests/test_surfing_minigame.lua @@ -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!") diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 31e06cca..2e1f876f 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -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] diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 843f7d36..bcf70752 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -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), diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9fa9e696..ed4d489c 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -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 diff --git a/tools/shaderfx-bridge/Cargo.toml b/tools/shaderfx-bridge/Cargo.toml index d9671c35..f43f56b9 100644 --- a/tools/shaderfx-bridge/Cargo.toml +++ b/tools/shaderfx-bridge/Cargo.toml @@ -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"