diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c8d14c3..1d5d4456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -383,6 +383,9 @@ jobs: - name: install luajit run: sudo apt-get update && sudo apt-get install -y luajit + - name: install Pillow + run: python3 -m pip install --upgrade pillow + - name: interpreter version run: luajit -v diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 3b114d6b..61eb95df 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1067,23 +1067,18 @@ local championsRoomRivalScript = { { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23 { "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25 - -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement - -- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the - -- screen just fading on the spot (#704). The entrance walk leaves the - -- player at (4,3) and both north-wall warps sit on row 0, so the original - -- only ever spends three of those simulated steps -- CheckWarpsNoCollision - -- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the - -- trailing UP/LEFT are dropped. Scripted steps ignore collision here just - -- as they do in the original (CollisionCheckOnLand skips its checks while - -- wSimulatedJoypadStatesIndex is non-zero), so stepping through the - -- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported - -- as a clip in #847 and re-checked against home/overworld.asm - -- CollisionCheckOnLand, which is still the authority: do not "fix" it. - { "move_player", "up", 3 }, -- 26 + -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement. + -- The player walks out after Oak instead of the screen just fading on the + -- spot (#704). Route one tile right before walking north so the player + -- reaches the north-wall HALL_OF_FAME warp without sharing the rival's + -- (4,2) cell. The original simulated movement bypasses entity collision, + -- but this scene should not visibly walk through the defeated rival. + { "move_player", "right", 1 }, -- 26 + { "move_player", "up", 3 }, -- 27 -- hand the induction off to the HALL_OF_FAME room (consumed by its -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) - { "set_field", "pendingHallOfFame", true }, -- 27 - { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28 + { "set_field", "pendingHallOfFame", true }, -- 28 + { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29 } M.CHAMPIONS_ROOM = { diff --git a/docs/architecture.md b/docs/architecture.md index a1d58df6..09bbc60d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification. | | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir | | render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling | | | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring | -| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing | +| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing | | | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) | | | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page | | | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades | diff --git a/docs/modding.md b/docs/modding.md index 31721d9c..b13fb048 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Read-only map overviews + +`mod.world:mapOverview()` returns collision `rows` at map-cell resolution, +optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at +4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to +`"3"` (darkest); their matching width and height fields describe the grid. +`markers` contains active `{ kind, x, y }` points in map-cell coordinates for +`warp`, visible `item`, and untaken `hidden` locations. All fields are +read-only snapshots; mods choose which layers to render. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it @@ -100,6 +110,44 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Variable-size overworld sprites + +The `sprites` registry keeps the vanilla 16x16 grounded walker as its default, +but a mod can describe any frame rectangle and anchor for player characters, +NPCs, followers, mounts, vehicles, bosses, or other field actors: + +```lua +mod.content.sprites:register("SPRITE_COMPANION", { + image = "mods/example/companion.png", -- one frame per row + frames = 6, + walker = true, + frameWidth = 32, + frameHeight = 32, + anchorX = 16, -- frame-relative bottom-center anchor + anchorY = 32, +}) +``` + +`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are +measured from each frame's top-left; when omitted they default to the frame's +horizontal center and bottom edge, so a larger sprite grows upward while its +feet stay on the same world cell. Omitting all four fields is exactly the +vanilla 16x16 placement. The normal player/NPC/follower draw paths consume +these values automatically, including horizontal flips and the fishing pose. + +Custom render pipelines can use the same geometry without reproducing the +pose rules: + +```lua +local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip) +-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror +local originX, originY = sprite:getScreenOrigin(px, py, camX, camY) +``` + +`getFrameGeometry(frame)` is the corresponding accessor for a specific +zero-based sheet frame. Both accessors return fresh tables and share the +renderer’s frame selection and mirror conventions. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way @@ -140,6 +188,90 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Durable tool storage and runtime checkpoints + +`mod.save` remains the right place for state that should travel with the next +normal Pokémon SAVE. Tools that need independently written, larger data-only +records can use `mod.storage`; the engine scopes every logical key by game +version, opaque playthrough identity, and mod id, and routes it through the same +standard or portable persistence backend as saves: + +```lua +local context, code, message = mod.storage:context(game) +local ok, code, message = mod.storage:write(game, "history/quick/q0001", { + format = 1, createdAt = os.time(), payload = { money = 3000 }, +}) +local value, code, message = mod.storage:read(game, "history/quick/q0001") +local keys, code, message = mod.storage:list(game, "history/quick") +local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") +``` + +`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine +version is compatibility metadata; physical launcher-slot and path identity stays +private. + +Values must be tables containing serializable data only. Keys are conservative +slash-separated segments (letters, digits, `_`, `-`); paths and filesystem +handles are never exposed. Writes are staged and decode-verified, reads recover +from a valid staged/backup generation, and methods return structured errors for +normal data or I/O failures. The playthrough identity is allocated lazily on the +first storage/checkpoint call, so an unused API changes no save bytes. + +`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state: + +```lua +local capability = mod.checkpoints:inspect(game) +if capability.canCapture then + local checkpoint, code, message = mod.checkpoints:capture(game) + -- Store the detached data-only checkpoint through mod.storage. +end + +local ok, code, message = mod.checkpoints:restore(game, checkpoint) +``` + +Checkpoint format 1 supports settled overworld control and proven battle +player-decision safe points. Battle checkpoints are limited to ordinary +single-player wild/trainer origins with no suspended script; link, Safari, +ghost, demo, scripted, animation, message, queue, and forced-action phases fail +closed. New checkpoints preserve gameplay RNG, while legacy overworld records +without RNG remain loadable. Capture excludes global options and runtime +objects. Restore validates format, game/playthrough identity, content, +coordinates, battle relationships, continuation, and RNG before mutation; +preserves current options; suppresses normal map-entry/save-load/intro side +effects; verifies a recapture; and rolls back runtime plus RNG in memory if +reconstruction fails. Callers that need crash recovery should durably capture +their own recovery checkpoint before restore. + +Checkpoint ownership follows the persistence model rather than mod identity: + +- canonical `game.save` progress, including every mod's `save.modData` / + `mod.save` bucket and data-only fields added to saved Pokémon, rewinds; +- global and per-mod options remain at their current values; +- independently written `mod.storage` records do not rewind; and +- mod-owned runtime objects, references, and caches are never serialized. + +Successful restore emits `checkpoint.restored` only after reconstruction and +differential recapture have committed. Mods that cache rewound progress or hold +references to reconstructed runtime objects can re-read their own public state +and rebuild at that point: + +```lua +mod.events:on("checkpoint.restored", function(ev) + -- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed. + cachedQuestStage = mod.save:get("quest_stage", 0) + rebuildRuntimeFor(ev.game, ev.kind) +end) +``` + +The event is not emitted for validation failure, failed reconstruction, or a +successful rollback. Its payload contains no checkpoint data or other mod's +private state. A mod that deliberately stores progress-coupled truth in +`mod.storage` must version and reconcile that relationship itself; the engine +cannot distinguish it safely from independent history, configuration, or cache +data. + +See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload @@ -221,10 +353,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a palette-correct blit of either canvas into an arbitrary screen rect, and the -`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`) -for driving a second physical display. This is what lets a mod lay the two -passes out as two stacked Game Boy screens, or push one onto a second screen, -without the engine knowing the layout. +`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` / +`setEnabled`) for driving a second physical display. `pollTouch()` returns the +oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. +This is what lets a mod lay the two passes out as two stacked Game Boy screens, +or push one onto a second screen, without the engine knowing the layout. `screen.render_visible` receives `(next, state)` while the main screen is being composed. Return `false` to omit that state from drawing, opacity selection and @@ -233,5 +366,63 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +Scrollable list states expose `state.kind` for use with this hook. Generic +lists fall back to their title; PC lists use stable, localization-independent +identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, +`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`. + +`battle.bottom_ui_visible` and `battle.status_hud_visible` independently +control the battle text/menu layer and the HP/status panels. Both receive +`(next, state)` and default to `true`, so vanilla rendering is unchanged. +Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that +only owns battle presentation should return `false` only for its active battle +or text-box state. + +`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call +(once per frame). Vanilla behavior resolves the per-category GAME SPEED +option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever +category `Game.speedCategoryInStack` says is active right now. A mod may +call `next(game)` and return its result to pass that resolution through, or +return a different number outright to override it for that frame (a bot mod +forcing 1X for one route segment, say, regardless of the category or saved +option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry +regardless of what a subscriber returns, so a bad value (0, negative, `nil`) +cannot destabilize the fixed-step accumulator. This hook runs *after* link +play's 1X lock and the `--speed`/equivalent run-argument override, both of +which stay unconditional and are never visible to a subscriber. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. + +## Process-lifecycle hooks + +These exist so a platform-specific launcher integration (a native shell +that embeds this engine and wraps its window in platform UI) can live +entirely in a mod instead of hand-patching `main.lua`, which every other +engine change also touches. + +`core.update` receives `(next, game, dt)` once per frame from +`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A +mod may skip calling `next(game, dt)` to pause the simulation for that +frame (e.g. while a native settings sheet is on top), and may run +additional per-frame polling before or after that call regardless of +whether it calls `next` -- useful for one-shot flags that must be observed +every frame even while paused. + +`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()` +returns the engine's own decision for whether closing the window should +return to the Lua launcher instead of exiting; a mod may return `false` +outright, without ever calling `next`, to veto that and let the process +really quit -- for a platform host that owns its own "return to launcher" +UI and would otherwise get looped straight back into the game it just +quit. + +A manifest may also declare `force_enable_env`, an environment variable +name that re-enables the mod regardless of a saved disable in +`options.mods` when that variable is set to `"1"`. This is for a mod that +cannot function disabled on the one build where its env var is set (a +platform-bridge mod bundled only with that build's launcher, for example). + +Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` +already falls straight through to the vanilla function when no mod has +wrapped the name, at negligible cost. diff --git a/docs/rfcs/0003-multiplayer-session-layer.md b/docs/rfcs/0003-multiplayer-session-layer.md new file mode 100644 index 00000000..66af086a --- /dev/null +++ b/docs/rfcs/0003-multiplayer-session-layer.md @@ -0,0 +1,114 @@ +# RFC 0003 — Add a reusable multiplayer session layer + +## Status + +Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`, +`Tournament.lua`. Tests: `link_session.lua`. + +## Motivation + +Link play and tournaments currently own transport lifecycle details and +temporarily remove and reinsert packets in `Net.inbox` when a handshake or +battle starts. That makes packet ownership fragile and gives a future +shared-world mode no stable host/guest-aware boundary to reuse. + +The engine needs one small layer that preserves today's wire protocol while +owning received-packet order and terminal cleanup. Pokémon, battle, tournament, +save, and overworld rules remain outside that layer. + +## The decision it extends + +Extends the existing split between `Net` (backend setup, framing, and relay +controls), `Handshake`/`Protocol` (mode payloads), and the states that +interpret those payloads. It does not replace any of those components. + +## The exact API delta + +Backward-compatible and internal-only. + +### `Session.new(transport, options)` + +Wraps one successfully configured Net-compatible transport. `options.role` +is exactly `"host"` or `"guest"`; `options.kind` is a non-empty +local label such as `"link"` or `"tournament"`. Role and kind are +immutable session metadata selected locally and are never inferred from peer +packets. + +The facade forwards the narrow fields current consumers need: +`paired`, `code`, `address`, `target`, +`error`, and `closed`. +It forwards valid outbound tables unchanged through `send(message)`. + +### Receive and lifecycle methods + +- `update()` pumps the transport, validates decoded inbound values, and + appends accepted messages to a private FIFO. +- `pollOne()` removes the oldest queued message. +- `poll()` removes every queued message in order. +- `take(type)` removes the first queued message with that type without + disturbing any other message. +- `hasPending()` reports whether the FIFO is non-empty. +- `getRole()`, `getKind()`, `getStatus()`, and + `getFailure()` expose local metadata and lifecycle. +- `close()` closes the underlying transport once and is safe to repeat. + +Statuses are `connecting`, `paired`, `draining`, `closed`, +and `failed`. A transport close or failure becomes `draining` while +accepted packets remain queued. The terminal `closed`/`error` +compatibility projection appears only after that FIFO drains, so a last packet +travelling with a disconnect remains observable. + +An inbound value is structurally valid only when it is a table with a string +`type`. Invalid decoded values end the session with a protocol failure. +Unknown but structurally valid types remain queued for the owning mode; the +session does not contain a packet allowlist. + +## Authority direction + +A later `WorldSession` may compose this facade. In that mode the host +will own the world snapshot, map state, NPC state, event results, and shared +progression. A guest will bring a trainer identity plus their Pokémon party, +inventory, and other explicitly selected profile snapshot. + +Guest profile data and commands will be untrusted input. The host must validate +them and must authorize every world mutation before rebroadcasting the result. +The concrete snapshot schema, command vocabulary, conflict rules, and +persistence policy require a separate RFC and are not introduced here. + +## Compatibility and security + +No packet envelope, message name, payload shape, framing rule, relay protocol, +save schema, or engine protocol version changes. Existing valid outbound +messages encode exactly as before, and existing link and tournament screens +keep their current player-facing behavior. + +The layer does not authenticate players or encrypt traffic. Existing LAN and +relay access assumptions remain unchanged; knowledge of a join address or code +still grants the same access it grants today. Authentication, reconnect +identity, rate limits, and abuse controls remain future protocol decisions. + +## Migration note for players, mods, and peers + +**Nothing.** `LinkState` and `Tournament` adopt the facade +internally. Existing peers receive the same messages, mods gain no new API, and +players do not migrate saves or settings. + +## Parity tests + +- **ROM-free facade:** constructor validation, immutable role/kind, unchanged + send shape, FIFO ordering, typed retrieval, unknown typed packets, draining, + terminal failure latching, protected transport calls, and decoded-value + rejection. +- **Existing modes:** source guards prohibit direct inbox mutation; headless + module loads and the complete engine tier cover both migrated states. +- **ROM-backed link play:** run the existing link driver when generated ROM + data is available; the normal quick suite remains the required baseline. + +## Deprecation etiquette and non-goals + +Nothing deprecated. This RFC adds an internal facade and removes no transport +method. + +It does not add shared-world packets, co-op screens, a remote actor, save +transfer, server persistence, matchmaking, reconnect, or a protocol-version +bump. Those changes require the world-specific layer and its own review. diff --git a/docs/rfcs/0003-playthrough-storage.md b/docs/rfcs/0003-playthrough-storage.md new file mode 100644 index 00000000..96c574e2 --- /dev/null +++ b/docs/rfcs/0003-playthrough-storage.md @@ -0,0 +1,124 @@ +# RFC 0003 — Playthrough-scoped mod storage + +## Status + +Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`, +`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing +save-slot and mod-save suites. + +## Motivation + +`mod.save` intentionally lives inside the normal progress record. That is the +right home for quest state, but not for independent tool data such as replay +captures, checkpoint histories, or recovery records: writing it would require a +normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would +recursively embed the save that contains them. + +Mods also cannot safely infer which launcher slot or portable filesystem backs +the active playthrough. Direct filesystem access would expose private paths and +make isolation dependent on engine implementation details. + +## The decision it extends + +Extends the per-mod persistence contract documented in `docs/modding.md` and the +wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior. + +## The exact API delta + +Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage` +facade to the calling mod id. Mods receive logical keys and decoded values, never +filesystem handles or physical paths. + +### Lazy opaque playthrough identity + +`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque +32-hex-character identity without consuming gameplay RNG. It is called only when +`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE, +and ordinary load remain byte-compatible when no caller uses either API. + +The id is stored in `save.meta.playthroughId` after allocation. Until the next +ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy +saves stable by game version and active launcher slot (or the legacy flat-save +scope). A newly created playthrough never adopts the previous playthrough's +mapping for that slot. + +`SaveData.persistenceFs([fs])` is engine-only routing used by the storage +implementation. It follows the same standard/portable backend as progress and +honors injected test filesystems; it is not exposed on the mod object. + +### `mod.storage:context(game)` + +Returns: + +```lua +{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." } +``` + +or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata; +the context intentionally omits launcher slot ids and paths. + +### `mod.storage:write(game, key, value)` + +Accepts a data-only table and returns `true`, or +`false, code, message`. Keys are nonempty slash-separated segments containing +letters, digits, underscore, or dash. Empty segments, leading/trailing slash, +`.`/`..`, and other characters are rejected. + +The engine encodes deterministically, stages and decodes a `.tmp` witness, +preserves the previous valid generation, writes and decodes the main record, +then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a +verified prior generation readable. + +### `mod.storage:read(game, key)` + +Returns a freshly decoded table, or `nil, code, message`. It tries main, staged, +then backup data. A valid staged/backup value is returned and promoted +best-effort; corrupt bytes are never executed. + +### `mod.storage:list(game[, prefix])` + +Returns sorted logical keys beneath a valid prefix, an exact key when the prefix +names one, or `nil, code, message`. Physical witness filenames are hidden. + +### `mod.storage:delete(game, key)` + +Deletes only that key's main, backup, and staged witnesses. Returns `true`, or +`false, code, message`. + +### Scope and errors + +Physical records are scoped as: + +`persistence root / mod_storage / game version / playthrough id / mod id` + +Stable error codes are `not_in_playthrough`, `storage_unavailable`, +`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and +`not_found`. Ordinary data and I/O failures are return values, not callback- +terminating errors. + +The restricted serializer's recursive writer runs outside LuaJIT traces. A +1,000-process GC stress regression found compiled recursion could intermittently +drop a newly inserted nested identity entry and produce undecodable bytes; save +encoding is infrequent and I/O-bound, so interpreter execution is the safe +boundary. + +## Migration note for existing mods + +**Nothing.** No API is removed, no manifest field changes, and no storage path or +playthrough id is created unless a mod invokes `mod.storage` or +`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path. + +## Parity tests + +- **No-mod:** New Game plus ordinary save/load creates no identity or storage + file; the existing save-slot and mod-save suites remain green. +- **Engine identity:** lazy allocation, save/load preservation, stable legacy + mapping, fresh-playthrough replacement, and version/slot isolation. +- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip, + deterministic listing, key rejection, mod/game/playthrough isolation, + corrupt-main recovery, failure retention, exact delete, and no-mod no-write. + +## Deprecation etiquette + +Nothing deprecated. The additions are one bound public facade and engine-private +persistence/identity helpers. diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md new file mode 100644 index 00000000..1df98749 --- /dev/null +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -0,0 +1,138 @@ +# RFC 0004 — Stable runtime checkpoints for mods + +## Status + +Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`, +`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites. + +## Motivation + +Mods can observe world events and request semantic actions, but no supported API +can capture canonical progress at a proven-safe runtime boundary or reconstruct +the overworld without replaying map-entry scripts. Reaching into the state stack, +controller, ScriptRunner, or save restore internals would bind distributable mods +to private objects and can duplicate story side effects. + +The engine is the only component that can authoritatively decide whether the +runtime is settled and rebuild its controller objects. A generic checkpoint seam +lets tools store data-only records while keeping those responsibilities private. + +## The decision it extends + +Extends the public world/tool surfaces in `docs/modding.md`. It does not change +`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events. + +## The exact API delta + +Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods +never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem +internals inside a checkpoint. + +### `mod.checkpoints:inspect(game)` + +Returns a capability record. Stable overworld control returns: + +```lua +{ canCapture = true, canRestore = true, kind = "overworld" } +``` + +A refusal returns the same booleans as `false` plus `kind`, `reason`, and a +player-readable `message`. Format-1 supports only an overworld whose controller +is topmost, player movement has settled on a tile, and no transition, foreground +or parallel ScriptRunner, queued script, scripted move, engagement, emote, +teleport, field animation, or similar partial controller mutation is active. + +Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`, +`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`. +Identity allocation is lazy and happens only after an active topmost overworld +has been established. + +### `mod.checkpoints:capture(game)` + +Returns a detached data-only format-1 checkpoint, or +`nil, code, message`: + +```lua +{ + format = 1, + kind = "overworld", + identity = { + engineVersion = "...", gameVersion = "red", playthroughId = "...", + }, + save = { -- canonical dynamic progress, excluding global options }, + runtime = { overworld = { + map = "PALLET_TOWN", x = 5, y = 6, + facing = "down", surfing = false, + } }, +} +``` + +`engineVersion` is metadata for caller compatibility warnings; the engine does +not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after +`OverworldController:captureSave` synchronizes live map, tile, facing, and surf +state. It excludes `save.options`, functions, userdata, threads, metatables as +behavior, controller instances, and static content registries. Failure code +`capture_failed` covers non-data progress and synchronization errors. + +### `mod.checkpoints:restore(game, checkpoint)` + +Returns `true`, or `false, code, message`. Before mutation it requires the current +runtime to be capturable and validates a detached copy of the complete record: +format, kind, internal identity consistency, current game/playthrough identity, +map availability, integral in-bounds tile, facing, surfing, and synchronized save +position. + +Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`, +`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and +`invalid_position`, in addition to the capability refusal reasons. + +The canonical save validator runs against the detached record. Unlike ordinary +CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or +repair: any such content change returns `invalid_content` before live mutation. + +The engine captures an in-memory rollback checkpoint, preserves current global +options, then reconstructs semantic overworld state through +`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry +events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites; +it does not emit normal `save.loading`/`save.loaded` lifecycle events. After +reconstruction, the engine recaptures and byte-compares normalized data. A failed +apply rolls back and returns `restore_failed`; failure of that rollback returns +`rollback_failed`. Only after a successful comparison does the engine emit +`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation +failure, failed apply, and successful rollback emit nothing. + +Durable recovery remains a caller responsibility: in-memory rollback handles a +runtime exception, not process termination. + +## Runtime boundary and future kinds + +This RFC's original Level A contract intentionally rejects battles, menus, +transitions, animations, and suspended/queued scripts. RFC 0005 subsequently +adds a separately inventoried `battle` kind with deterministic RNG and +differential reconstruction tests; it does not broaden script or arbitrary-frame +support implied here. + +## Migration note for existing mods + +**Nothing required.** No existing hook, save, controller, or world action changes +when `mod.checkpoints` is unused. The reconstruction path is called only by a +successful public restore after validation. Mods whose runtime caches derive from +rewound `game.save` or `mod.save` state may optionally subscribe to +`checkpoint.restored` and rebuild from their own public state. + +## Parity tests + +- **No-mod:** the complete ROM-free engine suite and existing world behavior stay + green; ordinary New Game/save/load allocates no checkpoint identity. +- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every + unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync, + `A -> mutate B -> restore A -> recapture A2` equality across representative + progress, settings preservation, compatibility rejection without mutation, + map-side-effect suppression, injected reconstruction rollback, mod-owned + metadata and `mod.save` rewind, independent `mod.storage`/options preservation, + and success-only runtime-cache reconciliation through `checkpoint.restored`. + +## Deprecation etiquette + +Nothing deprecated. This adds one public facade and a checkpoint-only semantic +reconstruction route. diff --git a/docs/rfcs/0005-battle-runtime-checkpoints.md b/docs/rfcs/0005-battle-runtime-checkpoints.md new file mode 100644 index 00000000..f94d2ba4 --- /dev/null +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -0,0 +1,140 @@ +# RFC 0005 — Persistent battle safe-point checkpoints + +## Status + +Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`, +`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests: +`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites. + +## Motivation + +RFC 0004 lets a tool capture and reconstruct settled overworld progress without +private engine access. A battle is a different runtime: its queue can hold Lua +functions and UI factories, its controller contains renderer objects and live +references, completion is currently an `onFinish` closure, and scripted battles +resume a suspended `ScriptRunner` coroutine. Copying the controller would create +a record that is neither data-only nor process-independent. + +The engine can instead expose a narrow semantic safe point. This gives all mods +the strongest persistent battle checkpoint the current architecture can prove, +without claiming mid-animation or suspended-script support. + +## API delta + +No new facade is added. The existing additive `mod.checkpoints` API gains a +second format-1 runtime kind. + +### Capability + +`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player +wild or trainer battle is settled at the player command menu: + +```lua +{ canCapture = true, canRestore = true, kind = "battle" } +``` + +The action/message queue, waits, UI, animations, HP/status presentation, and +faint processing must be settled. The player must actually control the menu. +The underlying overworld must have no running/queued script or scripted move, +and the battle must carry an engine-owned semantic continuation descriptor. + +Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`, +`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari, +ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created +closure continuations remain rejected. + +### Capture + +A battle checkpoint remains detached and data-only: + +```lua +{ + format = 1, + kind = "battle", + identity = { engineVersion = "...", gameVersion = "red", + playthroughId = "..." }, + save = { -- canonical dynamic progress, excluding global options }, + runtime = { + overworld = { map = "ROUTE_1", x = 7, y = 8, + facing = "left", surfing = false }, + battle = { -- normalized semantic model and continuation }, + }, + rng = { love = "..." }, +} +``` + +The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and +escape state, HP/PP/status/stages/volatiles, participants, level-up tracking, +trainer AI state, battle ruleset identity, side/field extension data, and +normalized pointer relationships such as multi-turn move slots and Mimic +restoration entries. Definitions, sprites, canvases, queues, callbacks, and +controller objects are reconstructed or excluded. + +Callback-bearing battle extension tokens fail with `battle_extension_unsafe`; +invalid live reference relationships fail with `battle_state_invalid`. Nothing +is silently stripped. + +New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy +format-1 overworld checkpoints without `rng` remain loadable and leave the +current stream untouched. + +### Restore + +Battle restore validates the detached save, map, content references, ruleset, +roster indices, move references, continuation identity, and RNG before live +mutation. The engine then: + +1. reconstructs the saved overworld return point without entry side effects; +2. creates a fresh `BattleState` from current content registries; +3. applies the normalized battle model and rebuilds object-reference relations; +4. binds an engine-owned wild/trainer completion continuation; +5. installs the battle directly at the settled menu without replaying its intro; +6. restores the RNG after reconstruction has finished; and +7. recaptures and compares the complete checkpoint; and +8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the + comparison succeeds. + +The pre-operation checkpoint is the transaction rollback. A failed post-install +RNG restore is covered: both battle runtime and RNG are reconstructed back to +their original values. Validation failure, failed reconstruction, and successful +rollback emit no checkpoint lifecycle event. + +## Continuation decision + +Ordinary random wild battles resume through `OverworldState:afterBattle`. +Ordinary trainer battles use a descriptor containing map id, stable NPC id, +trainer class/party, and optional header event; a win reapplies the same defeated +flag, event, reward, and `afterBattle` path. Reconstructed overworld input and +NPC freeze state are normalized instead of reviving the old closure. + +`Commands.start_battle` is deliberately unsupported: its completion closure +mutates script context and resumes a coroutine whose program counter and Lua +stack cannot be serialized. Existing script rejection remains the correct safe +contract until a separate semantic ScriptRunner checkpoint RFC exists. + +## Migration note + +**Existing mods require no changes.** The facade and format number are unchanged; +the new kind, RNG field, and success-only lifecycle event are additive. +Overworld-only callers may continue to filter `capability.kind`. Mods with derived +runtime caches may rebuild them from restored public state when the event fires. +No-mod behavior is unchanged when checkpoints are unused. + +## Verification + +- settled/unsafe boundary and every variant refusal; +- data-only wild and trainer capture, including callback-bearing extension + rejection; +- process-independent controller and continuation reconstruction; +- exact differential recapture for wild and trainer states; +- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster, + multi-turn move references, and Mimic restore pointers; +- exact damage, critical, accuracy, random AI, escape, next encounter, and next + raw RNG result after reload; +- corrupt content/continuation rejection before mutation; +- injected post-install failure with full runtime and RNG rollback; +- mod-added Pokémon metadata and `mod.save` rewind while independent + `mod.storage` and options remain current; +- exactly one post-verification `checkpoint.restored` event and none on failure; +- legacy overworld checkpoint compatibility; +- complete ROM-free engine and public mod-API suites. diff --git a/docs/rfcs/0006-platform-lifecycle-hooks.md b/docs/rfcs/0006-platform-lifecycle-hooks.md new file mode 100644 index 00000000..9763fed1 --- /dev/null +++ b/docs/rfcs/0006-platform-lifecycle-hooks.md @@ -0,0 +1,107 @@ +# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations + +## Status + +Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`, +`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`, +`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`. + +## Motivation + +A platform-specific launcher wrapper -- a native shell that embeds this +engine and owns its own UI around the game window (a mobile app shell, +say, presenting its own settings/import/save screens and only handing +control to the LÖVE window once play starts) needs three things no +current hook covers: + +1. Pause the simulation while its own UI is on top of the game window. +2. Live-reload options it wrote from outside any Lua UI. +3. Veto `main.lua`'s "closing the window returns to the Lua launcher" + behavior when the platform shell owns that job itself -- without this, + a shell that re-fronts its own launcher UI on quit gets looped straight + back into `HostShell.restart()`'s in-process reboot instead. + +Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit` +directly ties every such integration to editing the one file every other +engine change also touches, guaranteeing merge conflicts for any second +platform integration (or any unrelated engine PR landing around the same +time). No existing hook covers "should the per-frame simulation step run" +or "should closing the window return to the Lua launcher." + +## The decision it extends + +No prior D-number. Extends the hook-contract section of `docs/modding.md` +alongside `input.step`, `render.hud`, `screen.render_visible`, etc. + +## The exact API delta + +Backward-compatible, additive-only. + +### `core.update` + +New hook, `(game, dt) -> nil` through the public wrapper signature +`(next, game, dt)`, called once per frame from `love.update` via +`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla +behavior (used when no mod claims the hook) is `game:update(dt)`, +unconditionally -- identical to `love.update`'s behavior before this hook +existed. A subscriber may skip calling `next(game, dt)` to pause the +simulation for that frame, or do additional per-frame work before/after +calling it regardless of whether it calls `next`. + +### `core.quit_to_launcher` + +New hook, `() -> boolean` through the public wrapper signature `(next)`, +called once from `love.quit()` via +`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing +non-platform-specific decision (`Game and not Importer and not +quitToLauncher and not scripted and not launchedIntoGame`). A subscriber +may return `false` outright to veto returning to the Lua launcher (without +ever calling `next`, so the vanilla condition is never evaluated), or call +`next()` and return its result to pass the vanilla decision through +unchanged. + +Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally +every call, matching the existing `input.step` precedent +(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare +`vanilla(...)` call when no mod has wrapped the name. + +### `Manifest.force_enable_env` + +New optional manifest field, a bare env-var name. `Loader:load` re-enables +a mod carrying this field whenever that variable is set to `"1"`, +regardless of a saved disable in `options.mods`. This exists for exactly +the mod class this RFC is for: a platform-bridge mod that ships only with +one build and cannot function disabled there, but must still behave like +every other mod (a manifest opt-in, not an engine special case) on every +build that doesn't set its variable. + +## Migration note for existing mods + +**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)` +unconditionally every frame and `love.quit()`'s restart-to-launcher +decision is exactly the pre-existing condition -- bit-identical to today's +behavior on every platform where no mod wraps either hook. A manifest with +no `force_enable_env` field behaves exactly as before. + +## Parity tests + +- **No-mod:** `core.update`'s vanilla runs exactly once per call with the + hook chain empty; `core.quit_to_launcher`'s vanilla return value passes + through unchanged. Both hooks are picked up automatically by the + catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans + for `Runtime.call("...")` call sites), so neither needs a dedicated + no-mod test file. +- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves, + through a fixture mod loaded via the public loader (not the engine's + internals), that a subscriber can skip the vanilla update call (pause), + run extra per-frame polling regardless of pause state, and veto the + quit-to-launcher decision without the vanilla condition ever running. +- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover + `force_enable_env`: a matching env var re-enables a mod saved as + disabled, and an unset one leaves the saved disable alone. + +## Deprecation etiquette + +Nothing deprecated. These are two additive hooks and one additive manifest +field; `main.lua`'s only footprint is one `require` and two call sites +into `src/core/PlatformHooks.lua`. diff --git a/docs/rfcs/0007-per-category-game-speed.md b/docs/rfcs/0007-per-category-game-speed.md new file mode 100644 index 00000000..eb34b3a4 --- /dev/null +++ b/docs/rfcs/0007-per-category-game-speed.md @@ -0,0 +1,212 @@ +# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook + +## Status + +Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`, +`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests: +`tests/engine/game_speed_categories_test.lua`, +`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua` +(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order). + +## Motivation + +`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier +applied uniformly to the whole logic clock in `Game:logicSpeed()` / +`Game:update()` -- overworld walking, menu navigation and battle turns all +scale together. A player who wants 4X battles (grinding, a long gym fight) +but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past) +has no way to get both; the one GAME SPEED row is a single ladder that +applies everywhere at once. + +This needs to be an engine change, not a mod: there is no per-frame seam a +mod can use to swap the multiplier mid-step, and no public event granular +enough to say "which category is active" (`screen.pushed`/`screen.popped` +and `battle.started`/`battle.ended` are the closest and are not enough -- +see Decisions below). The engine's own speed resolution has to become +category-aware. + +A category-aware speed resolution is also the general seam a +platform-launcher integration or automation tool needs to read or override +the effective multiplier for a given frame without caring which category +produced it -- this RFC's `core.logic_speed` hook is written for that case +alongside the player-facing Options rows. + +## The decision it extends + +No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged) +with per-category resolution. + +## The exact API delta + +Backward-compatible except for one save-data field rename, which ships with +an automatic migration (see below) -- nothing in the public mod API (hooks, +events, registries, `mod.*`) is renamed or removed. + +### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu` + +`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list +of categories, and `GameSpeed.optionKey(category)` maps a category to its +`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.). +`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are +unchanged -- the ladder and its behavior are exactly what they were, just +applied three times instead of once. + +`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`, +`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a +loaded options table that still has `speed` and none of the three new +fields seeds all three from it, so an existing player's fast-forward +preference carries over instead of two of the three categories silently +resetting to 1X. `speed` is dropped on the way out (not carried forward), +so a re-save never re-triggers the migration. + +### `Game.speedCategoryInStack(stack)` + +New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the +whole state stack top-down -- the same idiom `Game.wideBattleInStack` and +`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new +marker, `BattleState.isBattle = true`, covering every battle: wild, +trainer, link, safari, the old-man demo) or `state.isOverworld` (existing +marker, `OverworldController`'s `OverworldState.isOverworld = true`). The +first match wins; a state with neither marker (a menu, a text box, a +naming screen, a cutscene) is transparent to the walk and falls through to +whatever is under it. Nothing in the stack matching either falls back to +`"menu"`. + +### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()` + +`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack` +against the live stack, maps the category to its `save.options` key via +`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or +`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new +hook wraps. + +`Game:logicSpeed()` keeps its existing early returns -- link play forces +`1`, a run-argument speed override wins over the saved option -- unchanged, +and in the same order, before ever calling the hook. Only once neither +applies does it call the `core.logic_speed` hook. + +### `Game:_cycleSpeed(dir)` + +The keyboard hotkey and the gamepad shoulders/triggers that used to cycle +the single `speed` option now cycle whichever category +`Game.speedCategoryInStack` says is active: pressing the hotkey during a +battle speeds up just the battle, on the overworld just the walk, in a menu +just the menu. This is the natural per-category answer for a control that +used to have one option to reach and now has three -- see Decisions below +for why this reading was chosen over, say, always cycling `overworld`. + +### `core.logic_speed` + +New hook, `(game) -> number` through the public wrapper signature +`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame). +Vanilla behavior (used when no mod claims the hook) is +`Game:_resolveLogicSpeed()` -- exactly the category resolution above, +nothing else. A subscriber may call `next(game)` and return its result to +pass the vanilla multiplier through, or return a different number outright +to override it for that frame (e.g. a bot mod forcing `1` during one route +segment regardless of what category or option is active). + +This intentionally sits *after* the link and speed-override checks in +`Game:logicSpeed()`, not around them: link play staying locked to 1X "no +matter what either player set this to" is exactly the invariant that would +break if a mod's hook could override it, and the run-argument override +exists so a bot/screenshot run's speed does not depend on a mod any more +than on the player's saved option. Both stay unconditional early returns a +mod never sees. + +Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a +bare `vanilla(...)` call when no mod has wrapped the name, and this hook +fires every frame regardless. + +## Decisions on the issue's open questions + +**1. Overlays on top of another category's state (a party menu, a choice +box, a naming screen opened mid-battle or mid-overworld).** Resolved by +making the category a property of stack *position*, not of the overlay's +own type: an overlay with no `isBattle`/`isOverworld` marker is transparent +to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A +party swap opened mid-battle reads as `"battle"`; a bag opened while +walking reads as `"overworld"`. This was chosen over giving every UI state +its own fixed category (which would make a fast-forwarded battle visibly +stutter back to 1X every time its party menu opens) because it matches +what the player is actually doing moment to moment, and it reuses a +pattern the codebase already leans on for exactly this "menus opened over +X should behave like X" class of problem (`Game.fillScaleInStack`, +`Game.wideBattleInStack`). + +**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs +through the owning state's own machinery -- the overworld's script runner +or a battle's message queue -- rather than pushing a state of its own, so +it is already covered by decision 1: it inherits whatever category the +state driving it resolves to. A cutscene state that genuinely has nothing +under it (a pre-game intro) falls to `"menu"`, the default for anything +that is not battle or overworld gameplay -- consistent with those being +pre-game presentation, not something a player is likely to want scaled +differently from menu navigation. + +**3. Category granularity (splitting "menu" further).** Deferred. Start +with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey` +are written so adding a fourth later (a Pokédex/Bag category, say) is one +entry plus one new `save.options` field, not a resolution-logic rewrite. +No current request motivates it. + +**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three +things.** `Game:_cycleSpeed` now cycles whichever category is currently +active (`Game.speedCategoryInStack`), rather than, say, always cycling +`overworld` or requiring a modifier key to pick a category. A single +physical control that means "speed up whatever I'm looking at right now" +is the reading that needs no new UI and matches what a player pressing it +mid-battle almost certainly wants. + +## Migration note for existing mods + +**Nothing**, for the mod API surface: `content.X:register/override/get`, +`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are +untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle` +keep their exact signatures and behavior. + +**One save-data field**, for anything that read `save.options.speed` +directly (not a formal registry/hook surface, but worth naming): it is +superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated +automatically on load (see above) so a save from before this RFC keeps its +player's chosen speed. A mod reading `save.options.speed` after this change +sees `nil` (the key is dropped on migration, not kept as a stale alias) and +should read the per-category fields, or hook `core.logic_speed` to observe +the resolved multiplier directly regardless of which category produced it. + +## Parity tests + +- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file -- + `tests/engine/gate_hooks.lua` walks the live hook catalog (which scans + `src` for `Runtime.call("...")` call sites), so the new + `Runtime.call("core.logic_speed", ...)` site is picked up and gated + automatically: vanilla runs exactly once with an empty hook chain, an + unsubscribed-but-live bus passes values and multiple returns through + unchanged, and `Runtime.wantsHook` reads `false`. +- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the + hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed", + ...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a + subscriber can read the vanilla category resolution via `next(game)` and + can override it outright -- plus direct coverage of + `Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay + inheriting each, an empty/unmatched stack falling to `"menu"`) and + `Game:logicSpeed()`'s precedence (link forces 1X over all three + categories and over a hook override; the run-argument override wins over + the category resolution). +- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows + (OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping + independently, in place of the old single GAME SPEED row. +- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index + activations (MODS, CONTROLS) are updated for the two extra rows. +- A link-play driver should set all three per-category speeds high before + asserting `game:logicSpeed()` reads `1` during a real link session, + proving the lock wins over every category at once, not just whichever + one happens to be active. + +## Deprecation etiquette + +Nothing deprecated in the mod-facing hook/event/registry catalog -- this +adds one hook, additive. The `save.options.speed` field is superseded with +an automatic migration rather than a deprecation notice, since it was never +a registered mod-API surface (no schema entry, no registry) -- the same +treatment any other `save.options` field would get if it needed reshaping. diff --git a/main.lua b/main.lua index cb57bc37..f8c349e8 100644 --- a/main.lua +++ b/main.lua @@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE = local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") +local PlatformHooks = require("src.core.PlatformHooks") -- Lua errors: persist a redacted trace in the save dir and surface a hint. do @@ -426,7 +427,11 @@ function love.update(dt) end return end - Game:update(dt) + -- Mods may wrap or veto the per-frame simulation step (pause it, react + -- to external platform state, etc.) -- see docs/modding.md's core.update + -- entry. Vanilla behavior (used when no mod claims the hook) is just + -- Game:update(dt), unconditionally, exactly as before this hook existed. + PlatformHooks.update(Game, dt) end function love.draw() @@ -819,8 +824,16 @@ function love.quit() or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM") -- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go -- back to and the restart would re-read the shortcut, so it exits instead. - if Game and not Importer and not quitToLauncher and not scripted - and not launchedIntoGame then + -- + -- A platform launcher that owns "return to launcher" itself (see + -- docs/modding.md's core.quit_to_launcher entry) may veto returning to + -- this Lua launcher via that hook. Vanilla behavior (used when no mod + -- claims the hook) is exactly the condition below. + local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() + return Game and not Importer and not quitToLauncher and not scripted + and not launchedIntoGame + end) + if wouldReturnToLauncher then quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- once, so the restart really does land in the launcher (#887). A failed diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 6cba2fb5..2204e45d 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on) env->DeleteLocalRef(activity); } +extern "C" __attribute__((visibility("default"))) +const char *love_android_poll_secondary_touch() +{ + static thread_local std::string event; + event.clear(); + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch", + "()Ljava/lang/String;"); + if (!method) + env->ExceptionClear(); + else + { + jstring value = (jstring) env->CallStaticObjectMethod(activity, method); + if (value) + { + const char *utf = env->GetStringUTFChars(value, nullptr); + if (utf) + { + event = utf; + env->ReleaseStringUTFChars(value, utf); + } + env->DeleteLocalRef(value); + } + } + env->DeleteLocalRef(activity); + return event.empty() ? nullptr : event.c_str(); +} + #endif // LOVE_ANDROID diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 1d70c7fa..09fa2148 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private static final int MAX_SECONDARY_TOUCHES = 32; + private static final java.util.ArrayDeque secondaryTouches = + new java.util.ArrayDeque<>(); @Keep public static void setSecondaryEnabled(final boolean on) { @@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity { private static void teardownSecondaryDisplay() { SecondaryPresentation p = secondaryPresentation; secondaryPresentation = null; + synchronized (secondaryTouches) { secondaryTouches.clear(); } if (p != null) { try { p.dismiss(); } catch (Throwable t) {} } @@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity { } } + @Keep + public static String pollSecondaryDisplayTouch() { + synchronized (secondaryTouches) { + return secondaryTouches.pollFirst(); + } + } + private static class SecondaryPresentation extends android.app.Presentation { private final FrameView frameView; @@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity { private final android.graphics.Paint paint = new android.graphics.Paint(); private final Object lock = new Object(); private int fw, fh; + private int activePointer = -1; FrameView(Context context) { super(context); @@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity { postInvalidate(); } + private void enqueueTouch(String event) { + synchronized (secondaryTouches) { + if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) { + secondaryTouches.clear(); + secondaryTouches.addLast("cancel,0,0"); + } else { + secondaryTouches.addLast(event); + } + } + } + + private int logicalX(float x) { + return Math.min(fw - 1, Math.max(0, + (int) ((x - dst.left) * fw / dst.width()))); + } + + private int logicalY(float y) { + return Math.min(fh - 1, Math.max(0, + (int) ((y - dst.top) * fh / dst.height()))); + } + + @Override + public boolean onTouchEvent(android.view.MotionEvent event) { + synchronized (lock) { + int action = event.getActionMasked(); + if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0 + && dst.contains((int) event.getX(), (int) event.getY())) { + activePointer = event.getPointerId(0); + enqueueTouch("down," + logicalX(event.getX()) + "," + + logicalY(event.getY())); + } else if (action == android.view.MotionEvent.ACTION_UP + && activePointer >= 0) { + int index = event.findPointerIndex(activePointer); + if (index >= 0 && fw > 0) { + enqueueTouch("up," + logicalX(event.getX(index)) + "," + + logicalY(event.getY(index))); + } + activePointer = -1; + } else if (action == android.view.MotionEvent.ACTION_CANCEL) { + activePointer = -1; + enqueueTouch("cancel,0,0"); + } + } + return true; + } + @Override protected void onDraw(android.graphics.Canvas canvas) { synchronized (lock) { diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index 9f3e3966..1793f7c4 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -85,11 +85,30 @@ public final class GRPickerBridge: NSObject { types = [.zip] case "sav": destName = "picked_save.sav" - default: + // A Nintendo 64 cartridge, for mods that build assets out of one -- + // the voxel mod's Pokemon Stadium battle models are the caller this + // was added for. Its own filename on purpose: an N64 ROM landing on + // picked_rom.gb is swept up by the Game Boy importer, deleted, and + // reported to the player as a broken cartridge. + case "stadium": + destName = "picked_stadium.z64" + for ext in ["z64", "n64", "v64"] { + if let t = UTType(filenameExtension: ext) { types.append(t) } + } + case "rom", "": destName = "picked_rom.gb" for ext in ["gb", "gbc"] { if let t = UTType(filenameExtension: ext) { types.append(t) } } + // An unknown kind is REFUSED rather than treated as a Game Boy ROM. + // + // It used to fall through to picked_rom.gb, so a caller asking for a + // kind this build had never heard of got its file deleted and + // reported as a broken cartridge -- the worst possible answer to + // "I do not know that one". Returning false lets the caller find out + // and offer its own fallback. + default: + return false } // .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering // .data as well keeps every real file selectable. The importer @@ -107,6 +126,20 @@ public final class GRPickerBridge: NSObject { return present(picker, with: delegate) } + // Which kinds presentPicker understands, comma separated. + // + // So a CALLER can ask before it calls. A mod that wants a kind this build + // predates cannot otherwise tell "refused" from "the picker would not + // open", and guessing wrong used to cost the player their ROM (see the + // default case above). Asking first turns that into a fallback the caller + // chooses rather than a file it loses. + // + // Kept beside the switch it describes, because the two drifting apart is + // the only way this can lie. + @objc public static func supportedPickerKinds() -> NSString { + return "rom,mod,sav,stadium" as NSString + } + @objc(presentExportWithName:saveDir:) public static func presentExport(name: UnsafePointer?, saveDir: UnsafePointer?) -> Bool { diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index ef9d1b39..d640bee7 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -84,6 +84,49 @@ int w_pickFile(lua_State *L) return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind); } +// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS. +// +// So a caller can ask what this build's picker understands BEFORE opening it. +// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly +// like a picker that would not open -- so a caller with a fallback worth +// showing needs to know which it is facing. A mod that guesses instead has +// no way back: before the refusal landed, an unrecognised kind wrote +// picked_rom.gb and the ROM importer deleted it. +// +// nil where there is no bridge at all, which reads the same as "no kinds". +int w_pickFileKinds(lua_State *L) +{ + Class cls = objc_getClass("GRPickerBridge"); + if (cls == nullptr) + { + lua_pushnil(L); + return 1; + } + // Fetched through the runtime: wrap_System.cpp is compiled as C++ rather + // than Objective-C++, so no Foundation type may be NAMED here -- writing + // `NSString` alone breaks the whole translation unit. objc_msgSend is a + // plain C entry point and `id` comes from objc/runtime.h, so the string + // is asked for its UTF8 bytes without ever being typed. + typedef id (*GRObj)(Class, SEL); + id kinds = ((GRObj)objc_msgSend)(cls, + sel_registerName("supportedPickerKinds")); + if (kinds == nullptr) + { + lua_pushnil(L); + return 1; + } + typedef const char *(*GRUTF8)(id, SEL); + const char *bytes = ((GRUTF8)objc_msgSend)(kinds, + sel_registerName("UTF8String")); + if (bytes == nullptr || bytes[0] == '\0') + { + lua_pushnil(L); + return 1; + } + lua_pushstring(L, bytes); + return 1; +} + int w_createFile(lua_State *L) { const char *name = luaL_optstring(L, 1, "export.sav"); @@ -101,6 +144,7 @@ int w_syncHealthSteps(lua_State *L) WRAP_REGISTRATION = """#ifdef LOVE_IOS { "pickFile", w_pickFile }, + { "pickFileKinds", w_pickFileKinds }, { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, diff --git a/mods/spanish_ui/README.md b/mods/spanish_ui/README.md new file mode 100644 index 00000000..b844ac80 --- /dev/null +++ b/mods/spanish_ui/README.md @@ -0,0 +1,27 @@ +# spanish_ui + +A Espanol translation of the game. + +Generated with `python3 tools/modkit.py translation spanish_ui`. See +`TRANSLATING.md` for how to work on it. + +## Status + +Nothing is translated yet: 601 strings are waiting in `lang/`. + +| Catalog | Entries | +|---|---| +| `lang/dialogue.lua` | 6 | +| `lang/strings.lua` | 577 | +| `lang/species_names.lua` | 3 | +| `lang/move_names.lua` | 4 | +| `lang/item_names.lua` | 5 | +| `lang/trainer_names.lua` | 1 | +| `lang/status_labels.lua` | 5 | + +## Layout + +- `manifest.json` - identity and the engine version range +- `main.lua` - registers whatever is filled in and skips whatever is not +- `lang/` - the catalogs; this is the whole job +- `assets/font/` - your glyph sheet diff --git a/mods/spanish_ui/TRANSLATING.md b/mods/spanish_ui/TRANSLATING.md new file mode 100644 index 00000000..f1cca9bc --- /dev/null +++ b/mods/spanish_ui/TRANSLATING.md @@ -0,0 +1,111 @@ +# Translating into Espanol + +Everything the player can read is one of two kinds of string, and they live +in different places for a reason. + +| lang/ file | What it is | Key | +|---|---|---| +| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` | +| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string | +| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id | +| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id | +| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below | +| `naming.lua` | The letter grid for entering names | - | + +Fill in a value and it takes effect. Leave it `""` and that string stays in +English, so the game is playable at every point along the way. + +## Where the English is + +The catalogs hold keys and *your* text, never the original English. The +English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per +catalog: + +``` +"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?" +``` + +That directory is deliberately outside the mod. Extracted script text and +the vanilla names are ROM content, and `modkit pack` zips everything under +the mod directory, so a worksheet kept inside would end up in your release +whatever a `.gitignore` said. Keep it beside the mod, never in it. + +`lang/strings.lua` is the exception: those sources are the engine's own Lua +rather than anything out of the ROM, so there the key *is* the English and +you can translate straight from it. + +## Start with the font, not the text + +The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap +saying which byte sequence draws which cell. The vanilla pages sit at `$60` +and `$80`. Anything from `0x100` up is free, so a new alphabet is added +rather than swapped in: + +```lua +-- lang/font.lua +return { + spanish_ui = { + image = "assets/font/spanish_ui.png", + base = 0x100, -- first code this page owns + glyphsPerRow = 16, + -- advance = 8, -- set this if your glyphs are not 8px wide + }, +} +``` + +```lua +-- lang/charmap.lua: sequence -> code, in the same order as the sheet +return { + ["A"] = 0x100, + ["B"] = 0x101, +} +``` + +The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8, +black on white like `assets/generated/font.png`. Codes run left to right, +top to bottom from `base`. + +Sequences are matched **longest first**, so a multi-byte character and a +multi-character ligature both work and neither shadows the other: + +```lua +["\u{3042}"] = 0x120, -- one 3-byte character, one glyph +["ch"] = 0x121, -- two ASCII letters, one glyph +``` + +## Line length is counted in glyphs + +The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character +costs one column, and the engine will never cut a character in half. Your +own `\n` line breaks are respected exactly as written, so break lines where +they read best rather than where they fit English. + +If your glyphs are not 8px wide, set `advance` on the page and the box +re-measures. + +## Format directives must survive + +Some sources carry `%s` or `%d`: + +```lua +["Wild %s\nappeared!"] = "...", +``` + +Keep every directive, in a count that matches. Word order is yours to +change; the engine substitutes in the order the directives appear, so if +your language needs the name last, write the sentence with the `%s` last. +A translation whose directive count does not match the English is refused +at runtime and the English is drawn instead, with a line in the log saying +so - it will not crash a battle. + +## Checking your work + +```sh +python3 tools/modkit.py validate spanish_ui --base imported +python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings +POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/ +``` + +`--refresh` rewrites the catalogs from the current engine, keeping every +translation you have already written and reporting what changed. Run it +after pulling a new engine version. diff --git a/mods/spanish_ui/assets/font/README.md b/mods/spanish_ui/assets/font/README.md new file mode 100644 index 00000000..8f0f5890 --- /dev/null +++ b/mods/spanish_ui/assets/font/README.md @@ -0,0 +1,11 @@ +Put your glyph sheet here. + +A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes +run left to right and top to bottom starting at the page's `base`, so the +first cell is `base`, the second `base + 1`, and so on. + +`assets/generated/font.png` in the player's cache is the vanilla sheet at +the same scale; open it alongside yours to match weight and baseline. + +Declare the sheet in `lang/font.lua` and map sequences to codes in +`lang/charmap.lua`. diff --git a/mods/spanish_ui/lang/charmap.lua b/mods/spanish_ui/lang/charmap.lua new file mode 100644 index 00000000..ca0e3140 --- /dev/null +++ b/mods/spanish_ui/lang/charmap.lua @@ -0,0 +1,10 @@ +-- Which byte sequence draws which glyph code. +-- +-- Sequences are matched longest-first, so a multi-byte character and a +-- multi-character ligature both work: "ch" can be one glyph even though +-- "c" is also mapped. Codes here must land inside a page declared in +-- lang/font.lua. +return { + -- ["A"] = 0x100, + -- ["B"] = 0x101, +} diff --git a/mods/spanish_ui/lang/dialogue.lua b/mods/spanish_ui/lang/dialogue.lua new file mode 100644 index 00000000..8d3c16f3 --- /dev/null +++ b/mods/spanish_ui/lang/dialogue.lua @@ -0,0 +1,12 @@ +-- Script text +-- +-- Keyed by the original text label. The English is in the comment. + +return { + ["_FixMartText"] = "", + ["_FixRouteTrainerAfterText"] = "", + ["_FixRouteTrainerBattleText"] = "", + ["_FixRouteTrainerEndText"] = "", + ["_FixTownGreeterText"] = "", + ["_FixTownSignText"] = "", +} diff --git a/mods/spanish_ui/lang/font.lua b/mods/spanish_ui/lang/font.lua new file mode 100644 index 00000000..4b878079 --- /dev/null +++ b/mods/spanish_ui/lang/font.lua @@ -0,0 +1,13 @@ +-- Glyph pages this translation adds. Delete the entry if the vanilla +-- alphabet already covers your language. +-- +-- base is the first glyph code the page owns. 0x100 and up is free space +-- above the vanilla $60/$80 pages, so this adds an alphabet rather than +-- replacing one. Set `advance` if your glyphs are not 8px wide. +return { + -- spanish_ui = { + -- image = "assets/font/spanish_ui.png", + -- base = 0x100, + -- glyphsPerRow = 16, + -- }, +} diff --git a/mods/spanish_ui/lang/item_names.lua b/mods/spanish_ui/lang/item_names.lua new file mode 100644 index 00000000..2798d181 --- /dev/null +++ b/mods/spanish_ui/lang/item_names.lua @@ -0,0 +1,11 @@ +-- Item names +-- +-- Item names for Espanol. + +return { + ["FIX_BADGE_1"] = "", + ["FIX_BADGE_2"] = "", + ["FIX_BALL"] = "", + ["FIX_POTION"] = "", + ["FIX_TM"] = "", +} diff --git a/mods/spanish_ui/lang/move_names.lua b/mods/spanish_ui/lang/move_names.lua new file mode 100644 index 00000000..80abf686 --- /dev/null +++ b/mods/spanish_ui/lang/move_names.lua @@ -0,0 +1,10 @@ +-- Move names +-- +-- Move names for Espanol. + +return { + ["FIX_CUT"] = "", + ["FIX_EMBERISH"] = "", + ["FIX_SCRATCH"] = "", + ["FIX_TACKLE"] = "", +} diff --git a/mods/spanish_ui/lang/naming.lua b/mods/spanish_ui/lang/naming.lua new file mode 100644 index 00000000..adb0666b --- /dev/null +++ b/mods/spanish_ui/lang/naming.lua @@ -0,0 +1,41 @@ +-- The naming screen's letter grid. Return an empty table to keep the +-- English alphabet. +-- +-- Each entry is a row of cells; a cell is whatever sequence your charmap +-- maps, so a multi-byte character is one cell. The row holding a single +-- "lower case" / "UPPER CASE" cell is the case switch, and the cell +-- spelled "ED" is the confirm. +-- +-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at +-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**: +-- a 10th column lands at x=160 and a 7th row at y=144, both off screen. +-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish +-- letters have to displace something rather than being added. +-- +-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a +-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in +-- Spanish -- and here it sits in its alphabetical place after N, which is +-- where a Spanish speaker will look for it. Space, and are kept. +-- +-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É, +-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they +-- do not, so main.lua checks the running game's charmap first and keeps the +-- English grid rather than drawing blank cells. +return { + upper = { + { "A", "B", "C", "D", "E", "F", "G", "H", "I" }, + { "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" }, + { "R", "S", "T", "U", "V", "W", "X", "Y", "Z" }, + { "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "", "" }, + { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "lower case" }, + }, + lower = { + { "a", "b", "c", "d", "e", "f", "g", "h", "i" }, + { "j", "k", "l", "m", "n", "ñ", "o", "p", "q" }, + { "r", "s", "t", "u", "v", "w", "x", "y", "z" }, + { "á", "é", "í", "ó", "ú", "ü", " ", "", "" }, + { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, + { "UPPER CASE" }, + }, +} diff --git a/mods/spanish_ui/lang/species_names.lua b/mods/spanish_ui/lang/species_names.lua new file mode 100644 index 00000000..7a4e5645 --- /dev/null +++ b/mods/spanish_ui/lang/species_names.lua @@ -0,0 +1,9 @@ +-- Species names +-- +-- Species names for Espanol. + +return { + ["FIXMON_A"] = "", + ["FIXMON_B"] = "", + ["FIXMON_C"] = "", +} diff --git a/mods/spanish_ui/lang/status_labels.lua b/mods/spanish_ui/lang/status_labels.lua new file mode 100644 index 00000000..b3cd3365 --- /dev/null +++ b/mods/spanish_ui/lang/status_labels.lua @@ -0,0 +1,11 @@ +-- Status labels +-- +-- Short enough for the battle HUD: the vanilla ones are three glyphs. + +return { + ["BRN"] = "", + ["FRZ"] = "", + ["PAR"] = "", + ["PSN"] = "", + ["SLP"] = "", +} diff --git a/mods/spanish_ui/lang/strings.lua b/mods/spanish_ui/lang/strings.lua new file mode 100644 index 00000000..c0858d3c --- /dev/null +++ b/mods/spanish_ui/lang/strings.lua @@ -0,0 +1,584 @@ +-- Engine text +-- +-- Keyed by the English source, which is also what draws if you leave +-- an entry empty. Keep any %s / %d directives. + +return { + ["%s\nflew up high!"] = "¡%s\nvoló muy alto!", + ["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!", + ["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!", + ["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!", + ["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!", + ["%s\nis glowing!"] = "¡%s\nestá brillando!", + ["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!", + ["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!", + ["%s wants\nto fight!"] = "¡%s\nquiere luchar!", + ["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!", + ["Go! %s!"] = "¡Ve, %s!", + ["Do it! %s!"] = "¡Hazlo, %s!", + ["Get'm! %s!"] = "¡A por él, %s!", + ["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!", + ["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!", + ["%s blacked\nout!"] = "¡%s se\ndebilitó!", + ["%s sent\nout %s!"] = "¡%s envió\na %s!", + ["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!", + ["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!", + ["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!", + ["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!", + ["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!", + ["But, it failed!"] = "¡Pero falló!", + ["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!", + ["POKé BALL"] = "POKé BALL", + ["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!", + ["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!", + ["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...", + ["%s with-\ndrew %s!"] = "¡%s retiró\na %s!", + ["%s\nmust recharge!"] = "¡%s debe\nrecargarse!", + ["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!", + ["%s\nis confused!"] = "¡%s está\nconfuso!", + ["%s\nwoke up!"] = "¡%s se\ndespertó!", + ["%s\nis frozen solid!"] = "¡%s está\ncongelado!", + ["%s\ncan't move!"] = "¡%s no\npuede moverse!", + ["%s\nflinched!"] = "¡%s se\namedrentó!", + ["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!", + ["%s\nused %s!"] = "¡%s usó\n%s!", + ["%s\nis charging up!"] = "¡%s está\ncargando energía!", + ["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!", + ["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!", + ["%s\nis storing energy!"] = "¡%s está\nacumulando energía!", + ["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!", + ["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!", + ["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!", + ["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!", + ["%s\nfainted!"] = "¡%s se\ndebilitó!", + ["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!", + ["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!", + ["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!", + ["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!", + ["%s is\nabout to use"] = "%s va a usar", + ["%s!"] = "¡%s!", + ["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?", + ["%s defeated\n%s!"] = "¡%s venció\na %s!", + ["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!", + ["%s learned\n%s!"] = "¡%s aprendió\n%s!", + ["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?", + ["Use next POKéMON?"] = "¿Sacar al siguiente?", + ["Got away safely!"] = "¡Escapaste!", + ["Can't escape!"] = "¡No puedes escapar!", + ["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!", + ["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!", + ["%s threw some\nBAIT."] = "%s echó\nCEBO.", + ["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.", + ["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!", + ["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!", + ["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!", + ["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!", + ["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!", + ["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!", + ["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!", + ["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!", + ["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?", + ["NICKNAME?"] = "MOTE?", + ["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!", + ["someone's PC"] = "el PC de alguien", + ["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!", + ["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!", + ["%s used\n%s!"] = "¡%s usó\n%s!", + ["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!", + ["Don't be a thief!"] = "¡No seas ladrón!", + ["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!", + ["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!", + ["%s is\nalready out!"] = "¡%s ya\nestá fuera!", + ["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!", + ["FIGHT"] = "LUCHAR", + ["ITEM"] = "OBJETO", + ["RUN"] = "HUIR", + ["BALLx"] = "BALLx", + ["BAIT"] = "CEBO", + ["THROW ROCK"] = "TIRAR PIEDRA", + ["disabled!"] = "¡anulado!", + ["TYPE/"] = "TIPO/", + ["It doesn't affect\n%s!"] = "¡No afecta a\n%s!", + ["Critical hit!"] = "¡Golpe crítico!", + ["One-hit KO!"] = "¡KO en un golpe!", + ["It's super\neffective!"] = "¡Es muy eficaz!", + ["It's not very\neffective..."] = "¡No es muy\neficaz...", + ["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!", + ["Hit %d times!"] = "¡Golpeó %d veces!", + ["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!", + ["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!", + ["Nothing happened!"] = "¡No pasó nada!", + ["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!", + ["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!", + ["%s's\n%s fell!"] = "¡%s bajó\nsu %s!", + ["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!", + ["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!", + ["%s\nbecame confused!"] = "¡%s se\nconfundió!", + ["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!", + ["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!", + ["%s\nregained health!"] = "¡%s recuperó\nsalud!", + ["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!", + ["%s\ngained armor!"] = "¡%s ganó\narmadura!", + ["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!", + ["%s's\ngetting pumped!"] = "¡%s se\nestá animando!", + ["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!", + ["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!", + ["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!", + ["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!", + ["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!", + ["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!", + ["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!", + ["No effect!"] = "¡Sin efecto!", + ["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!", + ["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!", + ["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!", + ["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!", + ["%s\nran away scared!"] = "¡%s huyó\nasustado!", + ["%s\nwas blown away!"] = "¡%s salió\nvolando!", + ["%s\nran from battle!"] = "¡%s huyó\ndel combate!", + ["It didn't affect\n%s!"] = "¡No afectó a\n%s!", + ["%s\nis unaffected!"] = "¡%s no se\nvio afectado!", + ["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!", + ["%s\nfell asleep!"] = "¡%s se\nquedó dormido!", + ["%s\nwas frozen solid!"] = "¡%s se\ncongeló!", + ["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!", + ["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!", + ["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!", + ["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!", + ["%s\nwas burned!"] = "¡%s se\nquemó!", + ["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!", + ["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!", + ["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!", + ["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!", + ["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!", + ["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!", + ["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!", + ["What will"] = "¿Qué va a hacer", + [" do?"] = "?", + ["You can't get off\nhere."] = "No puedes bajarte\naquí.", + ["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.", + ["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!", + ["No cycling\nallowed here."] = "No se puede montar\naquí.", + ["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.", + ["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!", + ["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.", + ["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.", + ["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.", + ["Booted up a TM!"] = "¡Se activó una MT!", + ["It contained\n%s!"] = "¡Contenía\n%s!", + ["USE"] = "USAR", + ["TOSS"] = "TIRAR", + ["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!", + ["Threw away\n%s."] = "Tiraste\n%s.", + ["PRESS A BUTTON"] = "PULSA UN BOTON", + ["ESC TO CANCEL"] = "ESC PARA CANCELAR", + ["%s :L%d"] = "%s :N%d", + ["STATS"] = "DATOS", + ["CANCEL"] = "CANCELAR", + ["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!", + ["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.", + ["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)", + ["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.", + ["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!", + ["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.", + ["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!", + ["BOX %d is full!"] = "¡La CAJA %d está\nllena!", + ["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.", + ["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)", + ["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?", + ["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!", + ["%sBOX %2d"] = "%sCAJA %2d", + ["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?", + ["What?"] = "¿Qué?", + ["BOX No."] = "CAJA No.", + ["BOX No.%d"] = "CAJA No.%d", + ["Empty."] = "Vacía.", + [":L%d No.%03d"] = ":N%d No.%03d", + ["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.", + ["Printer error!\n%s"] = "¡Error de impresión!\n%s", + ["WITHDRAW "] = "RETIRAR ", + ["DEPOSIT "] = "GUARDAR ", + ["RELEASE "] = "SOLTAR ", + ["CHANGE BOX"] = "CAMBIAR CAJA", + ["PRINT BOX"] = "IMPRIMIR CAJA", + ["SEE YA!"] = "HASTA LUEGO!", + ["YES"] = "SI", + ["NO"] = "NO", + ["GAME FREAK"] = "", + ["Nintendo"] = "", + ["Creatures inc."] = "", + ["GAME FREAK inc."] = "", + ["T H E E N D"] = "F I N", + ["HT %d′%02d″"] = "AL %d′%02d″", + ["WT %.1flb"] = "PE %.1flb", + ["Data unknown."] = "Datos desconocidos.", + [""] = "", + ["Player"] = "Jugador", + ["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!", + ["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!", + ["evolving!"] = "evolucionando", + ["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}", + ["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}", + ["Keep it up!"] = "¡Sigue así!", + ["LEVEL/"] = "NIVEL/", + ["TYPE1/"] = "TIPO1/", + ["TYPE2/"] = "TIPO2/", + ["HALL OF FAME"] = "SALON DE LA FAMA", + ["PLAY TIME"] = "TIEMPO", + ["MONEY"] = "DINERO", + ["bois club games"] = "bois club games", + ["GENGAR VS NIDORINO"] = "", + ["bois club"] = "bois club", + ["Nothing here."] = "Aquí no hay nada.", + ["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f", + ["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?", + ["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!", + ["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?", + ["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!", + ["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!", + ["Which move should"] = "¿Qué movimiento", + ["be forgotten?"] = "hay que olvidar?", + ["YOUR NAME?"] = "TU NOMBRE?", + ["NEW NAME"] = "NUEVO NOMBRE", + ["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.", + ["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!", + ["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.", + ["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!", + ["First, what is\nyour name?"] = "¿Cómo te llamas?", + ["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?", + ["HIS NAME?"] = "SU NOMBRE?", + ["_OakSpeechText2A"] = "", + ["TEXT SPEED"] = "VEL TEXTO", + ["BATTLE ANIMATION"] = "ANIMACIONES", + ["OFF"] = "NO", + ["ON"] = "SI", + ["BATTLE STYLE"] = "ESTILO COMBATE", + ["SET"] = "FIJO", + ["SHIFT"] = "CAMBIO", + ["BATTLE LAYOUT"] = "DISENO COMBATE", + ["WIDE"] = "ANCHO", + ["OG"] = "OG", + ["RULESET"] = "REGLAS", + ["MUSIC VOL"] = "VOL MUSICA", + ["SFX VOL"] = "VOL SONIDO", + ["PIKACHU VOL"] = "VOL PIKACHU", + ["MUSIC FILTER"] = "FILTRO MUSICA", + ["COLORS"] = "COLORES", + ["TILT"] = "INCLINACION", + ["GBC FX"] = "EFECTO GBC", + ["ZOOM"] = "ZOOM", + ["VOID FILL"] = "RELLENO VACIO", + ["VIDEO MODE"] = "MODO VIDEO", + ["MAX FPS"] = "FPS MAXIMO", + ["GAME SPEED"] = "VELOCIDAD JUEGO", + ["MODS"] = "MODS", + ["%d INSTALLED"] = "%d INSTALADOS", + ["CONTROLS"] = "CONTROLES", + ["TOUCH PAD"] = "CONTROL TACTIL", + ["SURE? AGAIN"] = "SEGURO? OTRA VEZ", + ["AUTO HIDE PAD"] = "OCULTAR AUTO", + ["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!", + ["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!", + ["Nothing to CUT!"] = "¡Nada que CORTAR!", + ["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.", + ["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.", + ["It won't have\nany effect."] = "No tendrá ningún\nefecto.", + ["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!", + ["SWITCH"] = "CAMBIAR", + ["FLY"] = "VUELO", + ["FLASH"] = "DESTELLO", + ["CUT"] = "CORTE", + ["SURF"] = "SURF", + ["STRENGTH"] = "FUERZA", + ["SOFTBOILED"] = "HUEVO SUERTE", + ["TELEPORT"] = "TELETRANSPORTE", + ["DIG"] = "EXCAVAR", + ["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?", + ["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?", + ["Choose a POKéMON."] = "Elige un POKéMON.", + ["No POKéMON!"] = "¡Ningún POKéMON!", + ["ABLE"] = "PUEDE", + ["NOT ABLE"] = "NO PUEDE", + ["FNT"] = "DEB", + ["Move to where?"] = "¿Mover a dónde?", + ["Use on which one?"] = "¿Usar en cuál?", + ["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.", + ["Withdrew\n%s."] = "Retirado\n%s.", + ["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.", + ["%s was\nstored via PC."] = "%s se\nguardó en el PC.", + ["Toss %s?"] = "¿Tirar %s?", + ["Threw away %s."] = "Tiraste %s.", + ["WITHDRAW ITEM"] = "RETIRAR OBJETO", + ["DEPOSIT ITEM"] = "GUARDAR OBJETO", + ["TOSS ITEM"] = "TIRAR OBJETO", + ["LOG OFF"] = "SALIR", + ["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d", + ["DATA"] = "DATOS", + ["CRY"] = "VOZ", + ["AREA"] = "ZONA", + ["PRNT"] = "IMPR", + ["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.", + ["QUIT"] = "SALIR", + ["%s (%s)"] = "%s (%s)", + ["%s x%d"] = "%s x%d", + ["%s to box %d"] = "%s a caja %d", + ["LOAD REPORT"] = "CARGAR PARTIDA", + ["A:CONTINUE"] = "A:CONTINUAR", + ["You don't have\nenough money."] = "No tienes dinero\nsuficiente.", + ["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?", + ["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!", + ["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.", + ["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.", + ["BUY"] = "COMPRAR", + ["SELL"] = "VENDER", + ["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!", + ["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!", + ["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!", + ["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS", + ["COINS %4d"] = "FICHAS %4d", + ["POKéDEX"] = "POKéDEX", + ["POKéMON"] = "POKéMON", + ["SAVE"] = "GUARDAR", + ["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d", + ["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?", + ["Now saving..."] = "Guardando...", + ["%s saved\nthe game!"] = "¡%s guardó\nla partida!", + ["OPTION"] = "OPCION", + ["LINK"] = "LINK", + ["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?", + ["BALL"] = "BALL", + ["STATUS/"] = "ESTADO/", + ["OT/"] = "EO/", + ["EXP POINTS"] = "P. EXP.", + ["LEVEL UP"] = "SUBE NIVEL", + ["PP"] = "PP", + ["SCORE %d"] = "PUNTOS %d", + ["New record!"] = "¡Nuevo récord!", + ["HI %d"] = "MAX %d", + ["A: done"] = "A: listo", + ["PLAYER"] = "JUGADOR", + ["BADGES"] = "MEDALLAS", + ["TIME"] = "TIEMPO", + ["CONTINUE"] = "CONTINUAR", + ["NEW GAME"] = "NUEVA PARTIDA", + ["EXIT GAME"] = "SALIR DEL JUEGO", + ["POKéMON RED"] = "", + ["2026 bois club games"] = "", + ["OT/%s"] = "EO/%s", + ["NAME/%s"] = "NOMBRE/%s", + ["In battle"] = "En combate", + ["Wild battle"] = "Combate salvaje", + ["Trainer battle"] = "Combate entrenador", + ["Link battle"] = "Combate link", + ["Title screen"] = "Pantalla de título", + ["Level %d"] = "Nivel %d", + ["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!", + ["Not even a nibble!"] = "¡Ni un mordisco!", + ["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!", + ["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.", + ["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!", + ["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!", + ["An elevator!"] = "¡Un ascensor!", + ["INDIGO PLATEAU"] = "MESETA ANIL", + ["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON", + ["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!", + ["%s found\n%s!"] = "¡%s encontró\n%s!", + ["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!", + ["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.", + ["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.", + ["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.", + ["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!", + ["You don't have\nany coins!"] = "¡No tienes fichas!", + ["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}", + ["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.", + ["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!", + ["Bingo!"] = "¡Bingo!", + ["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!", + ["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!", + ["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!", + ["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!", + ["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.", + ["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!", + ["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!", + ["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!", + ["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!", + ["Gyaoo!"] = "¡Gyaoo!", + ["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?", + ["SOMEONE'S PC"] = "EL PC DE ALGUIEN", + ["PROF.OAK's PC"] = "EL PC DEL PROF.OAK", + ["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:", + ["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!", + ["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!", + ["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?", + ["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.", + ["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!", + ["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!", + ["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.", + ["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.", + ["Please come\nagain!"] = "¡Vuelve pronto!", + ["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!", + ["%s received\nthe %s!"] = "¡%s recibió\nel %s!", + ["%s received\n%s!"] = "¡%s recibió\n%s!", + ["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.", + ["Go right ahead!"] = "¡Adelante!", + ["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!", + ["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!", + ["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!", + ["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!", + ["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!", + ["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!", + ["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!", + ["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!", + ["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!", + ["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!", + ["contribution is not a table"] = "", + [" [%s %s.%s]"] = " [%s %s.%s]", + ["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.", + ["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.", + ["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)", + ["%s wants\nto battle!"] = "¡%s quiere\nluchar!", + ["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?", + ["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!", + ["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!", + ["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!", + ["%s left the\nbattle."] = "%s dejó el\ncombate.", + ["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!", + ["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.", + ["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.", + ["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)", + ["%s vs %s!"] = "¡%s contra %s!", + ["Link error:\n%s"] = "Error de link:\n%s", + ["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?", + ["The link was\nbroken."] = "Se ha perdido el\nlink.", + ["Link battle\ncan't start."] = "El combate link no\npuede empezar.", + ["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.", + ["The trade was\ncancelled."] = "El intercambio se\nha cancelado.", + ["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!", + ["LINK CABLE (LAN)"] = "CABLE LINK (LAN)", + ["ONLINE MATCH"] = "PARTIDA EN LINEA", + ["TOURNAMENT"] = "TORNEO", + ["HOST A GAME"] = "CREAR PARTIDA", + ["JOIN A GAME"] = "UNIRSE A PARTIDA", + ["UDP port %s"] = "Puerto UDP %s", + ["HOST ONLINE"] = "CREAR EN LINEA", + ["JOIN ONLINE"] = "UNIRSE EN LINEA", + ["Tell your friend"] = "Dile a tu amigo", + ["the code:"] = "el código:", + ["Waiting for join..."] = "Esperando...", + ["A: connect B: back"] = "A: conectar B: atrás", + ["Calling..."] = "Llamando...", + ["Friend joins at:"] = "Tu amigo entra en:", + ["Port: %s"] = "Puerto: %s", + ["TRADE"] = "INTERCAMBIO", + ["BATTLE"] = "COMBATE", + ["LEVELS:"] = "NIVELES:", + ["A: continue B: back"] = "A: seguir B: atrás", + ["Checking the"] = "Comprobando el", + ["other game..."] = "otro juego...", + ["Waiting for the"] = "Esperando a que", + ["host to choose..."] = "el anfitrión elija...", + ["A: trade anyway"] = "A: intercambiar igual", + ["YOURS"] = "TUYO", + ["THEIRS"] = "SUYO", + ["X: not on theirs"] = "X: no en el suyo", + ["A: trade B: cancel"] = "A: cambiar B: cancelar", + ["Exchanging data..."] = "Intercambiando...", + ["can't reach relay %s:%d\n(%s)"] = "", + ["That code wasn't\nfound."] = "Ese código no se\nha encontrado.", + ["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.", + ["That code has\nexpired."] = "Ese código ha\ncaducado.", + ["Couldn't join:\n%s"] = "No se pudo unir:\n%s", + ["no answer from\n%s"] = "", + ["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.", + ["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.", + ["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.", + ["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.", + ["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.", + ["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.", + ["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.", + ["Can't watch this\nmatch."] = "No se puede ver\neste combate.", + ["HOST"] = "CREAR", + ["JOIN"] = "UNIRSE", + ["START: create"] = "START: crear", + ["A: join B: back"] = "A: unirse B: atrás", + ["B: cancel"] = "B: cancelar", + ["TOURNAMENT %s"] = "TORNEO %s", + ["ROUND %d"] = "RONDA %d", + ["%s (bye)"] = "%s (pasa)", + ["%s%s vs %s%s"] = "%s%s contra %s%s", + ["(organizing --"] = "(organizando --", + ["not playing)"] = "no juega)", + ["Waiting for"] = "Esperando a que", + ["players to join:"] = "entren jugadores:", + ["A: START B: cancel"] = "A: START B: cancelar", + ["%s is the"] = "¡%s es el", + ["champion!"] = "campeón!", + ["A: continue"] = "A: continuar", + ["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.", + ["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!", + ["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.", + ["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!", + ["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!", + ["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!", + ["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!", + ["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!", + ["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!", + ["%s\nis refusing!"] = "¡%s se\nniega!", + ["%s's %s\nrose!"] = "¡El %s de %s\nsubió!", + ["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!", + ["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!", + ["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!", + ["Coin count:\n%d"] = "Fichas:\n%d", + ["NO MODS INSTALLED"] = "NO HAY MODS", + ["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..", + ["OPTIONS.."] = "OPCIONES..", + ["PERMISSIONS.."] = "PERMISOS..", + ["VIEW ERROR.."] = "VER ERROR..", + ["BACK"] = "ATRAS", + ["APPLY & RESTART"] = "APLICAR Y REINICIAR", + ["DISCARD CHANGES"] = "DESCARTAR CAMBIOS", + ["DATA & API ONLY"] = "SOLO DATOS Y API", + ["DISABLE BOTH?"] = "DESACTIVAR AMBOS?", + ["PROFILE NAME?"] = "NOMBRE DEL PERFIL?", + ["RENAME?"] = "RENOMBRAR?", + ["RESET DEFAULTS"] = "VALORES POR DEFECTO", + ["NO CHANGES"] = "SIN CAMBIOS", + ["A:OK"] = "A:OK", + ["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)", + ["MOD MANAGER"] = "GESTOR DE MODS", + ["Choose a mod .zip"] = "Elige un .zip de mod", + ["Choose a .sav save file"] = "Elige un archivo .sav", + ["An update is available"] = "Hay una actualización", + ["Name save slot"] = "Nombra la ranura", + ["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra", + ["Add a mod index"] = "Añadir un índice de mods", + ["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.", + ["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar", + ["Import a ROM to play"] = "Importa una ROM para jugar", + ["RED"] = "ROJO", + ["BLUE"] = "AZUL", + ["YELLOW"] = "AMARILLO", + ["FIND MODS"] = "BUSCAR MODS", + ["%d of 3 ready"] = "%d de 3 listos", + ["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.", + ["ROM imported"] = "ROM importada", + ["That ROM could not be imported."] = "No se pudo importar esa ROM.", + ["Open folder"] = "Abrir carpeta", + ["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados", + ["%d of %d enabled"] = "%d de %d activados", + ["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.", + ["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.", + ["Refreshed - %d mods listed"] = "Actualizado - %d mods listados", + ["Added %s"] = "Añadido %s", + ["Index removed"] = "Índice eliminado", + ["Downloading %s..."] = "Descargando %s...", + ["Installed %s %s"] = "Instalado %s %s", + ["%d mods listed"] = "%d mods listados", + ["%d of %d mods"] = "%d de %d mods", + ["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.", + ["No mod index added"] = "No hay índice de mods", + ["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.", + ["Search mods"] = "Buscar mods", + ["This index lists no mods yet."] = "Este índice aún no lista mods.", + ["No mods match that search."] = "Ningún mod coincide con esa búsqueda.", +} diff --git a/mods/spanish_ui/lang/trainer_names.lua b/mods/spanish_ui/lang/trainer_names.lua new file mode 100644 index 00000000..c6537732 --- /dev/null +++ b/mods/spanish_ui/lang/trainer_names.lua @@ -0,0 +1,7 @@ +-- Trainer class names +-- +-- Trainer class names for Espanol. + +return { + ["OPP_FIX_YOUNGSTER"] = "", +} diff --git a/mods/spanish_ui/main.lua b/mods/spanish_ui/main.lua new file mode 100644 index 00000000..ada371d1 --- /dev/null +++ b/mods/spanish_ui/main.lua @@ -0,0 +1,127 @@ +-- spanish_ui: a translation of the game into Espanol. +-- +-- Nothing here is translated yet. Every table under lang/ starts with +-- empty strings; fill one in and it takes effect on the next boot, and +-- anything still empty keeps rendering in English. That means a +-- half-finished translation is always playable, so you can ship early and +-- fill the long tail in later. +-- +-- Read TRANSLATING.md before the first edit; the font is the part people +-- get wrong. +return function(mod) + -- mod:read is the supported way into your own directory; the catalogs are + -- plain Lua tables, so read and run them rather than require()ing them. + local function catalog(name) + local rel = "lang/" .. name .. ".lua" + local body = mod:read(rel) + if not body then return {} end + local chunk, err = loadstring(body, rel) + if not chunk then + mod.log:warn("%s has a syntax error: %s", rel, tostring(err)) + return {} + end + local ok, table_ = pcall(chunk) + if not ok or type(table_) ~= "table" then + mod.log:warn("%s did not return a table: %s", rel, tostring(table_)) + return {} + end + return table_ + end + + -- An empty value means "not translated yet", never "translate to blank". + local function each(name, apply) + local n = 0 + for key, value in pairs(catalog(name)) do + if type(value) == "string" and value ~= "" then + apply(key, value) + n = n + 1 + end + end + return n + end + + -- ---- glyphs ------------------------------------------------------- + -- Register the sheet BEFORE anything asks for a glyph on it. base is + -- the first code the page owns; 0x100 and up is free space above the + -- vanilla pages, so a new alphabet never collides with them. + for id, page in pairs(catalog("font")) do + mod.content.font:register(id, page) + end + -- charmap: which byte sequence draws which code + for seq, code in pairs(catalog("charmap")) do + mod.content.font:register("charmap:" .. seq, { seq = seq, code = code }) + end + + -- ---- text --------------------------------------------------------- + local counts = {} + counts.dialogue = each("dialogue", function(id, value) + mod.content.text:override(id, value) + end) + counts.strings = each("strings", function(source, value) + mod.content.strings:override(source, value) + end) + counts.species = each("species_names", function(id, value) + mod.content.pokemon:patch(id, { name = value }) + end) + counts.moves = each("move_names", function(id, value) + mod.content.moves:patch(id, { name = value }) + end) + counts.items = each("item_names", function(id, value) + mod.content.items:patch(id, { name = value }) + end) + counts.trainers = each("trainer_names", function(id, value) + mod.content.trainers:patch(id, { name = value }) + end) + counts.statuses = each("status_labels", function(id, value) + mod.content.statuses:patch(id, { label = value }) + end) + + -- ---- name entry --------------------------------------------------- + -- The naming screen's letter grid. Leave lang/naming.lua returning nil + -- to keep the English alphabet. + local grid = catalog("naming") + if grid.upper then + -- Only offer the accented cells when the running cartridge can actually + -- draw them. A Spanish ROM has Ñ and the accented vowels in its font + -- and the manifest maps them; an English one does not, and an + -- unmappable cell renders blank -- a naming screen with six empty keys + -- is worse than an English one. So check the charmap and fall back. + local function drawable(cells, ctx) + local font = ((ctx.game or {}).data or {}).font + local charmap = font and font.charmap + if not charmap then return false end + local have = {} + for _, entry in ipairs(charmap) do have[entry.seq] = true end + for _, row in ipairs(cells) do + for _, cell in ipairs(row) do + -- Only the non-ASCII cells are at risk; A-Z and punctuation are + -- on every page. + if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then + return false + end + end + end + return true + end + local warned = false + mod.hooks:on("ui.naming.grid", function(base, ctx) + local want = ctx.lower and grid.lower or grid.upper + if not want then return base end + if not drawable(want, ctx) then + if not warned then + warned = true + mod.log:info("naming grid: this ROM has no accented glyphs, " + .. "keeping the English alphabet") + end + return base + end + return want + end) + end + + mod.events:on("game.ready", function() + local total = 0 + for _, n in pairs(counts) do total = total + n end + mod.log:info("Espanol: %d strings translated", total) + end) +end diff --git a/mods/spanish_ui/manifest.json b/mods/spanish_ui/manifest.json new file mode 100644 index 00000000..b3e45f04 --- /dev/null +++ b/mods/spanish_ui/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "spanish_ui", + "name": "Espanol (interfaz)", + "version": "0.1.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "game_version": ">=0.0.0-dev <1.0.0", + "category": "LANGUAGE", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "incompatible": [], + "experimental": false, + "description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus." +} \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 4d187f5e..5ae82de6 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -357,13 +357,44 @@ build_linux() { cp "$LOVE_FILE" "$appdir/game.love" - # The .desktop's Icon=love resolves against the AppDir root by basename, - # so drop the stock love.svg and provide our PNG under the same name; - # .DirIcon is what appimaged/thumbnailers show for the file itself. + # Replace LÖVE's own desktop entry rather than keeping it: it says + # Name=LÖVE / Icon=love, which is what appimaged, app menus and file + # managers displayed this image as. Same file as the arm64 build writes, + # so both architectures integrate under the game's name. + local stock_desktop + stock_desktop="$(find "$appdir" -maxdepth 1 -name '*.desktop' | wc -l | tr -d ' ')" + [ "$stock_desktop" = 1 ] \ + || fail "expected exactly one .desktop at the AppDir root, found $stock_desktop" + rm -f "$appdir"/*.desktop + + # share/ carries a second, NoDisplay copy of the same entry plus the .love + # file-type icons and mime rule, all left over from LÖVE's `make install` + # (its Exec even points at the CI runner that built it). Nothing at runtime + # reads them -- only share/lua and share/luajit-* are on LUA_PATH -- but + # AppRun puts $APPDIR/share on XDG_DATA_DIRS, so anyone extracting the image + # gets a "LÖVE" entry back. The arm64 AppDir never had them. + rm -rf "$appdir/share/applications" "$appdir/share/pixmaps" \ + "$appdir/share/mime" "$appdir/share/icons" + + cat > "$appdir/$APP_NAME.desktop" </dev/null - cp "$appdir/love.png" "$appdir/.DirIcon" + rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon" + sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null + cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon" sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 53fc8fdc..f5bcdbcc 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -369,6 +369,7 @@ Name=gen1recomp Comment=Pokémon Gen 1 recompilation Exec=$APP_NAME Icon=$APP_NAME +StartupWMClass=love Categories=Game; Terminal=false EOF diff --git a/scripts/linux-arm64/common.sh b/scripts/linux-arm64/common.sh index 39e196b4..292f1416 100755 --- a/scripts/linux-arm64/common.sh +++ b/scripts/linux-arm64/common.sh @@ -142,7 +142,10 @@ download_pinned() { rm -f "$dest" fi say "downloading $(basename "$dest")" - curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url" + # --retry-all-errors because plain --retry skips TLS handshake failures, + # which is how xiph.org drops these tarballs; the pin below still gates it. + curl -fL --retry 5 --retry-delay 2 --retry-all-errors --progress-bar \ + "$url" -o "$dest.tmp" || fail "download failed: $url" got="$(sha256_file "$dest.tmp")" [ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \ "checksum mismatch for $(basename "$dest")" "$want" "$got")" diff --git a/scripts/run.sh b/scripts/run.sh index dc08f50d..f0110d21 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Run the LÖVE2D Pokémon Red port (macOS-friendly). # -# Assumes scripts/setup.sh has been run once (generated data present and -# LÖVE installed). Extra arguments are passed through to LÖVE. +# Assumes scripts/setup.sh has been run once for at least one game (generated +# data present and LÖVE installed). Extra arguments are passed through to LÖVE. # # Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player # picks HOST A GAME in START > LINK and reads out the address shown; @@ -15,8 +15,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } -[ -f "$ROOT/data/generated/maps.lua" ] \ - || fail "generated data missing, run scripts/setup.sh first" +if [ ! -f "$ROOT/data/generated/maps.lua" ] \ + && [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \ + && [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then + fail "generated data missing, run scripts/setup.sh first" +fi find_love() { command -v love >/dev/null 2>&1 && { echo "love"; return; } diff --git a/scripts/test.sh b/scripts/test.sh index fe1a3120..d88e6128 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -66,6 +66,7 @@ run_tier() { # ------- ROM-free tiers: these are what CI runs +run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index da79b2b7..f0a5403f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -37,6 +37,12 @@ local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Category identity for per-category GAME SPEED (RFC 0007), the same +-- style OverworldController.isOverworld already uses. Every battle -- +-- wild, trainer, link, safari, the old-man demo -- is this metatable, so +-- Game.speedCategoryInStack needs no special-casing beyond this one flag. +BattleState.isBattle = true + function BattleState:romText(label, fallback, ...) return romText(self.data, label, fallback, ...) end @@ -98,6 +104,15 @@ function BattleState:bgMode() return "white" end +-- Resume a semantic checkpoint directly at the command menu. Unlike enter(), +-- this deliberately does not replay the battle transition, intro queues, +-- cries, happiness changes, or battle-start events. +function BattleState:resumeCheckpoint() + self.isOpaque = self:bgMode() ~= "world" + require("src.core.Music").playBattle(self.data, + self.musicKind or self:computeMusicKind()) +end + -- How far to dim the overworld behind a "world" background, 0..1. Enough -- that the battle reads as the foreground rather than competing with a fully -- lit map behind it. @@ -118,6 +133,18 @@ function BattleState:sgbPalettes() return nil end +function BattleState:bottomUIVisible() + if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end + return Runtime.call("battle.bottom_ui_visible", function() return true end, + self) ~= false +end + +function BattleState:statusHUDVisible() + if not Runtime.wantsHook("battle.status_hud_visible") then return true end + return Runtime.call("battle.status_hud_visible", function() return true end, + self) ~= false +end + local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), modern_clean = require("src.battle.rulesets.modern_clean"), @@ -424,7 +451,7 @@ function StatBox:draw() { Strings("SPEED"), s.speed }, { Strings("SPECIAL"), s.special } } for i, r in ipairs(rows) do - Font.draw(r[1], 88, 24 + (i - 1) * 16) + Font.draw(Strings(r[1]), 88, 24 + (i - 1) * 16) Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16) end love.graphics.setColor(1, 1, 1, 1) @@ -1411,6 +1438,26 @@ function BattleState:computeMusicKind() return "wild" end +-- a mod-set per-trainer battle theme (trainers.battleTheme, an audio.songs +-- id); nil for vanilla trainers, so the kind default is untouched (#782) +function BattleState:battleTheme() + local trainer = self.trainer + if trainer and trainer.battleTheme then return trainer.battleTheme end + return nil +end + +-- the battle-theme cue for this battle: the mod-set trainer battleTheme +-- when the class has one, else the kind default. The single choke point +-- both the transition-wipe start (OverworldController:pushBattle) and +-- enter() route through, so a per-trainer override can't drift between +-- them. self.musicKind is set by enter(); pushBattle runs before that, +-- so compute it here when absent. +function BattleState:playBattleTheme() + require("src.core.Music").playBattle(self.data, + self.musicKind or self:computeMusicKind(), + self.trainer and self.trainer.id, self:battleTheme()) +end + -- side tables mirror the singles battlers; called before every -- battler-switch notification so sides[i].battlers[1] stays honest function BattleState:syncSides() @@ -1462,7 +1509,6 @@ function BattleState:enter() .. Strings("%s blacked\nout!", name), blackedOut)) return end - local Music = require("src.core.Music") self.musicKind = self:computeMusicKind() if self.isGymLeader then require("src.world.PikachuFollower") @@ -1472,7 +1518,7 @@ function BattleState:enter() -- (audio/play_battle_music.asm runs before the transition, and -- Music.play no-ops on the same song); this covers battles pushed -- without a transition (link battles, scripted pushes) - Music.playBattle(self.data, self.musicKind) + self:playBattleTheme() -- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both -- sides slide in; the trainer pics stay up until the send-outs -- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing @@ -1865,6 +1911,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return @@ -1901,6 +1948,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] if choice == "fight" and self.ghost then self:say(Strings("%s is too\nscared to move!", self.player.name)) @@ -1962,9 +2010,11 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then + require("src.core.Sound").play(self.data, "Press_AB") self.moveSwapIndex = nil self.phase = "menu" elseif input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil @@ -2003,6 +2053,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") local pick = moves[self.mimicIndex] local ctx = self.mimicCtx self.mimicMoves, self.mimicCtx = nil, nil @@ -5319,7 +5370,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- .mimicmenu) wipes rows 7+. The port draws pics above the menu -- layer in the colorized pipeline, so clip them to the visible rows. local g = love.graphics - local clipY = not skipMenuClip + local clipY = not skipMenuClip and self:bottomUIVisible() and (self.phase == "mimicSelect" and 56 or self.phase == "moveSelect" and 64) or nil @@ -5431,6 +5482,7 @@ function BattleState:drawHUDs(slide) -- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill -- double-applies and the zone shade shader maps the whole bar to black (#229). local grayFill = self:colorMode() + local showStatus = self:statusHUDVisible() local barData = self.data local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 @@ -5440,7 +5492,8 @@ function BattleState:drawHUDs(slide) -- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763) -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the -- player's ball row with no enemy HUD beside it (#317) - if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut + if showStatus and self.enemy and not self.showEnemyTrainer + and not self.enemySendingOut and not self:growInScale(self.enemy) and slide == 0 and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), @@ -5527,7 +5580,7 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) end local hidePlayer = self.safari or self.demo - if self.player and not hidePlayer and not self.showPlayerBack + if showStatus and self.player and not hidePlayer and not self.showPlayerBack and slide == 0 then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with @@ -5552,6 +5605,7 @@ function BattleState:drawHUDs(slide) end function BattleState:drawTextArea() + if not self:bottomUIVisible() then return end Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if self.phase == "messages" @@ -5587,9 +5641,9 @@ function BattleState:drawTextArea() -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) Font.drawBox(8, 12, 12, 6) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("FIGHT"), 80, 112) + Font.draw(Strings("FIGHT", "battle"), 80, 112) Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) + Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) elseif self.phase == "menu" then local col = (self.menuIndex - 1) % 2 @@ -5599,7 +5653,7 @@ function BattleState:drawTextArea() -- THROW ROCK RUN" from (2,14) Font.drawBox(0, 12, 20, 6) Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) - Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128) + Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128) -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two -- digits, space padded -- right after the "BALLx" label at columns @@ -5610,9 +5664,9 @@ function BattleState:drawTextArea() -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / -- ITEM RUN" from (10,14); cursor columns 9 / 15 Font.drawBox(8, 12, 12, 6) - Font.draw(Strings("FIGHT"), 80, 112) + Font.draw(Strings("FIGHT", "battle"), 80, 112) Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) + Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) end elseif self.phase == "moveSelect" then diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 93c24079..a96127fd 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -281,11 +281,11 @@ MoveEffects.primary = { failed = true } end local cost = math.floor(user.mon.stats.hp / 4) - -- substitute.asm only fails on subtraction underflow (current HP - -- strictly below maxHP/4); at equality the substitute is built and - -- the user is left standing on exactly 0 HP (it faints only when - -- the engine next checks HP, not here) - if user.mon.hp < cost then + -- A Substitute costs one quarter of max HP, rounded down. Do not let + -- the cost consume the user's last HP: the move must fail at the exact + -- boundary as well as below it, or the next turn's HP guard can leave a + -- trainer battle unable to progress. + if user.mon.hp <= cost then return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"), failed = true } end diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 72b122c7..87e43bd2 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -128,7 +128,8 @@ local function drawIntroBalls(battle) end local function drawHUDs(battle, slide) - if battle.enemy and not battle.showEnemyTrainer + local showStatus = battle:statusHUDVisible() + if showStatus and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then drawStatusPanel(battle, battle.enemy, 0, 0, false) @@ -139,7 +140,7 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). - if not battle.safari and battle.player and not battle.demo + if showStatus and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then drawStatusPanel(battle, battle.player, 184, 56, true) end @@ -255,6 +256,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) + if not battle:bottomUIVisible() then return end if battle.phase == "messages" and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua new file mode 100644 index 00000000..912ea1b2 --- /dev/null +++ b/src/core/BattleCheckpoint.lua @@ -0,0 +1,432 @@ +-- Semantic, data-only capture for settled single-player battle checkpoints. +-- Reconstruction lives here too; public mods only see the opaque checkpoint +-- facade in Loader. + +local BattleCheckpoint = {} +local BattleState = require("src.battle.BattleState") +local BUILTIN_RULESETS = { + gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + modern_clean = require("src.battle.rulesets.modern_clean"), +} + +local function rulesets(game) + return game.data.rulesets or BUILTIN_RULESETS +end + +local function rulesetId(game, record) + for id, candidate in pairs(rulesets(game)) do + if candidate == record then return id end + end +end + +local BATTLER_FIELDS = { + "shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves", + "sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns", + "toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns", + "chargeReady", "invulnerable", "mustRecharge", + "thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded", + "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", + "skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns", + "trapMove", "trapDamage", "fainted", + "aiLayer2", +} + +local MOVE_REFERENCE_FIELDS = { + charging = "chargingSlot", + thrashMove = "thrashMoveSlot", + rageMove = "rageMoveSlot", +} + +local BATTLE_FIELDS = { + "oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex", + "moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay", + "sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall", + "lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed", + "endBattleText", +} + +local function partyIndex(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +local function indexSet(set, party) + local out = {} + for mon, present in pairs(set or {}) do + if present then + local index = partyIndex(party, mon) + if index then out[#out + 1] = index end + end + end + table.sort(out) + return out +end + +local function captureBattler(battler, index, copy) + local out = { + index = index, + curStatsFromMon = battler.curStats == battler.mon.stats, + curTypesFromDefinition = battler.curTypes == battler.def.types, + curMovesFromMon = battler.curMoves == battler.mon.moves, + } + for _, field in ipairs(BATTLER_FIELDS) do + if battler[field] ~= nil then out[field] = battler[field] end + end + for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do + local reference = battler[field] + if reference ~= nil then + for slot, move in ipairs(battler.curMoves or {}) do + if move == reference then out[slotField] = slot break end + end + if out[slotField] == nil then return nil end + end + end + return copy(out) +end + +local function integer(value, min, max) + return type(value) == "number" and value % 1 == 0 + and value >= (min or -math.huge) and value <= (max or math.huge) +end + +local function validateMoveList(data, moves) + if type(moves) ~= "table" then return false end + for _, move in ipairs(moves) do + if type(move) ~= "table" or type(move.id) ~= "string" + or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then + return false + end + end + return true +end + +local function validateMon(data, mon) + return type(mon) == "table" and type(mon.species) == "string" + and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100) + and type(mon.hp) == "number" and type(mon.stats) == "table" + and validateMoveList(data, mon.moves) +end + +local function validateBattler(data, battler, maxIndex) + if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then + return false + end + if type(battler.curMoves) ~= "table" then return false end + if battler.stages ~= nil then + if type(battler.stages) ~= "table" then return false end + for _, stage in pairs(battler.stages) do + if not integer(stage, -6, 6) then return false end + end + end + for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do + if battler[slotField] ~= nil + and not integer(battler[slotField], 1, #battler.curMoves) then + return false + end + end + return validateMoveList(data, battler.curMoves) + and type(battler.curStats) == "table" and type(battler.curTypes) == "table" +end + +local function clone(value, copy) + if type(value) ~= "table" then return value end + return assert(copy(value)) +end + +local function captureMimicRestores(battle) + local out = {} + for _, restore in ipairs(battle.mimicRestores or {}) do + local side = restore.battler == battle.player and "player" + or restore.battler == battle.enemy and "enemy" or nil + local slot + for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do + if move == restore.entry then slot = index break end + end + if not side or not slot or type(restore.id) ~= "string" then return nil end + out[#out + 1] = { side = side, slot = slot, id = restore.id } + end + return out +end + +function BattleCheckpoint.validate(game, checkpoint) + local model = checkpoint.runtime and checkpoint.runtime.battle + local rngState = checkpoint.rng and checkpoint.rng.love + if type(model) ~= "table" or type(model.origin) ~= "table" + or type(rngState) ~= "string" or rngState == "" then + return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing." + end + local expectedOrigin = model.kind == "wild" and "wild_encounter" + or model.kind == "trainer" and "trainer_encounter" or nil + if not expectedOrigin or model.origin.kind ~= expectedOrigin + or model.origin.map ~= checkpoint.runtime.overworld.map then + return nil, "battle_origin_unsupported", + "Battle continuation data is unsupported or inconsistent." + end + if type(model.rulesetId) ~= "string" + or type(rulesets(game)[model.rulesetId]) ~= "table" then + return nil, "invalid_content", "Battle ruleset is unavailable." + end + if model.kind == "trainer" and (type(model.origin.npcId) ~= "string" + or model.origin.trainerClass ~= model.oppClass + or model.origin.partyIndex ~= (model.partyIndex or 1)) then + return nil, "battle_origin_unsupported", + "Trainer continuation data is incomplete or inconsistent." + end + local party = checkpoint.save.party + if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then + return nil, "invalid_content", "Player battle state is invalid." + end + if model.kind == "wild" then + if not validateMon(game.data, model.enemyMon) + or not validateBattler(game.data, model.enemy, 1) then + return nil, "invalid_content", "Wild opponent state is invalid." + end + else + local trainer = game.data.trainers and game.data.trainers[model.oppClass] + if type(trainer) ~= "table" or not integer(model.partyIndex, 1) + or type(model.enemyParty) ~= "table" or #model.enemyParty == 0 + or not integer(model.enemyIndex, 1, #model.enemyParty) + or not validateBattler(game.data, model.enemy, #model.enemyParty) then + return nil, "invalid_content", "Trainer battle identity or roster is invalid." + end + for _, mon in ipairs(model.enemyParty) do + if not validateMon(game.data, mon) then + return nil, "invalid_content", "Trainer opponent state is invalid." + end + end + end + for _, indices in ipairs({ model.participants, model.leveledUp }) do + if type(indices) ~= "table" then + return nil, "invalid_checkpoint", "Battle party reference set is missing." + end + for _, index in ipairs(indices) do + if not integer(index, 1, #party) then + return nil, "invalid_checkpoint", "Battle party reference is invalid." + end + end + end + if type(model.mimicRestores) ~= "table" then + return nil, "invalid_checkpoint", "Mimic restore state is missing." + end + for _, restore in ipairs(model.mimicRestores) do + local battler = restore.side == "player" and model.player + or restore.side == "enemy" and model.enemy or nil + if not battler or not integer(restore.slot, 1, #battler.curMoves) + or type(restore.id) ~= "string" + or type(game.data.moves[restore.id]) ~= "table" then + return nil, "invalid_content", "Mimic restore state is invalid." + end + end + return true +end + +local function applyBattler(target, captured, copy) + for _, field in ipairs(BATTLER_FIELDS) do + if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then + if captured[field] ~= nil then + target[field] = clone(captured[field], copy) + else + target[field] = nil + end + end + end + target.curStats = captured.curStatsFromMon and target.mon.stats + or assert(copy(captured.curStats)) + target.curTypes = captured.curTypesFromDefinition and target.def.types + or assert(copy(captured.curTypes)) + target.curMoves = captured.curMovesFromMon and target.mon.moves + or assert(copy(captured.curMoves)) + for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do + target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil + end + return target +end + +local function restoreIndexSet(indices, party) + local out = {} + for _, index in ipairs(indices or {}) do out[party[index]] = true end + return next(out) and out or nil +end + +function BattleCheckpoint.restore(game, checkpoint, copy) + local model = checkpoint.runtime.battle + local battle + if model.kind == "trainer" then + battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex) + battle.enemyParty = assert(copy(model.enemyParty)) + battle.enemyIndex = model.enemyIndex + else + battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level) + end + + battle.player = BattleState.makeBattler(game.data, + game.save.party[model.player.index], true, game.save) + applyBattler(battle.player, model.player, copy) + local enemyMon + if model.kind == "trainer" then + enemyMon = battle.enemyParty[model.enemy.index] + else + enemyMon = assert(copy(model.enemyMon)) + end + battle.enemy = BattleState.makeBattler(game.data, enemyMon, false) + applyBattler(battle.enemy, model.enemy, copy) + + battle.mimicRestores = {} + for _, restore in ipairs(model.mimicRestores or {}) do + local battler = restore.side == "player" and battle.player or battle.enemy + battle.mimicRestores[#battle.mimicRestores + 1] = { + battler = battler, + entry = battler.curMoves[restore.slot], + id = restore.id, + } + end + if #battle.mimicRestores == 0 then battle.mimicRestores = nil end + + for _, field in ipairs(BATTLE_FIELDS) do + if model[field] ~= nil then + battle[field] = clone(model[field], copy) + else + battle[field] = nil + end + end + battle.kind = model.kind + battle.ruleset = rulesets(game)[model.rulesetId] + battle.checkpointOrigin = assert(copy(model.origin)) + battle.participants = restoreIndexSet(model.participants, game.save.party) + battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party) + battle.sides = assert(copy(model.sides)) + battle.sides[1].battlers = { battle.player } + battle.sides[2].battlers = { battle.enemy } + battle.field = assert(copy(model.field)) + battle.field.sides = battle.sides + battle.phase, battle.queue = "menu", {} + battle.frame = 0 + battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil + battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil + battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil + battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil + battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil + battle.player.shownHP, battle.player.shownStatus = + battle.player.mon.hp, battle.player.mon.status + battle.enemy.shownHP, battle.enemy.shownStatus = + battle.enemy.mon.hp, battle.enemy.mon.status + + local ow = game.overworld + if not ow or type(ow.restoreBattleContinuation) ~= "function" + or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then + error("battle continuation reconstruction is unavailable", 0) + end + if type(game.restoreCheckpointBattle) ~= "function" then + error("game has no battle checkpoint reconstruction path", 0) + end + game:restoreCheckpointBattle(battle) + local setState = love and love.math and love.math.setRandomState + if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end + setState(checkpoint.rng.love) + return battle +end + +local function captureExtensions(battle, copy) + local sides = {} + for i = 1, 2 do + local side = battle.sides and battle.sides[i] or {} + local encoded, err = copy({ + index = i, + screens = side.screens or {}, + hazards = side.hazards or {}, + tokens = side.tokens or {}, + }) + if not encoded then return nil, err end + sides[i] = encoded + end + local field, err = copy({ + weather = battle.field and battle.field.weather or nil, + tokens = battle.field and battle.field.tokens or {}, + }) + if not field then return nil, err end + return sides, field +end + +function BattleCheckpoint.capture(game, battle, progress, copy) + local getState = love and love.math and love.math.getRandomState + local setState = love and love.math and love.math.setRandomState + if type(getState) ~= "function" or type(setState) ~= "function" then + return nil, "rng_state_unavailable", + "This runtime cannot preserve deterministic battle randomness." + end + local ok, rngState = pcall(getState) + if not ok or type(rngState) ~= "string" or rngState == "" then + return nil, "rng_state_unavailable", + "The gameplay random-number state could not be captured." + end + + local origin, originErr = copy(battle.checkpointOrigin) + if not origin then + return nil, "battle_origin_unsupported", + "The battle completion path is not data-only: " .. tostring(originErr) + end + local sides, fieldOrErr = captureExtensions(battle, copy) + if not sides then + return nil, "battle_extension_unsafe", + "Battle extension state is not data-only: " .. tostring(fieldOrErr) + end + local field = fieldOrErr + + local liveParty = game.save.party + local playerIndex = partyIndex(liveParty, battle.player.mon) + if not playerIndex then + return nil, "battle_state_invalid", + "The active player battler is not in the current party." + end + + local model = { + kind = battle.kind, + rulesetId = rulesetId(game, battle.ruleset), + origin = origin, + player = captureBattler(battle.player, playerIndex, copy), + participants = indexSet(battle.participants, liveParty), + leveledUp = indexSet(battle.leveledUp, liveParty), + sides = sides, + field = field, + mimicRestores = captureMimicRestores(battle), + } + if not model.rulesetId then + return nil, "battle_state_invalid", "Battle ruleset identity is unavailable." + end + if not model.player then + return nil, "battle_state_invalid", "Player move references are inconsistent." + end + if not model.mimicRestores then + return nil, "battle_state_invalid", "Mimic restore state is inconsistent." + end + if battle.kind == "trainer" then + model.enemyParty = copy(battle.enemyParty) + model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy) + else + model.enemyMon = copy(battle.enemy.mon) + model.enemy = captureBattler(battle.enemy, 1, copy) + end + if not model.enemy then + return nil, "battle_state_invalid", "Enemy move references are inconsistent." + end + for _, fieldName in ipairs(BATTLE_FIELDS) do + if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end + end + + model = copy(model) + if not model then + return nil, "battle_state_invalid", + "Battle state contains non-serializable runtime data." + end + local player = progress.player + return { + overworld = { + map = player.map, x = player.x, y = player.y, + facing = player.facing, surfing = player.surfing and true or false, + }, + battle = model, + }, { love = rngState } +end + +return BattleCheckpoint diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua new file mode 100644 index 00000000..e6a3b036 --- /dev/null +++ b/src/core/Checkpoint.lua @@ -0,0 +1,424 @@ +-- Public runtime checkpoint implementation. Loader exposes bound forwarding +-- methods; mods never receive controller or state-stack internals from here. + +local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") +local Version = require("src.core.Version") +local BattleState = require("src.battle.BattleState") +local BattleCheckpoint = require("src.core.BattleCheckpoint") +local ModRuntime = require("src.mods.Runtime") + +local Checkpoint = {} + +Checkpoint.FORMAT = 1 + +local function refusal(kind, reason, message) + return { + canCapture = false, + canRestore = false, + kind = kind or "unknown", + reason = reason, + message = message, + } +end + +local function running(runner) + return runner and runner.isRunning and runner:isRunning() +end + +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end + +local function scriptsBusy(ow) + return running(ow.runner) or nonempty(ow.parallelRunners) + or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue) + or nonempty(ow.scriptMoves) +end + +local BATTLE_BUSY_FIELDS = { + "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", + "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", + "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", +} + +local function inspectBattle(ow, battle) + if battle.kind == "link" then + return refusal("battle", "link_battle_unsupported", + "Network battles cannot be checkpointed.") + end + if battle.safari or battle.ghost or battle.scopeReveal or battle.demo + or battle.noCatch then + return refusal("battle", "battle_variant_unsupported", + "This battle variant does not have a checkpoint contract.") + end + if battle.kind ~= "wild" and battle.kind ~= "trainer" then + return refusal("battle", "battle_variant_unsupported", + "This battle kind does not have a checkpoint contract.") + end + local origin = battle.checkpointOrigin + local expectedOrigin = battle.kind == "wild" and "wild_encounter" + or "trainer_encounter" + if type(origin) ~= "table" or origin.kind ~= expectedOrigin then + return refusal("battle", "battle_origin_unsupported", + "The battle completion path cannot be reconstructed safely.") + end + if scriptsBusy(ow) then + return refusal("battle", "script_busy", + "A suspended or queued script cannot be checkpointed.") + end + if battle.phase ~= "menu" or nonempty(battle.queue) then + return refusal("battle", "battle_phase_busy", + "Wait for the player command menu before creating a checkpoint.") + end + for _, field in ipairs(BATTLE_BUSY_FIELDS) do + if battle[field] ~= nil and battle[field] ~= false then + return refusal("battle", "battle_phase_busy", + "Wait for the current battle action to finish.") + end + end + if not battle.player or not battle.enemy or battle.player.mon.hp <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then + return refusal("battle", "battle_phase_busy", + "Wait for an ordinary player decision before creating a checkpoint.") + end + for _, battler in ipairs({ battle.player, battle.enemy }) do + if battler.shownHP ~= battler.mon.hp + or battler.shownStatus ~= battler.mon.status + or battler.drainFloor ~= nil or battler.drainHold ~= nil + or battler.faintQueued then + return refusal("battle", "battle_phase_busy", + "Wait for battle status and HP presentation to settle.") + end + end + return { canCapture = true, canRestore = true, kind = "battle" } +end + +function Checkpoint.inspect(game) + local save = game and game.save + if type(save) ~= "table" or type(save.version) ~= "string" then + return refusal("unknown", "not_in_playthrough", + "A checkpoint requires an identified active playthrough.") + end + + local ow = game.overworld + if type(ow) ~= "table" or type(ow.map) ~= "table" + or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then + return refusal("unknown", "not_overworld", + "Only a settled overworld can be checkpointed.") + end + local top = game.stack and game.stack.top and game.stack:top() + if getmetatable(top) == BattleState then + return inspectBattle(ow, top) + end + if top ~= ow then + return refusal("overworld", "screen_busy", + "Close the active menu or screen before creating a checkpoint.") + end + local identity = save.meta and save.meta.playthroughId + if type(identity) ~= "string" or identity == "" then + identity = SaveData.ensurePlaythroughId(save) + end + if type(identity) ~= "string" or identity == "" then + return refusal("overworld", "not_in_playthrough", + "The active playthrough could not be identified.") + end + if ow.transitioning then + return refusal("overworld", "transition_busy", + "Wait for the map transition to finish.") + end + if scriptsBusy(ow) then + return refusal("overworld", "script_busy", + "Wait for the active or queued script to finish.") + end + + local animationFields = { + "engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose", + "pikaHop", "healAnim", "flyAnim", "flyArrive", + } + for _, field in ipairs(animationFields) do + if ow[field] then + return refusal("overworld", "animation_busy", + "Wait for the overworld animation to finish.") + end + end + if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then + return refusal("overworld", "movement_busy", + "Wait for movement to settle on a tile.") + end + return { canCapture = true, canRestore = true, kind = "overworld" } +end + +local function dataCopy(value) + local ok, encoded = pcall(SaveSerializer.encode, value) + if not ok then return nil, tostring(encoded) end + local decoded, err = SaveSerializer.decode(encoded) + if not decoded then return nil, err end + return decoded +end + +local function captureRng() + local getState = love and love.math and love.math.getRandomState + local setState = love and love.math and love.math.setRandomState + if type(getState) ~= "function" or type(setState) ~= "function" then return nil end + local ok, state = pcall(getState) + if ok and type(state) == "string" and state ~= "" then + return { love = state } + end + return nil +end + +local function restoreRng(rng) + if rng == nil then return end -- legacy format-1 overworld checkpoint + local setState = love and love.math and love.math.setRandomState + if type(rng) ~= "table" or type(rng.love) ~= "string" + or type(setState) ~= "function" then + error("checkpoint RNG restore is unavailable", 0) + end + setState(rng.love) +end + +function Checkpoint.capture(game) + local capability = Checkpoint.inspect(game) + if not capability.canCapture then + return nil, capability.reason, capability.message + end + + local progress = {} + for key, value in pairs(game.save) do + if key ~= "options" then progress[key] = value end + end + progress = dataCopy(progress) + if not progress then + return nil, "capture_failed", "Progress contains non-serializable runtime data." + end + + local ok, err = pcall(game.overworld.captureSave, game.overworld, progress) + if not ok then + return nil, "capture_failed", "Could not synchronize overworld progress: " + .. tostring(err) + end + progress, err = dataCopy(progress) + if not progress then + return nil, "capture_failed", "Synchronized progress is not data-only: " + .. tostring(err) + end + + if capability.kind == "battle" then + local battle = game.stack:top() + local runtime, rngOrCode, battleMessage = + BattleCheckpoint.capture(game, battle, progress, dataCopy) + if not runtime then return nil, rngOrCode, battleMessage end + return { + format = Checkpoint.FORMAT, + kind = "battle", + identity = { + engineVersion = Version.engine, + gameVersion = game.save.version, + playthroughId = game.save.meta.playthroughId, + }, + save = progress, + runtime = runtime, + rng = rngOrCode, + } + end + + local player = game.overworld.player + return { + format = Checkpoint.FORMAT, + kind = "overworld", + identity = { + engineVersion = Version.engine, + gameVersion = game.save.version, + playthroughId = game.save.meta.playthroughId, + }, + save = progress, + runtime = { overworld = { + map = game.overworld.map.id, + x = player.cellX, + y = player.cellY, + facing = player.facing, + surfing = player.surfing and true or false, + } }, + rng = captureRng(), + } +end + +local FACINGS = { up = true, down = true, left = true, right = true } + +local function validate(game, checkpoint) + if type(checkpoint) ~= "table" then + return nil, "invalid_checkpoint", "Checkpoint root must be a table." + end + if checkpoint.format ~= Checkpoint.FORMAT then + return nil, "unsupported_format", "This checkpoint format is not supported." + end + if checkpoint.kind ~= "overworld" and checkpoint.kind ~= "battle" then + return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not supported." + end + + local copy, copyErr = dataCopy(checkpoint) + if not copy then + return nil, "invalid_checkpoint", "Checkpoint is not data-only: " + .. tostring(copyErr) + end + local identity = copy.identity + local current = game and game.save + local currentId = current and current.meta and current.meta.playthroughId + if type(identity) ~= "table" or type(identity.engineVersion) ~= "string" + or type(identity.gameVersion) ~= "string" + or type(identity.playthroughId) ~= "string" then + return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt." + end + if identity.gameVersion ~= current.version then + return nil, "wrong_game", "Checkpoint belongs to another game version." + end + if identity.playthroughId ~= currentId then + return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough." + end + + local save = copy.save + local runtime = copy.runtime and copy.runtime.overworld + if type(save) ~= "table" or type(save.player) ~= "table" + or type(runtime) ~= "table" then + return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing." + end + if save.version ~= identity.gameVersion + or not save.meta or save.meta.playthroughId ~= identity.playthroughId then + return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent." + end + if copy.rng ~= nil and (type(copy.rng) ~= "table" + or type(copy.rng.love) ~= "string" or copy.rng.love == "") then + return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt." + end + if type(runtime.map) ~= "string" or type(runtime.x) ~= "number" + or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0 + or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then + return nil, "invalid_checkpoint", "Overworld position is missing or corrupt." + end + if save.player.map ~= runtime.map or save.player.x ~= runtime.x + or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing + or (save.player.surfing and true or false) ~= runtime.surfing then + return nil, "invalid_checkpoint", "Progress and runtime position disagree." + end + + local map = game.data and game.data.maps and game.data.maps[runtime.map] + if type(map) ~= "table" then + return nil, "invalid_map", "Checkpoint references a map that is unavailable." + end + local width, height = tonumber(map.width), tonumber(map.height) + if not width or not height or runtime.x < 0 or runtime.y < 0 + or runtime.x >= width * 2 or runtime.y >= height * 2 then + return nil, "invalid_position", "Checkpoint position is outside the map." + end + + -- A checkpoint is a strict restoration record, not an ordinary CONTINUE + -- migration. Reuse the canonical save validator on the detached copy, but + -- reject any quarantine, remap, reclaim, clamp, or content repair it would + -- perform instead of silently changing the state the caller selected. + local beforeContent = SaveSerializer.encode(copy.save) + local validOk, report = pcall(SaveData.validate, copy.save, game.data) + local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save) + if not validOk or not afterOk or not SaveData.emptyReport(report) + or afterContent ~= beforeContent then + return nil, "invalid_content", + "Checkpoint references unavailable or invalid game content." + end + if copy.kind == "battle" then + local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy) + if not battleOk then return nil, battleCode, battleMessage end + elseif copy.runtime.battle ~= nil then + return nil, "invalid_checkpoint", + "Overworld checkpoint contains unexpected battle state." + end + return copy +end + +local function apply(game, checkpoint, options) + local save, err = dataCopy(checkpoint.save) + if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end + local runtime = checkpoint.runtime.overworld + save.options = options + save.player.map = runtime.map + save.player.x = runtime.x + save.player.y = runtime.y + save.player.facing = runtime.facing + save.player.surfing = runtime.surfing + if type(game.restoreCheckpointSave) ~= "function" then + error("game has no checkpoint reconstruction path", 0) + end + game:restoreCheckpointSave(save) + if checkpoint.kind == "battle" then + BattleCheckpoint.restore(game, checkpoint, dataCopy) + else + restoreRng(checkpoint.rng) + end +end + +local function equalData(a, b) + local okA, encodedA = pcall(SaveSerializer.encode, a) + local okB, encodedB = pcall(SaveSerializer.encode, b) + return okA and okB and encodedA == encodedB +end + +local function firstDifference(a, b, path) + path = path or "$" + if type(a) ~= type(b) then return path .. " (type)" end + if type(a) ~= "table" then + if a ~= b then return path end + return nil + end + for key, value in pairs(a) do + if b[key] == nil and value ~= nil then + return path .. "." .. tostring(key) .. " (missing)" + end + local found = firstDifference(value, b[key], path .. "." .. tostring(key)) + if found then return found end + end + for key, value in pairs(b) do + if a[key] == nil and value ~= nil then + return path .. "." .. tostring(key) .. " (unexpected)" + end + end + return nil +end + +function Checkpoint.restore(game, checkpoint) + local capability = Checkpoint.inspect(game) + if not capability.canRestore then + return false, capability.reason, capability.message + end + local validated, code, message = validate(game, checkpoint) + if not validated then return false, code, message end + + local rollback, captureCode, captureMessage = Checkpoint.capture(game) + if not rollback then return false, captureCode, captureMessage end + local options = game.save.options + + local ok, err = pcall(apply, game, validated, options) + if ok then + local restored, verifyCode = Checkpoint.capture(game) + if restored and validated.rng == nil then restored.rng = nil end + if restored and equalData(restored, validated) then + if ModRuntime.wants("checkpoint.restored") then + ModRuntime.emit("checkpoint.restored", { + game = game, + kind = validated.kind, + }) + end + return true + end + err = restored and ("restored state differed at " + .. tostring(firstDifference(validated, restored) or "canonical encoding")) + or ("restored state could not be captured: " .. tostring(verifyCode)) + end + + local rolledBack, rollbackErr = pcall(apply, game, rollback, options) + if not rolledBack then + return false, "rollback_failed", + "Checkpoint restore and rollback both failed: " .. tostring(rollbackErr) + end + return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err) +end + +return Checkpoint diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1ca31740..01f4f376 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -111,6 +111,11 @@ local NOISE_DIVISORS = { [4] = 64, [5] = 80, [6] = 96, [7] = 112, } + +local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE) +local LPF_ALPHA = 0.8 +local MIX_SCALE = 0.5 + local function snapTicks(ticks) return math.floor((ticks * 1470 + 256) / 512) end @@ -269,6 +274,7 @@ function Channel.new(engine, spec, options) phase = 0, noiseLfsr = 0x7FFF, noiseClock = 0, + drumTail = nil, timeTicks = 0, }, Channel) end @@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed) return math.min(15, volume + steps) end + +local function envelopeRingSamples(volume, fade) + if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end + return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5) +end + +local function extendDrumEnvelope(segments) + local last = segments and segments[#segments] + if not last then return segments end + local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade) + if ringEnd > last.endSample then last.endSample = ringEnd end + return segments +end + +local function drumAudioEnd(drum) + local last = drum and drum[#drum] + return last and last.endSample or 0 +end + function Channel:resetNoise() self.noiseLfsr = 0x7FFF self.noiseClock = 0 @@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter) end end end - -- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0) - return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 + return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0 end local function sweepCalculation(register, sweep) @@ -611,21 +635,51 @@ end function Channel:sample() while not self.ended and (not self.event or self.event.sample >= self.event.samples) do + local prev = self.event self.event = self:nextEvent() self.phase = 0 - self:resetNoise() + if self.event and self.event.drum then + self.drumTail = nil + self:resetNoise() + elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then + -- ..(audio/engine_1.asm ln 197) + self.drumTail = prev + elseif not (self.event and self.event.silence and self.drumTail) then + self.drumTail = nil + self:resetNoise() + end end local event = self.event - if not event then return 0 end + local gain = channelVolume[self.hardware] or 1 + if not event then + local tail = self.drumTail + if not tail then return 0 end + local sampleIndex = tail.sample + tail.sample = sampleIndex + 1 + if sampleIndex >= drumAudioEnd(tail.drum) then + self.drumTail = nil + return 0 + end + return self:sampleDrum(tail, sampleIndex) * gain + end local sampleIndex = event.sample event.elapsed = sampleIndex / SAMPLE_RATE event.sample = sampleIndex + 1 - if event.silence then return 0 end - - local gain = channelVolume[self.hardware] or 1 + if event.silence then + local tail = self.drumTail + if not tail then return 0 end + local tailIndex = tail.sample + tail.sample = tailIndex + 1 + if tailIndex >= drumAudioEnd(tail.drum) then + self.drumTail = nil + return 0 + end + return self:sampleDrum(tail, tailIndex) * gain + end if event.drum then return self:sampleDrum(event, sampleIndex) * gain end + self.drumTail = nil local volume = envelopeVolume( event.volume or 0, event.fade or 0, event.elapsed) if event.noise then @@ -665,7 +719,8 @@ function Channel:sample() -- a def-local program may omit its wave table entirely if not wave then return 0 end local index = math.min(32, math.floor(phase * 32) + 1) - return wave[index] * event.waveLevel * gain + local nibble = math.max(0, math.min(15, wave[index] * 8 + 8)) + return (nibble / 15) * event.waveLevel * gain end local duty = event.duty if type(duty) == "table" then @@ -674,7 +729,7 @@ function Channel:sample() local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2] local step = math.floor(phase * 8) % 8 if pattern[step + 1] == 0 then - return -volume / 15 * gain + return 0 end return volume / 15 * gain end @@ -685,7 +740,7 @@ Engine.__index = Engine function Engine:noiseInstrument(number) -- a def-local drum wins over the ROM engine's table for that id local custom = self.customDrums and self.customDrums[number] - if custom then return custom end + if custom then return extendDrumEnvelope(custom) end local cached = self.noiseInstruments[number] if cached then return cached end @@ -718,6 +773,7 @@ function Engine:noiseInstrument(number) end end + extendDrumEnvelope(segments) self.noiseInstruments[number] = segments return segments end @@ -792,6 +848,8 @@ function Engine.new(data, header, options) customDrums = chip and chip.drums or nil, noiseInstruments = {}, channels = {}, + hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0, + lpf = 0, lpfLeft = 0, lpfRight = 0, }, Engine) -- header.tempo: the Music_*AlternateTempo override Music.play stamps onto -- a copy of the song def (audio/alternate_tempo.asm) (#847) @@ -826,10 +884,20 @@ function Engine:finished() return true end +local function analogOut(engine, input, hpfField, lpfField) + local cap = engine[hpfField] + local hp = input - cap + engine[hpfField] = input - hp * HPF_CHARGE + local prev = engine[lpfField] + local lp = prev + LPF_ALPHA * (hp - prev) + engine[lpfField] = lp + return math.max(-1, math.min(1, lp * MIX_SCALE)) +end + function Engine:sample() local value = 0 for _, channel in ipairs(self.channels) do value = value + channel:sample() end - return math.max(-1, math.min(1, value / 4)) + return analogOut(self, value, "hpfCap", "lpf") end function Engine:sampleStereo() @@ -840,8 +908,8 @@ function Engine:sampleStereo() if not event or event.panLeft ~= false then left = left + value end if not event or event.panRight ~= false then right = right + value end end - return math.max(-1, math.min(1, left / 4)), - math.max(-1, math.min(1, right / 4)) + return analogOut(self, left, "hpfCapLeft", "lpfLeft"), + analogOut(self, right, "hpfCapRight", "lpfRight") end function Engine:sampleChannel(number) @@ -850,7 +918,7 @@ function Engine:sampleChannel(number) local value = channel:sample() if channel.number == number then selected = value end end - return math.max(-1, math.min(1, selected / 4)) + return analogOut(self, selected, "hpfCap", "lpf") end -- render `samples` frames into a fresh SoundData (mono or stereo). love.sound @@ -870,22 +938,7 @@ local function soundData(engine, samples, channels) return result end --- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when --- it is too short to be audible. The caller wraps it in a static --- love.audio.Source (a playback concern, hence not done here). --- --- The synthesis is mono (one summed value per frame, unlike the music path's --- sampleStereo), but the buffer is written stereo on purpose: OpenAL only --- spatializes 1-channel Sources, and a Source left at the default (0,0,0) --- position, exactly where the listener sits, is rendered as an ambient sound --- spread over EVERY output channel the device exposes at gains that differ --- from the front pair. On an interface with more than two outputs that put --- the SFX on outputs 5+6 as well, while the 2-channel music source --- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip --- spatialization entirely and map onto the front pair, so duplicating the --- sample costs one buffer's memory and makes effects route exactly like --- music. Deliberately not sampleStereo: that honors the NR51 panning byte --- and would newly hard-pan any effect whose header issues command 0xEE. + local function renderEffectData(data, header, options) if not header then return nil end options = options or {} diff --git a/src/core/Data.lua b/src/core/Data.lua index a6ec72e8..405fe107 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -84,6 +84,15 @@ function Data:applyVersionedFieldData() -- Yellow caches carry the wrong demo species too. The fixed import -- manifest below stamps RATTATA for fresh imports. self.field.oldManBattle = { species = "RATTATA", level = 5 } + -- The Oak-speech show-off mon is the player's Pikachu in Yellow + -- (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo) + -- but caches imported before the manifest carried demoSpecies fell + -- back to Red's NIDORINO (#915). The fixed import manifest below + -- stamps PIKACHU for fresh imports; fill it here for stale caches. + local oakSpeech = self.field.oakSpeech + if type(oakSpeech) == "table" and not oakSpeech.demoSpecies then + oakSpeech.demoSpecies = "PIKACHU" + end end end diff --git a/src/core/Game.lua b/src/core/Game.lua index 06856b91..9cb05fda 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -221,24 +221,44 @@ function Game:step(dt) -- it on its own real-time 60Hz accumulator instead. end +-- The per-category (RFC 0007) save.options multiplier for whichever of +-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active +-- right now. This is the "vanilla" the core.logic_speed hook wraps below +-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so +-- neither a mod nor the category resolution ever has a seam to defeat them. +function Game:_resolveLogicSpeed() + local GameSpeed = require("src.core.GameSpeed") + local category = Game.speedCategoryInStack(self.stack) + local key = GameSpeed.optionKey(category) + local opts = self.save and self.save.options + return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT) +end + -- The logic multiplier for this frame. Read live rather than cached so the --- Options row takes effect immediately; speedOverride is the --speed / +-- Options rows take effect immediately; speedOverride is the --speed / -- POKEPORT_SPEED run argument, which wins over the saved option so a bot -- or screenshot run does not depend on whatever the player last chose. function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") -- Link play is always 1X on both machines, and this wins over every other - -- source including POKEPORT_SPEED. Fast-forward multiplies the logic - -- clock, so a peer at 10X burned a tournament shot clock ten times faster - -- than the opponent it is racing, and drove its own animation/message - -- queue at a different rate than the peer it is locked to. Nothing about - -- a match should depend on what either player set this to. + -- source including POKEPORT_SPEED and every per-category option. + -- Fast-forward multiplies the logic clock, so a peer at 10X burned a + -- tournament shot clock ten times faster than the opponent it is racing, + -- and drove its own animation/message queue at a different rate than the + -- peer it is locked to. Nothing about a match should depend on what + -- either player set this to -- checked here, before the core.logic_speed + -- hook ever runs, so a mod cannot defeat it either. if self.linkSession or (self.linkNet and not self.linkNet.closed) then return 1 end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end - local opts = self.save and self.save.options - return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) + -- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's + -- core.logic_speed hook can return anything (0, negative, nil, NaN) and + -- Hooks:call only guards against a hook that throws, not one that + -- returns a bad value, so an unclamped result would flow straight into + -- the FixedStep accumulator math below and freeze or destabilize logic. + return GameSpeed.clamp(ModRuntime.call("core.logic_speed", + function(g) return g:_resolveLogicSpeed() end, self)) end function Game:update(dt) @@ -337,6 +357,28 @@ function Game.wideBattleInStack(stack) return nil end +-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007) +-- applies right now. Whole-stack, the same idiom as fillScaleInStack/ +-- wideBattleInStack above: an overlay with neither marker (PartyMenu, +-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and +-- inherits whatever is under it, making the category a property of the +-- STACK POSITION the overlay sits over, not of the overlay itself. A +-- scripted sequence (script.started/ended) never pushes a state of its +-- own either -- it runs through the owning overworld/battle state's own +-- script runner or message queue -- so it inherits the same way. Nothing +-- identifying as either (the title screen, credits, an intro cutscene +-- with nothing under it) falls to "menu", the bucket every non-gameplay +-- screen gets; see the RFC's Decisions section for the full reasoning. +function Game.speedCategoryInStack(stack) + local states = stack and stack.states + for i = #(states or {}), 1, -1 do + local state = states[i] + if state and state.isBattle then return "battle" end + if state and state.isOverworld then return "overworld" end + end + return "menu" +end + -- Whether a state on the stack composes its own screen and so wants the -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- everything else here: the text box and YES/NO a battle puts up are states @@ -556,8 +598,15 @@ function Game:_cycleSpeed(dir) or ow.engaging or ow.emote)) end if busy then return end + -- Cycles whichever category Game.speedCategoryInStack says is active + -- right now (RFC 0007) -- pressing the hotkey during a battle speeds up + -- just the battle, on the overworld just the walk, in a menu just the + -- menu. A single physical control that means "speed up whatever I'm + -- looking at right now" needs no new UI and matches what a player + -- pressing it mid-battle almost certainly wants. local GameSpeed = require("src.core.GameSpeed") - self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir) + local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) + self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) self:writeOptions() end @@ -1130,4 +1179,28 @@ function Game:restoreSave(loaded, recovered) end end +-- Reconstruct a previously validated runtime checkpoint without replaying the +-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and +-- save.loading/save.loaded events must not run a second time. Validation, +-- identity checks and transactional rollback live in Checkpoint.lua. +function Game:restoreCheckpointSave(loaded) + self.save = loaded + self:adoptSave(loaded) + while self.stack:top() do self.stack:pop() end + self.stack:push(self.overworld, loaded.player.map, + loaded.player.x, loaded.player.y, loaded.player.facing, + { via = "checkpoint", checkpoint = true }) +end + +-- Install a reconstructed battle without calling BattleState:enter(), whose +-- transition, intro queues and battle-start side effects already happened in +-- the checkpointed timeline. +function Game:restoreCheckpointBattle(battle) + if self.stack:top() ~= self.overworld then + error("battle checkpoint requires a reconstructed overworld base", 0) + end + self.stack.states[#self.stack.states + 1] = battle + if battle.resumeCheckpoint then battle:resumeCheckpoint() end +end + return Game diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 0a5ddde3..96f08815 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir) return levels[nextIdx] end +-- Per-category speed (RFC 0007): overworld walking, battle turns and menu +-- navigation each cycle their own multiplier instead of one global "speed" +-- value. This list is the single source of truth for which categories +-- exist and the order the Options rows/save.options keys follow; +-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category +-- is active on a given frame, this module only knows the category names. +GameSpeed.CATEGORIES = { "overworld", "battle", "menu" } + +-- the save.options field name a category's multiplier lives under, e.g. +-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua, +-- LauncherSettings.lua and the SaveData migration never hand-spell the key. +function GameSpeed.optionKey(category) + return "speed" .. category:sub(1, 1):upper() .. category:sub(2) +end + return GameSpeed diff --git a/src/core/Music.lua b/src/core/Music.lua index be6ba0e1..7ac621bc 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -1,12 +1,3 @@ --- Music playback supports compact ROM channel programs synthesized live by --- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The --- branch is chosen per song definition, never by a global import flag, so a --- file-backed song and a chip song coexist in one dataset. Songs with split --- files chain def.file into def.loopFile in Music.update(). --- Map themes switch on map change; battles override with the battle --- theme and restore afterwards; riding the bike overrides outdoor map --- themes with the bike song until dismount. - local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") @@ -14,20 +5,10 @@ local Music = {} local VOLUME = 0.7 --- port additions driven by OptionsMenu / save.options: musicVol scales --- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter --- low-passes the song. Each filter step keeps 40% of the previous --- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter --- applied twice/three times over. local volumeScale = 1 local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 --- Forward-declared here so applyVolume (below) closes over the real playback --- state rather than a nil global: the table literal is assigned further down, --- but a `local state = {}` there would leave every reference above it bound --- to the global `state`. Before this, registering the `music.volume` mod --- hook crashed applyVolume on `state.current` (a nil index). local state local function applyVolume(src) @@ -115,11 +96,6 @@ function Music.duckForFanfare(src) end end --- Overworld themes where the bike can be ridden (outdoor maps plus the --- caves/dungeons where gen-1 allows cycling). Indoor themes such as --- Pokecenter/Gym/SilphCo never get replaced by the bike theme. --- data.audio.outdoorSongs supersedes this; the copy stays as the fallback --- for caches built before the importer wrote the table. local OUTDOOR = { Music_PalletTown = true, Music_Cities1 = true, @@ -218,6 +194,7 @@ end -- the single choke point every song choice passes through, so one hook -- covers map themes, battle themes, jingles and scene music local function selectSong(song, ctx) + if ctx and ctx.selected then return song end if not Runtime.wantsHook("music.select") then return song end return Runtime.call("music.select", function(chosen) return chosen end, song, { reason = ctx and ctx.reason or "direct", @@ -234,17 +211,28 @@ end function Music.play(data, song, loop, ctx) if not song then return end if not love.audio then return end -- headless test stub + ctx = ctx or {} song = selectSong(song, ctx) - -- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm): - -- the same song restarted with channel 1 re-pointed at a stub whose only - -- difference is its `tempo`, so the same label at a different tempo is a - -- different cue and must not be deduped away (#847) + local tempo = ctx and ctx.tempo or nil -- a hook may silence the cue outright, or swap in a label the dedupe -- below has to compare against if not song or (song == state.current and tempo == state.tempo) then return end local def = songDef(data, song) if not def or state.failed[song] then return end + + if ctx.fade and state.source then + local queued = {} + for key, value in pairs(ctx) do queued[key] = value end + queued.fade, queued.selected = nil, true + local pending = { data = data, song = song, loop = loop, ctx = queued } + if state.fade then + state.fade.pending = pending + else + Music.fadeOut(ctx.fade, pending) + end + return + end if tempo then -- shallow copy: the registry def is shared, only this playback is slowed local slowed = {} @@ -317,24 +305,26 @@ function Music.reload() Music.stop() end --- Ramp the current song's volume to silence, then stop it, mirroring the --- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio + --- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in --- integer levels, one level every `control` frames, and the music stops --- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM --- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames --- to silence). Ticked once per frame from Music.update(). -function Music.fadeOut(control) - if not state.source then Music.stop() return end +function Music.fadeOut(control, pending) + if not state.source then + Music.stop() + if pending then + Music.play(pending.data, pending.song, pending.loop, pending.ctx) + end + return + end control = math.max(1, control or 10) state.fade = { control = control, counter = control, -- frames until the next volume step level = 7, -- current master-volume level (rAUDVOL nibble) from = VOLUME * volumeScale, -- level-7 (full) source volume + pending = pending, } end +Music.MAP_FADE = 10 + -- the song a map should currently play, honoring the bike/surf overrides local function effectiveMapSong(data, song) if not song or not outdoorSongs(data)[song] then return song end @@ -351,14 +341,17 @@ end -- overworld map theme; onBike/surfing override outdoor themes with the -- bike/surf songs and restore the map theme when they end -function Music.playMap(data, mapId, onBike, surfing) +function Music.playMap(data, mapId, onBike, surfing, fade) local song = data and data.audio and data.audio.mapSongs and mapId and data.audio.mapSongs[mapId] or nil state.mapSong = song state.onBike = not not onBike state.surfing = not not surfing local play = effectiveMapSong(data, song) - if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end + if play then + Music.play(data, play, nil, + { reason = "map", mapId = mapId, fade = fade }) + end end -- toggle the surf override mid-map (starting/ending a surf) @@ -368,11 +361,12 @@ function Music.setSurfing(data, surfing) if play then Music.play(data, play, nil, { reason = "map" }) end end --- battle themes; kind = "wild"|"trainer"|"gym"|"final" -function Music.playBattle(data, kind, trainerId) +-- battle themes; kind = "wild"|"trainer"|"gym"|"final". `song`, when +-- given, overrides the kind's default -- a mod-set trainer battleTheme. +function Music.playBattle(data, kind, trainerId, song) local b = data.audio and data.audio.battle if b then - Music.play(data, b[kind] or b.wild, nil, + Music.play(data, song or b[kind] or b.wild, nil, { reason = "battle", kind = kind, trainerId = trainerId }) end end @@ -474,8 +468,13 @@ function Music.update(data) f.counter = f.control f.level = f.level - 1 if f.level <= 0 then + -- ..(home/fade_audio.asm ln 36) state.fade = nil + local pending = f.pending Music.stop() + if pending then + Music.play(pending.data, pending.song, pending.loop, pending.ctx) + end return end local vol = f.from * f.level / 7 diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 00000000..77bf83f5 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -0,0 +1,18 @@ +-- Generic process-lifecycle mod hooks so a platform-specific launcher +-- integration (a native shell embedding this engine, e.g. wrapping the +-- window in a platform UI) can live entirely in a mod instead of +-- hand-patching main.lua, which every other engine change also touches. +-- See docs/modding.md's "Process-lifecycle hooks" section. +local ModRuntime = require("src.mods.Runtime") + +local PlatformHooks = {} + +function PlatformHooks.update(game, dt) + return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt) +end + +function PlatformHooks.quitToLauncher(vanilla) + return ModRuntime.call("core.quit_to_launcher", vanilla) +end + +return PlatformHooks diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 882897bb..4b8511bf 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -216,6 +216,13 @@ local function persistFs(fs) return SaveData.portableFs() or fs or (love and love.filesystem) end +-- Engine-owned persistence routing for subsystems that must follow the same +-- standard/portable root as saves without exposing raw filesystem access to a +-- mod. An explicitly injected headless filesystem still wins for tests. +function SaveData.persistenceFs(fs) + return persistFs(fs) +end + -- Port + original Options menu defaults. Missing keys on load are filled -- from this table so old options.lua files stay compatible. function SaveData.defaultOptions() @@ -254,8 +261,12 @@ function SaveData.defaultOptions() -- (the PIKACHU VOL row appears only on Yellow; see Sound.lua) pikaVol = 7, musicFilter = 0, - -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) - speed = 1, + -- Per-category logic fast-forward multiplier (RFC 0007); audio is + -- unaffected (GameSpeed.lua). Superseded from a single "speed" field -- + -- mergeOptions migrates an old save's value into all three below. + speedOverworld = 1, + speedBattle = 1, + speedMenu = 1, -- port display options (OptionsMenu / hotkeys 2/3/4/5) colors = "gbc", tilt = 0, @@ -332,6 +343,19 @@ function SaveData.mergeOptions(loaded) for k, v in pairs(loaded) do opts[k] = v end + -- RFC 0007 migration: a save from before per-category GAME SPEED still + -- has a single "speed" and none of the three new fields, so seed all + -- three from it -- an existing player's fast-forward preference + -- carries over instead of two of the three categories silently + -- resetting to 1X. "speed" is dropped on the way out (not kept as a + -- stale alias), so a re-save never re-triggers this migration. + if loaded.speed ~= nil and loaded.speedOverworld == nil + and loaded.speedBattle == nil and loaded.speedMenu == nil then + opts.speedOverworld = loaded.speed + opts.speedBattle = loaded.speed + opts.speedMenu = loaded.speed + end + opts.speed = nil end return opts end @@ -351,6 +375,22 @@ local function readTable(fs, name) return SaveSerializer.decode(body) end +-- Deep-copy a value folded in from the on-disk decode so the returned +-- options table never aliases the file's nested tables (SaveData must not +-- depend on src/mods/Merge.lua for this). Options data is plain tables of +-- strings/numbers/booleans/tables, so a cycle guard is belt-and-braces. +local function deepCopy(v, seen) + if type(v) ~= "table" then return v end + seen = seen or {} + if seen[v] then return seen[v] end + local copy = {} + seen[v] = copy + for k, val in pairs(v) do + copy[deepCopy(k, seen)] = deepCopy(val, seen) + end + return copy +end + -- the stub filesystem some headless harnesses inject has no remove; a -- lingering tmp/bak there is harmless local function remove(fs, name) @@ -364,13 +404,48 @@ end -- options round-trip headless (no love global). function SaveData.saveOptions(opts, fs) fs = persistFs(fs) + -- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a + -- PARTIAL table (just the keys it changed) would silently drop every key it + -- does not mention -- launcher-only keys like lastVersion, and keys the + -- launcher set (battleBg, tilt...) all fall back to defaults. Read the + -- on-disk file FIRST and fold caller-absent values underneath, so a delta + -- write changes only what it names. + -- + -- A table holding EVERY defaultOptions key is a full snapshot + -- (loadOptions() results, game.save.options, the RESET REBINDS / + -- activeProfile-drop paths) and stays authoritative: its absent keys are + -- deliberate deletions, so nothing folds for it. Partial tables get every + -- on-disk key they do not provide folded in (deep-copied so the caller's + -- table is never aliased). This is the reconciling rule: bindings and + -- activeProfile -- not defaultOptions members -- can be deleted by their + -- sites precisely because those sites always write full tables. + local onDisk = readTable(fs, OPTIONS_FILENAME) + local isFull = type(opts) == "table" + if isFull then + for k in pairs(SaveData.defaultOptions()) do + if opts[k] == nil then isFull = false break end + end + end + if not isFull then + local merged = {} + if type(opts) == "table" then + for k, v in pairs(opts) do merged[k] = v end + end + if type(onDisk) == "table" then + for k, v in pairs(onDisk) do + if k ~= "modOptions" and merged[k] == nil then + merged[k] = deepCopy(v) + end + end + end + opts = merged + end opts = SaveData.mergeOptions(opts) -- modOptions is per-mod nested state: fold the on-disk sub-tree -- underneath (newest value winning per key) so one caller's partial -- write cannot clobber another mod's persisted keys. Every other -- option stays on the shallow path. - local onDisk = readTable(fs, OPTIONS_FILENAME) - if onDisk and type(onDisk.modOptions) == "table" then + if type(onDisk) == "table" and type(onDisk.modOptions) == "table" then local merged = {} for modId, bucket in pairs(onDisk.modOptions) do merged[modId] = bucket @@ -479,6 +554,10 @@ end -- working unchanged. local activeSlotCache = {} -- version -> slotId in use, or false when none local slotsChecked = {} -- version -> true once resolved this process +-- At most one New Game can be the live candidate for a first public tool +-- request. A single strong reference models that runtime fact without adding +-- marker data to the save or retaining abandoned playthrough tables. +local freshPlaythrough local function slotDir(version) return "saves/" .. version end @@ -815,6 +894,77 @@ end function SaveData.resetSlotState() for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end + freshPlaythrough = nil +end + +-- ------- opaque playthrough identity + +-- An id must never perturb the engine's gameplay RNG: savestate tools need +-- repeatable random outcomes, and allocating persistence scope is not gameplay. +-- Combine wall/process time, a process-local sequence and a fresh table address +-- into four hex words. This is an opaque collision-resistant identifier, not a +-- secret or a player-visible value. +local playthroughSeq = 0 + +local function word(n) + return math.floor(tonumber(n) or 0) % 4294967296 +end + +function SaveData.newPlaythroughId() + playthroughSeq = playthroughSeq + 1 + local address = tostring({}):match("0x(%x+)") or "0" + local addressLo = tonumber(address:sub(-8), 16) or 0 + local clock = math.floor((os.clock() or 0) * 1000000) + return ("%08x%08x%08x%08x"):format( + word(os.time()), word(clock), word(addressLo), word(playthroughSeq)) +end + +local function playthroughScope(version, injectedFs) + version = version or GameVersion.get() + local fs = persistFs(injectedFs) + ensureVersionSlots(version, fs) + return activeSlotCache[version] or "legacy" +end + +local function rememberPlaythroughId(save, opts, injectedFs) + local meta = type(save) == "table" and save.meta + local id = type(meta) == "table" and meta.playthroughId + if type(id) ~= "string" or id == "" then return opts, false end + local version = save.version or GameVersion.get() + local scope = playthroughScope(version, injectedFs) + opts = opts or SaveData.loadOptions(injectedFs) + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + local changed = opts.playthroughIds[version][scope] ~= id + opts.playthroughIds[version][scope] = id + return opts, changed +end + +-- Return an existing save identity or give a pre-identity save a stable one. +-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id +-- into progress, so installing a tool mod never rewrites the player's checkpoint. +function SaveData.ensurePlaythroughId(save, injectedFs) + if type(save) ~= "table" then return nil end + save.meta = type(save.meta) == "table" and save.meta or {} + local id = save.meta.playthroughId + if type(id) == "string" and id ~= "" then return id end + + local version = save.version or GameVersion.get() + local scope = playthroughScope(version, injectedFs) + local opts = SaveData.loadOptions(injectedFs) + local isFresh = save == freshPlaythrough + if isFresh then freshPlaythrough = nil end + local byVersion = opts.playthroughIds and opts.playthroughIds[version] + id = not isFresh and byVersion and byVersion[scope] or nil + if type(id) ~= "string" or id == "" then + id = SaveData.newPlaythroughId() + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + opts.playthroughIds[version][scope] = id + SaveData.saveOptions(opts, injectedFs) + end + save.meta.playthroughId = id + return id end -- ------- meta @@ -838,6 +988,7 @@ function SaveData.buildMeta(mods, previous) format = Version.saveFormat, engine = Version.engine, savedAt = os.time(), + playthroughId = type(previous) == "table" and previous.playthroughId or nil, mods = list, } end @@ -1048,7 +1199,15 @@ function SaveData.save(data, mods) -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version) if data.options then - SaveData.saveOptions(data.options) + local opts = data.options + if data.meta and data.meta.playthroughId then + opts = rememberPlaythroughId(data, data.options) + end + data.options = opts + SaveData.saveOptions(opts) + elseif data.meta and data.meta.playthroughId then + local opts, changed = rememberPlaythroughId(data) + if changed then SaveData.saveOptions(opts) end end if mods ~= nil or data.meta == nil then data.meta = SaveData.buildMeta(mods, data.meta) @@ -1470,8 +1629,12 @@ function SaveData.newGame(boot) options = SaveData.loadOptions(), } -- a total conversion reshapes the skeleton (spawn, party, money) - -- before anything reads it; unhooked this returns save unchanged - return Runtime.call("save.new_game", function(s) return s end, save) + -- before anything reads it; unhooked this returns save unchanged. Keep the + -- "fresh playthrough" marker outside the serialized table so a later tool + -- request can distinguish two unsaved New Games sharing one vanilla slot. + save = Runtime.call("save.new_game", function(s) return s end, save) + freshPlaythrough = save + return save end return SaveData diff --git a/src/core/SaveSerializer.lua b/src/core/SaveSerializer.lua index 77ba482d..9f821f67 100644 --- a/src/core/SaveSerializer.lua +++ b/src/core/SaveSerializer.lua @@ -41,6 +41,13 @@ local function serialize(v, indent) error("cannot serialize " .. t) end +-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands +-- inside a compiled recursive serialization trace. The symptom is valid input +-- becoming `{" followed by only the trailing comma, which then cannot be read +-- back. Save encoding is infrequent and I/O-bound, so keep this correctness- +-- critical recursion in the interpreter while leaving the game JIT enabled. +if jit and jit.off then jit.off(serialize, true) end + function SaveSerializer.encode(data) return "return " .. serialize(data) .. "\n" end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index aba010b6..03f1b8cc 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -214,10 +214,24 @@ local function coreRows(opts, hooks) local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") if okSpd then - add(Strings("GAME SPEED"), - function() return GameSpeed.levelLabel(opts.speed) end, + -- Per-category (RFC 0007): overworld/battle/menu each cycle their own + -- multiplier, mirroring OptionsMenu.lua's three rows. + add(Strings("OVERWORLD SPEED"), + function() return GameSpeed.levelLabel(opts.speedOverworld) end, function(dir) - opts.speed = GameSpeed.cycle(opts.speed, dir) + opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir) + return true + end) + add(Strings("BATTLE SPEED"), + function() return GameSpeed.levelLabel(opts.speedBattle) end, + function(dir) + opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir) + return true + end) + add(Strings("MENU SPEED"), + function() return GameSpeed.levelLabel(opts.speedMenu) end, + function(dir) + opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir) return true end) end diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 88cad0fa..6c4319cc 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -198,6 +198,12 @@ function RomExtractor:extractTilesets() out[constName] = { id = constName, source = ("ROM:Tilesets[%d]"):format(index - 1), + -- The raw Tilesets row, verbatim. A .sav export has to reproduce what + -- LoadTilesetHeader (engine/overworld/tilesets.asm) would have left in + -- wTilesetBank..wGrassTile, because a Continue never re-runs it -- see + -- src/save_convert/MapContext.lua (#889). Byte 12 is the tile + -- animation id, which rides in sTileAnimations. + header = self.rom:bytes(headers.bank, rowAddress, 12), image = "assets/generated/tilesets/" .. base .. ".png", imageWidth = spec.imageWidth, imageHeight = spec.imageHeight, @@ -268,6 +274,11 @@ function RomExtractor:extractMaps() assert(tilesetId < #tilesets, constName .. ": unknown tileset id") local blockPointer = self.rom:word(header.bank, address + 3) local connectionFlags = self.rom:byte(header.bank, address + 9) + -- wCurMapHeader verbatim (tileset, height, width, data/text/script + -- pointers, connection flags). A save restores this window instead of + -- rebuilding it, so an export has to carry the real bytes (#889). + local headerBytes = self.rom:bytes(header.bank, address, 10) + local connectionStart = address + 10 address = address + 10 local connections = {} @@ -289,6 +300,8 @@ function RomExtractor:extractMaps() end assert(bit.band(connectionFlags, 0xF0) == 0, constName .. ": unknown connection flags") + local connectionBytes = self.rom:bytes( + header.bank, connectionStart, address - connectionStart) local objectPointer = self.rom:word(header.bank, address) local objectAddress = objectPointer local borderBlock = self.rom:byte(header.bank, objectAddress) @@ -376,6 +389,17 @@ function RomExtractor:extractMaps() width = width, height = height, blocks = blocks, borderBlock = borderBlock, connections = connections, warps = warps, signs = signs, objects = objects, + -- Raw ROM bytes a .sav export replays through LoadMapHeader's WRAM + -- writes (src/save_convert/MapContext.lua, #889). Kept as the original + -- bytes rather than re-encoded from the decoded tables above: the + -- pointers in them (wCurMapDataPtr, the connection strip src/dest + -- addresses, sign text ids) have no equivalent in the port's own model. + sram = { + header = headerBytes, + connections = connectionBytes, + objects = self.rom:bytes( + header.bank, objectPointer, objectAddress - objectPointer), + }, } self:tick("Maps", mapIndex, #keys) end @@ -1732,7 +1756,7 @@ end function RomExtractor:extractField() self:beginStage("Interface artwork") - local done, total = 0, 50 + local done, total = 0, 51 local function tick() done = done + 1 self:tick("Interface artwork", math.min(done, total), total) @@ -1748,6 +1772,16 @@ function RomExtractor:extractField() "title/copyright.png"); tick() self:raw2bpp("GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png"); tick() + + do + local gf = self.symbols["GameFreakLogoGraphics"] + local tb = self.symbols["TextBoxGraphics"] + if gf and tb and tb[2] == gf[2] + 9 * 16 + 16 then + local raw = self.rom:bytes(gf[1], gf[2] + 9 * 16, 16) + self:save(ImageWriter.decode2bpp(raw, 8, 8, false), "title/nine.png") + end + end + tick() -- Yellow fixed Pikachu title art (no-op on Red/Blue manifests). self:extractYellowTitleArt(); tick() diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d9501764..a38c5b0e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -30,7 +30,11 @@ end -- Cache generation tag; bump to force every imported version to re-extract. -- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches -- carry Red's bank $1f header, wave-table, and CryData offsets. -local CACHE_FORMAT = "rom-cache-v9:" +-- v10: maps carry their raw map-header/connection/object bytes and tilesets +-- their Tilesets row (#889), which a .sav export replays so a Continue on +-- real hardware has a map to load; a v9 cache has none of them and exports +-- the same unbootable save as before. +local CACHE_FORMAT = "rom-cache-v10:" -- The completion marker is written under each version's cache prefix -- (red/rom-cache.complete, blue/rom-cache.complete, ...). local MARKER_PATH = "rom-cache.complete" @@ -152,18 +156,22 @@ local function allRequiredFilesExist(version) return ok end --- A developer checkout / Python build leaves Red's generated data in the --- physfs SOURCE at the un-prefixed root (the checked-out data/generated and --- assets/generated); it is always current and never moves into red/. Only --- Red ships this way (Blue/Yellow are import-only). The check goes through --- love.filesystem directly so the red/ cache prefix cannot hide the source --- tree, and the realDirectory test keeps a save-dir cache from counting. -local function sourceTreeHasData() +-- A developer checkout / Python build leaves generated data in the physfs +-- source: Red at the historical root, Blue/Yellow in their versioned trees. +-- Imported Red caches still live under red/. Check source paths directly so +-- that cache prefix cannot hide Red's source tree, and keep save-dir caches +-- from counting as current source data. +local function sourceTreeHasData(version) if not love.filesystem.getRealDirectory then return false end + local prefix = version == "red" and "" or GameVersion.cachePrefix(version) for _, path in ipairs(REQUIRED_FILES) do - if love.filesystem.getInfo(path, "file") == nil then return false end + if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end end - local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1]) + for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do + if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end + end + local path = prefix .. REQUIRED_FILES[1] + local real = love.filesystem.getRealDirectory(path) return real == love.filesystem.getSource() end @@ -240,10 +248,8 @@ function RomImporter.isReady(version) -- save-directory copy that would otherwise shadow it at runtime. purgeSaveDirCache() end - -- Red generated data in the physfs source (developer checkout / Python - -- build) is always current; Blue is import-only and falls through to the - -- version-marker gate. - if version == "red" and sourceTreeHasData() then return true end + -- Generated data in a developer checkout / Python build is always current. + if sourceTreeHasData(version) then return true end local saved = CacheFs.prefix CacheFs.prefix = GameVersion.cachePrefix(version) local marker = CacheFs.read(MARKER_PATH) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 63f74228..2dd49e1d 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -25,6 +25,18 @@ local function noEffect(data) return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.") end +local function registeredEffect(data, itemDef) + if not data or not itemDef or not itemDef.effect then + return nil + end + + if not data.item_effects then + return nil + end + + return data.item_effects[itemDef.effect] +end + local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200, FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80, @@ -72,7 +84,18 @@ function ItemEffects.healsHP(id) end -- Does this item need a party-member target? -function ItemEffects.needsTarget(id, itemDef) +-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection +function ItemEffects.needsTarget(id, itemDef, data) + if itemDef and itemDef.needsTarget ~= nil then + return itemDef.needsTarget + end + + local effect = registeredEffect(data, itemDef) + + if effect and effect.needsTarget ~= nil then + return effect.needsTarget + end + return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION" or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE" or id == "RARE_CANDY" or STONES[id] @@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local itemDef = data.items[itemId] local name = itemDef and itemDef.name or itemId + local effectDef = registeredEffect(data, itemDef) + if effectDef then + if battle and effectDef.battle == false then + return "failed", { notTime(data, save) } + end + + if not battle and effectDef.field == false then + return "failed", { notTime(data, save) } + end + + return effectDef.use({ + data = data, + save = save, + itemId = itemId, + item = itemDef, + target = target, + battle = battle, + moveIndex = moveIndex, + overworld = ow, + }) + end + -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / -- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle -- (jp nz, ItemUseNotTime) diff --git a/src/link/Json.lua b/src/link/Json.lua index 9ddb2e30..b6bd58a4 100644 --- a/src/link/Json.lua +++ b/src/link/Json.lua @@ -171,4 +171,26 @@ function Json.decode(s) return nil, v end +-- For an HTTP response that was meant to carry JSON but did not. Returns nil +-- when `s` starts like a JSON object or array (the only shapes the update and +-- index endpoints publish), otherwise a short message naming what the server +-- actually sent -- so callers surface "the response was an HTML page/plain +-- text, not JSON (it starts with ...)" instead of leaking the decoder's +-- low-level "unexpected character 'E'" assert at the first byte of an error +-- page or plain-text outage message. +function Json.describeUnexpected(s) + if type(s) ~= "string" then + return "the response had no body to decode" + end + local first = s:match("^%s*(.)") + if first == "{" or first == "[" then return nil end + local preview = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if preview == "" then + return "the response was empty, not JSON" + end + if #preview > 60 then preview = preview:sub(1, 57) .. "..." end + local kind = (first == "<") and "an HTML page" or "plain text" + return ("the response was %s, not JSON (it starts with %q)"):format(kind, preview) +end + return Json diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 4e97133e..9f3da68d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -10,6 +10,7 @@ local Net = require("src.link.Net") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") +local Session = require("src.link.Session") local TextBox = require("src.render.TextBox") local Strings = require("src.core.Strings") @@ -44,10 +45,8 @@ local function forceLevelLabel(v) return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v)) end --- stages before any Net object is meaningfully "this session's link" -- --- .net can still be a leftover failed attempt sitting on self, so error/ --- closed checks below skip these rather than keying off self.net's --- presence alone +-- stages before a successful transport has become this link's Session; +-- terminal checks skip them rather than keying off self.net's presence local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } -- how long the host waits for a v2 hello before deciding the peer predates @@ -70,6 +69,16 @@ local function ipDigits(ip) return digits end +local function openSession(role, connect) + local transport = Net.new() + if connect(transport) then + return Session.new(transport, { role = role, kind = "link" }) + end + local detail = transport.error or "?" + transport:close() + return nil, detail +end + function LinkState.new(game) local self = setmetatable({}, LinkState) self.game = game @@ -89,12 +98,15 @@ end -- "connecting with this code", same as if the player had typed it in function LinkState.newJoinOnline(game, code) local self = LinkState.new(game) - self.net = Net.new() - if self.net:joinOnline(nil, code) then + local session, detail = openSession("guest", function(transport) + return transport:joinOnline(nil, code) + end) + if session then + self.net = session self.stage = "onlineJoining" else self.stage = "menu" -- exitWith below needs a real stage to unwind from - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end return self end @@ -163,21 +175,13 @@ end -- take the peer's hello out of the inbox without eating anything that -- shares the batch with it function LinkState:pollHello() - local msgs = self.net:poll() - local keep, got = {}, false - for _, msg in ipairs(msgs) do - if msg.type == "hello" and not self.peerHello then - self.peerHello = msg - self.peerName = msg.name - got = true - else - keep[#keep + 1] = msg - end + local message + if not self.peerHello then message = self.net:take("hello") end + if message then + self.peerHello = message + self.peerName = message.name end - for i = #keep, 1, -1 do - table.insert(self.net.inbox, 1, keep[i]) - end - return got, #keep > 0 + return message ~= nil, self.net:hasPending() end function LinkState:sendHello(mode) @@ -220,13 +224,15 @@ function LinkState:update(dt) local input = self.game.input if self.net then self.net:update() - if self.net.error and not PRE_CONNECT_STAGES[self.stage] then - self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) + local status = self.net:getStatus() + if status == "failed" and not PRE_CONNECT_STAGES[self.stage] then + self:exitWith(Strings("Link error:\n%s", + (self.net.error or "?"):sub(1, 60))) return end - -- the peer vanished without a bye (only once the inbox is drained, + -- the peer vanished without a bye (only once the session FIFO drains, -- so a final message travelling with the disconnect still counts) - if self.net.closed and #self.net.inbox == 0 + if status == "closed" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "battleRunning" then @@ -269,12 +275,15 @@ function LinkState:update(dt) self.stage = "menu" self.index = 1 elseif input:wasPressed("a") then - self.net = Net.new() if self.index == 1 then - if self.net:host() then + local session, detail = openSession("host", function(transport) + return transport:host() + end) + if session then + self.net = session self.stage = "hosting" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end else self.stage = "addrEntry" @@ -289,11 +298,14 @@ function LinkState:update(dt) self.index = 2 elseif input:wasPressed("a") then if self.index == 1 then - self.net = Net.new() - if self.net:hostOnline() then + local session, detail = openSession("host", function(transport) + return transport:hostOnline() + end) + if session then + self.net = session self.stage = "onlineHosting" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end else self.stage = "codeEntry" @@ -327,11 +339,14 @@ function LinkState:update(dt) CodeEntry.right(self.codeEntry) elseif input:wasPressed("a") then local code = CodeEntry.text(self.codeEntry) - self.net = Net.new() - if self.net:joinOnline(nil, code) then + local session, detail = openSession("guest", function(transport) + return transport:joinOnline(nil, code) + end) + if session then + self.net = session self.stage = "onlineJoining" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end end @@ -367,10 +382,15 @@ function LinkState:update(dt) + self.addr[base + 2] * 10 + self.addr[base + 3]) end - if self.net:join(table.concat(octets, ".")) then + local address = table.concat(octets, ".") + local session, detail = openSession("guest", function(transport) + return transport:join(address) + end) + if session then + self.net = session self.stage = "joining" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end end @@ -428,19 +448,11 @@ function LinkState:update(dt) elseif self.stage == "waitMode" then -- guest waits for host's pick if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "hello" then - self.peerHello = msg - self.peerName = msg.name - -- the host's next messages (party, ...) can share this batch; - -- put them back so the new stage's poll sees them - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end - self:decideCompat(msg.mode, false) - break - end + local message = self.net:take("hello") + if message then + self.peerHello = message + self.peerName = message.name + self:decideCompat(message.mode, false) end elseif self.stage == "notice" then @@ -456,40 +468,34 @@ function LinkState:update(dt) elseif self.stage == "battleWait" then if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "party" then - -- the host owns this rule (same as mode); the guest only learns - -- it here, off the host's own party message - if not self.isHost then self.forceLevel = msg.forceLevel end - local LinkBattle = require("src.link.LinkBattle") - local opts = { - myParty = Protocol.packParty(self.game.save.party), - theirParty = msg.mons, - theirName = self.peerName or "FOE", - seed = self.isHost and self.linkSeed or msg.seed, - verdict = self.verdict, - strict = Handshake.strict(self.verdict), - forceLevel = self.forceLevel, - } - local battle, why - if self.isHost then - battle, why = LinkBattle.newHost(self.game, self.net, opts) - else - battle, why = LinkBattle.newGuest(self.game, self.net, opts) - end - if not battle then - self.net:send({ type = "bye" }) - self:exitWith(why or Strings("Link battle\ncan't start."), "error") - return - end - self.game.stack:push(battle) - self.stage = "battleRunning" - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end - break + local message = self.net:take("party") + if message then + -- the host owns this rule (same as mode); the guest only learns + -- it here, off the host's own party message + if not self.isHost then self.forceLevel = message.forceLevel end + local LinkBattle = require("src.link.LinkBattle") + local opts = { + myParty = Protocol.packParty(self.game.save.party), + theirParty = message.mons, + theirName = self.peerName or "FOE", + seed = self.isHost and self.linkSeed or message.seed, + verdict = self.verdict, + strict = Handshake.strict(self.verdict), + forceLevel = self.forceLevel, + } + local battle, why + if self.isHost then + battle, why = LinkBattle.newHost(self.game, self.net, opts) + else + battle, why = LinkBattle.newGuest(self.game, self.net, opts) end + if not battle then + self.net:send({ type = "bye" }) + self:exitWith(why or Strings("Link battle\ncan't start."), "error") + return + end + self.game.stack:push(battle) + self.stage = "battleRunning" end elseif self.stage == "battleRunning" then diff --git a/src/link/Net.lua b/src/link/Net.lua index 950bee1e..b8d4654c 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -207,7 +207,7 @@ function Net:send(msg) end if self.peerEnd then -- loopback: re-encode through json like the wire local decoded = Json.decode(Json.encode(msg)) - if decoded and not self.peerEnd.closed then + if decoded ~= nil and not self.peerEnd.closed then table.insert(self.peerEnd.inbox, decoded) end return @@ -256,13 +256,12 @@ end function Net:handleTCPLine(line) local msg = Json.decode(line) - if not msg then + if msg == nil then Logger.warn("link: bad relay message %q", line:sub(1, 60)) return end - if not handleGenericRelayControl(self, msg) then - table.insert(self.inbox, msg) - end + if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end + table.insert(self.inbox, msg) end -- pulls every complete "\n"-terminated line out of rxBuf (leaving a @@ -352,7 +351,7 @@ function Net:update() end elseif event.type == "receive" then local msg = Json.decode(event.data) - if msg then + if msg ~= nil then table.insert(self.inbox, msg) else Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60)) diff --git a/src/link/Session.lua b/src/link/Session.lua new file mode 100644 index 00000000..ecb4a64b --- /dev/null +++ b/src/link/Session.lua @@ -0,0 +1,198 @@ +local Session = {} +Session.__index = Session + +local VALID_ROLES = { host = true, guest = true } +local REQUIRED_METHODS = { "update", "poll", "send", "close" } + +function Session.new(transport, options) + assert(type(transport) == "table", "Session.new requires a transport") + assert(type(options) == "table", "Session.new requires options") + assert(VALID_ROLES[options.role], "Session role must be host or guest") + assert(type(options.kind) == "string" and options.kind ~= "", + "Session kind must be a non-empty string") + for _, method in ipairs(REQUIRED_METHODS) do + assert(type(transport[method]) == "function", + "Session transport requires " .. method) + end + + local self = setmetatable({ + _transport = transport, + _role = options.role, + _kind = options.kind, + _inbox = {}, + _status = "connecting", + _terminal = nil, + _transportCloseCalled = false, + paired = false, + closed = false, + error = nil, + code = nil, + address = nil, + target = nil, + }, Session) + self:_syncMetadata() + self:_refreshStatus() + return self +end + +function Session:_syncMetadata() + local transport = self._transport + self.paired = transport.paired == true + self.code = transport.code + self.address = transport.address + self.target = transport.target +end + +function Session:_refreshStatus() + if not self._terminal then + self._status = self.paired and "paired" or "connecting" + self.closed = false + self.error = nil + return + end + if #self._inbox > 0 then + self._status = "draining" + self.closed = false + self.error = nil + return + end + self._status = self._terminal.status + self.closed = true + self.error = self._terminal.status == "failed" + and (self._terminal.detail or self._terminal.reason) or nil +end + +function Session:_latchTerminal(status, reason, detail) + if self._terminal then return false end + self._terminal = { status = status, reason = reason, detail = detail } + self:_refreshStatus() + return true +end + +function Session:_closeTransport() + if self._transportCloseCalled then return true end + self._transportCloseCalled = true + local ok, detail = pcall(self._transport.close, self._transport) + return ok, ok and nil or tostring(detail) +end + +function Session:getRole() return self._role end +function Session:getKind() return self._kind end +function Session:getStatus() return self._status end +function Session:getFailure() + if not self._terminal or self._terminal.status ~= "failed" then + return nil, nil + end + return self._terminal.reason, self._terminal.detail +end +function Session:hasPending() return #self._inbox > 0 end + +function Session:send(message) + if self._terminal then return nil end + return self._transport:send(message) +end + +function Session:update() + if self._terminal then + self:_refreshStatus() + return + end + + local failureReason, failureDetail + local updateOk, updateDetail = pcall(self._transport.update, self._transport) + self:_syncMetadata() + if not updateOk then + failureReason, failureDetail = "transport_error", tostring(updateDetail) + elseif self._transport.error then + failureReason = "transport_error" + failureDetail = tostring(self._transport.error) + end + + local pollOk, messages = pcall(self._transport.poll, self._transport) + if not pollOk then + if not failureReason then + failureReason, failureDetail = "transport_error", tostring(messages) + end + elseif type(messages) ~= "table" then + if not failureReason then + failureReason, failureDetail = "transport_error", + "transport poll returned non-table" + end + else + for index = 1, #messages do + local message = messages[index] + if type(message) ~= "table" or type(message.type) ~= "string" then + if not failureReason then + failureReason = "protocol_error" + failureDetail = ("message %d must be a table with string type") + :format(index) + end + break + end + self._inbox[#self._inbox + 1] = message + end + end + + self:_syncMetadata() + if failureReason then + self:_latchTerminal("failed", failureReason, failureDetail) + self:_closeTransport() + elseif self._transport.closed then + local closeOk, closeDetail = self:_closeTransport() + if closeOk then + self:_latchTerminal("closed") + else + self:_latchTerminal("failed", "transport_error", closeDetail) + end + end + self:_refreshStatus() +end + +local function finishRead(self) + self:_refreshStatus() +end + +function Session:take(messageType) + assert(type(messageType) == "string", "Session.take requires a message type") + for index, message in ipairs(self._inbox) do + if message.type == messageType then + local found = table.remove(self._inbox, index) + finishRead(self) + return found + end + end + return nil +end + +function Session:pollOne() + if #self._inbox == 0 then return nil end + local message = table.remove(self._inbox, 1) + finishRead(self) + return message +end + +function Session:poll() + local messages = self._inbox + self._inbox = {} + finishRead(self) + return messages +end + +function Session:close() + if self._status == "closed" or self._status == "failed" then return end + if self._terminal then + self:_closeTransport() + self:_refreshStatus() + return + end + local ok, detail = self:_closeTransport() + if ok then + self:_latchTerminal("closed") + else + self:_latchTerminal("failed", "transport_error", detail) + end + self:_syncMetadata() + self:_refreshStatus() +end + +return Session diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index cfe663f4..a8a06193 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -14,6 +14,7 @@ local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local LinkBattle = require("src.link.LinkBattle") local Net = require("src.link.Net") +local Session = require("src.link.Session") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") @@ -132,12 +133,23 @@ end -- host / join -- ------------------------------------------------------------------- +local function openSession(role) + local transport = Net.new() + if transport:connectTCP(Net.defaultRelayAddress()) then + return Session.new(transport, { role = role, kind = "tournament" }) + end + local detail = transport.error or "?" + transport:close() + return nil, detail +end + function Tournament:startHosting() - self.net = Net.new() - if not self.net:connectTCP(Net.defaultRelayAddress()) then - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + local session, detail = openSession("host") + if not session then + self:exitWith(Strings("Link error:\n%s", detail)) return end + self.net = session local size, minL, maxL = partyStats(self.game.save.party) self.isCreator = true self.participating = self.settings.participating @@ -156,11 +168,12 @@ function Tournament:startHosting() end function Tournament:startJoining(code) - self.net = Net.new() - if not self.net:connectTCP(Net.defaultRelayAddress()) then - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + local session, detail = openSession("guest") + if not session then + self:exitWith(Strings("Link error:\n%s", detail)) return end + self.net = session local size, minL, maxL = partyStats(self.game.save.party) self.isCreator = false self.participating = true -- joining is always to compete; only hosting can opt out @@ -256,22 +269,14 @@ function Tournament:sendHello(mode) end function Tournament:pollHello() - local msgs = self.net:poll() - local keep, got = {}, false - for _, msg in ipairs(msgs) do - if msg.type == "hello" and not self.peerHello then - self.peerHello = msg - got = true - else - keep[#keep + 1] = msg - end - end - for i = #keep, 1, -1 do - table.insert(self.net.inbox, 1, keep[i]) - end - return got + local message + if not self.peerHello then message = self.net:take("hello") end + if message then self.peerHello = message end + return message ~= nil end +-- The relay assigns a side for each match; this can differ from the immutable +-- tournament creator/joiner role held by Session. function Tournament:enterMatch(msg) self.isHost = (msg.role == "host") self.opponentName = msg.opponent @@ -356,12 +361,13 @@ function Tournament:update(dt) if self.net then self.net:update() - if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" + local status = self.net:getStatus() + if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings" and self.stage ~= "codeEntry" then - self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) + self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60))) return end - if self.net.closed and self.stage ~= "done" then + if status == "closed" and self.stage ~= "done" then self:exitWith(Strings("The tournament\nconnection was\nlost.")) return end @@ -370,24 +376,23 @@ function Tournament:update(dt) if self.stage == "matchHello" then if input:wasPressed("b") then self:exitWith(nil) return end self:pollHello() - if self.peerHello then self:beginMatchBattle() end - for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end + if self.peerHello then + self:beginMatchBattle() + return + end + for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end return elseif self.stage == "matchWaitParty" then if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "party" then - self.pendingBattleOpts.theirParty = msg.mons + while self.net:hasPending() do + local message = self.net:pollOne() + if message.type == "party" then + self.pendingBattleOpts.theirParty = message.mons if self.isHost then self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed else - self.pendingBattleOpts.seed = msg.seed + self.pendingBattleOpts.seed = message.seed end - -- Split rather than `cond and newHost() or newGuest()`: the and/or - -- idiom truncates a call to its first result, so the second return - -- (the specific reason) was always dropped and every failure showed - -- the generic fallback instead of "same mods on both games" etc. local battle, why if self.isHost then battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) @@ -398,27 +403,22 @@ function Tournament:update(dt) self:exitWith(why or Strings("Link battle\ncan't start.")) return end - -- anything after `party` in this same batch belongs to the - -- battle now, not to Tournament -- put it back for its own poll() - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end self.activeBattle = battle self.game.stack:push(battle) self.stage = "matchRunning" return else - self:handleMessage(msg) + self:handleMessage(message) end end return elseif self.stage == "spectateWait" then if input:wasPressed("b") then self:exitWith(nil) return end - local msgs = self.net:poll() - for i, msg in ipairs(msgs) do - if msg.type == "spectate" and msg.msg.type == "party" then - local inner = msg.msg - if msg.side == "host" then + while self.net:hasPending() do + local message = self.net:pollOne() + if message.type == "spectate" and message.msg.type == "party" then + local inner = message.msg + if message.side == "host" then self.spectate.hostParty = inner.mons self.spectate.seed = inner.seed else @@ -426,8 +426,10 @@ function Tournament:update(dt) end if self.spectate.hostParty and self.spectate.guestParty then local battle, why = LinkBattle.newSpectator(self.game, self.net, { - hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty, - hostName = self.spectate.hostName, guestName = self.spectate.guestName, + hostParty = self.spectate.hostParty, + guestParty = self.spectate.guestParty, + hostName = self.spectate.hostName, + guestName = self.spectate.guestName, seed = self.spectate.seed, forceLevel = levelForWire(self.settings.forceLevel), }) @@ -435,16 +437,13 @@ function Tournament:update(dt) self:exitWith(why or Strings("Can't watch this\nmatch.")) return end - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end self.activeBattle = battle self.game.stack:push(battle) self.stage = "spectateRunning" return end else - self:handleMessage(msg) + self:handleMessage(message) end end return diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index aa6b803f..fa6f6a34 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -569,6 +569,9 @@ end function Loader:_api(mod) local loader = self local modId = mod.manifest.id + local Storage = engineRequire("src.mods.Storage") + local storage = Storage and Storage.new(modId, loader.fs) + local Checkpoint = engineRequire("src.core.Checkpoint") local api = { id = modId, version = mod.manifest.version, @@ -658,6 +661,25 @@ function Loader:_api(mod) bucket[key] = value end, }, + -- Data-only state independent of the vanilla progress checkpoint. The + -- engine binds version/playthrough/mod scope and portable persistence; + -- callers never receive paths or a raw filesystem handle. + storage = { + context = function(_, game) return storage:context(game) end, + write = function(_, game, key, value) return storage:write(game, key, value) end, + read = function(_, game, key) return storage:read(game, key) end, + list = function(_, game, prefix) return storage:list(game, prefix) end, + delete = function(_, game, key) return storage:delete(game, key) end, + }, + -- Runtime safety and reconstruction stay engine-owned. Checkpoints contain + -- data only; no controller, stack, coroutine or renderer object crosses out. + checkpoints = { + inspect = function(_, game) return Checkpoint.inspect(game) end, + capture = function(_, game) return Checkpoint.capture(game) end, + restore = function(_, game, checkpoint) + return Checkpoint.restore(game, checkpoint) + end, + }, options = { define = function(_, schema) assert(type(schema) == "table", "options schema must be a table of rows") @@ -929,6 +951,16 @@ function Loader:load(data) end end end + -- A manifest may name an env var that force-enables it regardless of a + -- saved disable in options.mods -- generic, not tied to any mod id, for + -- a mod (e.g. a native-launcher bridge) that cannot function disabled on + -- the one build where its env var is set. + for id, mod in pairs(self.mods) do + local envName = mod.manifest.force_enable_env + if envName and os.getenv(envName) == "1" then + self.disabled[id] = nil + end + end for id, mod in pairs(self.mods) do mod.enabled = not self.disabled[id] mod.state = mod.enabled and "pending" or "disabled" diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 97bed0ac..9d1378a6 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -229,6 +229,7 @@ function Manifest.validate(raw, path) permissionSet = permissionSet, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), + force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"), path = path, raw = raw, } diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index c1e145fa..3988b9a9 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -191,8 +191,10 @@ end -- Never throws: a truncated download, an HTML error page, or a feed from a -- future schema all come back as a message the panel can print. function ModIndex.parse(jsonText, Json) + Json = Json or require("src.link.Json") + local notJson = Json.describeUnexpected(jsonText) + if notJson then return nil, notJson end local ok, result, err = pcall(function() - Json = Json or require("src.link.Json") local doc, decodeErr = Json.decode(jsonText) if type(doc) ~= "table" then return nil, decodeErr or "index.json is not an object" diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index 8087b642..4768bf70 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -140,8 +140,10 @@ end -- Decode a releases array (GET /repos/.../releases) into a sorted list -- (newest first). Releases without a .zip asset are dropped. Never throws. function ModUpdate.parseReleases(jsonText, modId, Json) + Json = Json or require("src.link.Json") + local notJson = Json.describeUnexpected(jsonText) + if notJson then return nil, notJson end local ok, result, err = pcall(function() - Json = Json or require("src.link.Json") local doc, decodeErr = Json.decode(jsonText) if type(doc) ~= "table" then return nil, decodeErr or "releases json is not an array" diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 7ccac8a9..3c033bbe 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -577,6 +577,9 @@ R.trainers = { aiMods = f.opt(f.any), aiClass = f.opt(f.id("ai_classes")), brain = f.opt(f.fn), + -- Per-trainer battle theme (an audio.songs id): overrides the + -- kind-based default (wild/trainer/gym/final) for this trainer's + -- battles. The victory jingle stays kind-based. battleTheme = f.opt(f.id("music")), }, example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })', @@ -589,6 +592,13 @@ R.sprites = { image = f.path, frames = f.int(1), walker = f.opt(f.bool), + -- Optional sheet geometry for mod actors. Defaults match the vanilla + -- 16x16 grounded walker; anchors are measured from each frame's + -- top-left in pixels (default: bottom-center). + frameWidth = f.opt(f.int(1)), + frameHeight = f.opt(f.int(1)), + anchorX = f.opt(f.num), + anchorY = f.opt(f.num), trueColor = f.opt(f.bool), -- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ -- palette assignment without claiming that the image itself came from diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua new file mode 100644 index 00000000..e529106c --- /dev/null +++ b/src/mods/Storage.lua @@ -0,0 +1,220 @@ +-- Data-only per-mod persistence, scoped by game version and opaque playthrough. +-- This module is engine-private; Loader exposes only the bound facade methods. + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local Version = require("src.core.Version") + +local Storage = {} +Storage.__index = Storage + +local ROOT = "mod_storage" + +local function failure(code, message) + return nil, code, message +end + +local function validSegment(value) + return type(value) == "string" and value ~= "" + and value:match("^[%w_-]+$") ~= nil +end + +local function validKey(key, allowEmpty) + if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end + if key == "" then return true end + if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then + return false + end + for segment in key:gmatch("[^/]+") do + if not validSegment(segment) then return false end + end + return true +end + +local function ensureParent(fs, path) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then fs.createDirectory(dir) end +end + +local function remove(fs, path) + if fs.remove then fs.remove(path) end +end + +local function decodeAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then return nil end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then return nil end + local data = SaveSerializer.decode(body) + if not data then return nil end + return data, body +end + +function Storage.new(modId, fs) + assert(validSegment(modId), "Storage.new needs a safe mod id") + return setmetatable({ modId = modId, injectedFs = fs }, Storage) +end + +function Storage:_scope(game) + local save = game and game.save + local meta = save and save.meta + local version = save and save.version + if not (save and validSegment(version)) then + return failure("not_in_playthrough", + "Storage is available only inside an identified playthrough.") + end + local playthroughId = meta and meta.playthroughId + if not validSegment(playthroughId) then + playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs) + end + if not validSegment(playthroughId) then + return failure("not_in_playthrough", + "Storage could not identify the active playthrough.") + end + local fs = SaveData.persistenceFs(self.injectedFs) + if not (fs and fs.read and fs.write and fs.getInfo) then + return failure("storage_unavailable", "The persistence backend is unavailable.") + end + local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") + return { gameVersion = version, playthroughId = playthroughId, + base = base, fs = fs } +end + +function Storage:context(game) + local scope, code, message = self:_scope(game) + if not scope then return nil, code, message end + return { + engineVersion = Version.engine, + gameVersion = scope.gameVersion, + playthroughId = scope.playthroughId, + } +end + +function Storage:_names(game, key, allowEmpty) + if not validKey(key, allowEmpty) then + return failure("invalid_key", + "Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") + end + local scope, code, message = self:_scope(game) + if not scope then return nil, code, message end + local path = scope.base .. (key ~= "" and ("/" .. key) or "") + return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" +end + +function Storage:write(game, key, value) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return false, main, bak end + if type(value) ~= "table" then + return false, "encode_failed", "Storage values must be data-only tables." + end + local encodedOk, encoded = pcall(SaveSerializer.encode, value) + if not encodedOk then + return false, "encode_failed", "Storage value is not serializable data: " + .. tostring(encoded) + end + + local fs = scope.fs + ensureParent(fs, main) + local _, previous = decodeAt(fs, main) + if not previous then _, previous = decodeAt(fs, bak) end + + local ok, err = fs.write(tmp, encoded) + if not ok then + return false, "write_failed", "Could not stage storage data: " .. tostring(err) + end + local staged = decodeAt(fs, tmp) + if not staged then + remove(fs, tmp) + return false, "verify_failed", "Staged storage data could not be verified." + end + + if previous then fs.write(bak, previous) end + ok, err = fs.write(main, encoded) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace storage data: " .. tostring(err) + end + local verified = decodeAt(fs, main) + if not verified then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement storage data could not be verified." + end + + -- At rest both main and backup hold the newest verified record. If a later + -- write dies after rolling this copy aside, one verified generation remains. + fs.write(bak, encoded) + remove(fs, tmp) + return true +end + +function Storage:read(game, key) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return nil, main, bak end + local fs = scope.fs + local data, body = decodeAt(fs, main) + if data then return data end + + data, body = decodeAt(fs, tmp) + if not data then data, body = decodeAt(fs, bak) end + if not data then + return nil, "not_found", "No valid stored value exists for this key." + end + + -- Best-effort healing. The recovered copy remains in tmp/bak if promotion + -- cannot land, so returning it is still safe and the next read can retry. + ensureParent(fs, main) + if fs.write(main, body) then fs.write(bak, body) end + remove(fs, tmp) + return data +end + +function Storage:list(game, prefix) + prefix = prefix or "" + local scope, main, codeOrBak = self:_names(game, prefix, true) + if not scope then return nil, main, codeOrBak end + local fs = scope.fs + if not fs.getDirectoryItems then + return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." + end + + local base = scope.base + local start = prefix == "" and base or (base .. "/" .. prefix) + local out = {} + + local function walk(path, logical) + local info = fs.getInfo(path) + if not info then return end + if info.type == "file" then + if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end + return + end + for _, child in ipairs(fs.getDirectoryItems(path) or {}) do + local childLogical = logical == "" and child or (logical .. "/" .. child) + walk(path .. "/" .. child, childLogical) + end + end + + -- A prefix may identify one exact key or a directory of keys. + if fs.getInfo(start .. ".lua") then + out[#out + 1] = prefix + else + walk(start, prefix) + end + table.sort(out) + return out +end + +function Storage:delete(game, key) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return false, main, bak end + local fs = scope.fs + if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then + return false, "not_found", "No stored value exists for this key." + end + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + return true +end + +return Storage diff --git a/src/render/Font.lua b/src/render/Font.lua index 760c91b2..3b59cffb 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -137,8 +137,10 @@ function Font.load(data) -- typo'd path degrades exactly like a missing page image does above. if type(def.ttf) == "table" then local file = def.ttf.file or Font.PLAINPIXEL - local ok, obj = pcall(love.graphics.newFont, file, - def.ttf.size or Font.PLAINPIXEL_SIZE, "mono") + local size = def.ttf.size or Font.PLAINPIXEL_SIZE + -- The game renders into a pixel-exact canvas, so keep the TTF rasterizer + -- on that same 1x grid instead of inheriting the window DPI on mobile. + local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1) if ok and obj then -- nearest keeps the pixel font crisp under the integer UI scale if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index c8a6efc3..2e5fb3b5 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -5,13 +5,15 @@ local SecondScreen = {} local C = nil +local ffi = nil local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) end do - local ok, ffi = pcall(require, "ffi") + local ok + ok, ffi = pcall(require, "ffi") if not (ok and ffi) then log("ffi unavailable (not LuaJIT); second display disabled") else @@ -19,6 +21,7 @@ do int love_android_secondary_ready(); void love_android_push_secondary(const void *rgba, int w, int h); void love_android_secondary_enable(int on); + const char *love_android_poll_secondary_touch(); ]]) local okLib, lib = pcall(ffi.load, "love") if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then @@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h) end) end +-- Returns the oldest queued secondary-display event as "action,x,y", where +-- coordinates are in the submitted frame's pixel space. +function SecondScreen.pollTouch() + if not C then return nil end + local ok, event = pcall(function() + return C.love_android_poll_secondary_touch() + end) + if not ok or event == nil or event == ffi.NULL then return nil end + return ffi.string(event) +end + function SecondScreen.setEnabled(on) if not C then return end pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 3f39f1d9..36ebb36e 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -1,7 +1,8 @@ --- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16 --- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm). +-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds +-- 6 16x16 frames: stand down/up/left, walk down/up/left +-- (data/sprites/facings.asm). Mod records may opt into another frame size +-- and anchor; the defaults below preserve the original grounded placement. -- Right-facing frames are horizontal flips of the left frames. --- Sprites draw 4px above their cell, like the GB engine. local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") @@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 } SpriteRenderer.STAND = STAND SpriteRenderer.WALK = WALK +-- Sprite records are anchored at the point where the actor stands in the +-- world. In the vanilla renderer that point is the bottom-center of a +-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is +-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left +-- in sheet pixels and may be fractional for a sub-pixel art style. +local DEFAULT_FRAME_WIDTH = 16 +local DEFAULT_FRAME_HEIGHT = 16 +local DEFAULT_ANCHOR_X = 8 +local DEFAULT_ANCHOR_Y = 16 +local WORLD_ANCHOR_X = 8 +local WORLD_ANCHOR_Y = 12 +SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH +SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT +SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X +SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y + +local function finiteNumber(value) + if type(value) ~= "number" or value ~= value + or value == math.huge or value == -math.huge then + return nil + end + return value +end + +local function positiveInteger(value, fallback) + value = finiteNumber(value) + if value and value >= 1 then return math.floor(value) end + return fallback +end + +local function numberOr(value, fallback) + return finiteNumber(value) or fallback +end + +local function pose(self, facing, walkPhase, stepFlip) + if self.frameCount <= 1 then return 0, false end + local frame = (self.def.walker and walkPhase == 1) + and WALK[facing] or STAND[facing] + frame = frame or 0 + -- Preserve the old fallback for a short custom sheet whose pose table + -- names a frame it does not provide. + if not self.frames[frame] then frame = 0 end + local flip = false + if facing == "right" then + flip = true + elseif (facing == "down" or facing == "up") + and walkPhase == 1 and stepFlip then + flip = true + end + return frame, flip +end + -- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve -- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp) function SpriteRenderer.new(spriteDef, seed) @@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed) self.def = spriteDef self.seed = seed self.image = getImage(spriteDef.image) + self.frameCount = positiveInteger(spriteDef.frames, 1) + self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH) + self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT) + self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2) + self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight) local iw, ih = self.image:getDimensions() self.frames = {} - for f = 0, spriteDef.frames - 1 do - self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih) + for f = 0, self.frameCount - 1 do + self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight, + self.frameWidth, self.frameHeight, + iw, ih) end return self end +-- Return the sheet rectangle and top-left-relative anchor for a frame. The +-- result is a fresh table so a custom render pipeline may annotate it without +-- changing the renderer's shared definition. +function SpriteRenderer:getFrameGeometry(frame) + frame = math.floor(finiteNumber(frame) or 0) + if frame < 0 then frame = 0 end + if frame >= self.frameCount then frame = self.frameCount - 1 end + return { + frame = frame, + x = 0, + y = frame * self.frameHeight, + width = self.frameWidth, + height = self.frameHeight, + anchorX = self.anchorX, + anchorY = self.anchorY, + quad = self.frames[frame], + } +end + +-- Return the frame geometry selected by the ordinary 2D pose rules, plus the +-- horizontal mirror state that :draw applies. This is the supported hook for +-- custom render pipelines that need to draw actors with the same pose/flip. +function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip) + local frame, flip = pose(self, facing, walkPhase, stepFlip) + local geometry = self:getFrameGeometry(frame) + geometry.facing = facing + geometry.walkPhase = walkPhase + geometry.stepFlip = stepFlip + geometry.mirror = flip + return geometry +end + +-- Screen-space top-left for the actor's current world anchor. World-facing +-- effects such as fishing can use this instead of assuming a 16x16 frame. +function SpriteRenderer:getScreenOrigin(px, py, camX, camY) + local baseX = math.floor(px - camX) + WORLD_ANCHOR_X + local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y + return math.floor(baseX - self.anchorX), + math.floor(baseY - self.anchorY) +end + -- The image this sprite would draw from right now: the plain sheet, or the -- OBP-recolored bake of it. Exposed so a render pipeline can texture its -- own geometry from the very same image -- the geometry carries sheet pixel @@ -125,27 +226,32 @@ end -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- steps mirror the walk frame for up/down (GB uses OAM flip for this). -local function blitFrame(image, quad, x, y, flip, redraw) +local function blitFrame(image, quad, x, y, flip, redraw, frameWidth) + frameWidth = frameWidth or DEFAULT_FRAME_WIDTH if flip then - love.graphics.draw(image, quad, x + 16, y, 0, -1, 1) - if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end + love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1) + if redraw then + PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1) + end else love.graphics.draw(image, quad, x, y) if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end end end --- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the --- bottom tile row of the standing frames with the fishing pose art, which the --- caller then draws itself through :drawTile (Player:draw, #384) +-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim +-- overwrites that row of the standing frames with fishing pose art, which the +-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla +-- frames therefore still draw 8 rows, while taller frames keep their larger +-- body and reserve only the overlay row. function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf) - local x = math.floor(px - camX) - local y = math.floor(py - camY) - 4 + local x, y = self:getScreenOrigin(px, py, camX, camY) local image = self.image local redraw = false - -- full-color art claims its 16x16 cell out of the shade-remap pass + -- True-color sheets bypass every palette bake; the screen-space exemption + -- is recorded below once the final frame/height is known. if self.def.trueColor then - PaletteFX.markTrueColor(x, y, 16, 16) + image = self.image elseif PaletteFX.usesGbcPack() then -- RED++: the world canvas is already true-color (TileRenderer bakes -- terrain, this bakes the sprite) and the world pass runs unshaded @@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to -- being colorized by the zone IS the point (#301). image = getObpImage(self.def.image, PaletteFX.dmgObj()) end - -- single-frame sprites (item balls, fossils...) have one fixed pose; + -- Single-frame sprites (item balls, fossils...) have one fixed pose; -- still 3-frame sprites turn to face (the nurse at her machine, - -- facePlayer on STAY NPCs) but never show walk frames - if self.def.frames <= 1 then - blitFrame(image, self.frames[0], x, y, false, redraw) - return - end - local frame = (self.def.walker and walkPhase == 1) - and WALK[facing] or STAND[facing] - local flip = false - if facing == "right" then - flip = true - elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then - flip = true - end - local quad = self.frames[frame] or self.frames[0] - if topHalf then + -- facePlayer on STAY NPCs) but never show walk frames. + local frame, flip = pose(self, facing, walkPhase, stepFlip) + local quad = self.frames[frame] + local drawHeight = self.frameHeight + if topHalf and self.frameCount > 1 then self.halfFrames = self.halfFrames or {} if not self.halfFrames[frame] then local iw, ih = self.image:getDimensions() - self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih) + local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight)) + self.halfFrames[frame] = love.graphics.newQuad( + 0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih) end quad = self.halfFrames[frame] + drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight)) end - blitFrame(image, quad, x, y, flip, redraw) + -- Full-color art claims exactly the portion of the frame that was drawn. + if self.def.trueColor then + PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight) + end + blitFrame(image, quad, x, y, flip, redraw, self.frameWidth) end -- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ @@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip) self.tileQuads = self.tileQuads or {} self.tileQuads[path] = self.tileQuads[path] or love.graphics.newQuad(0, 0, iw, ih, iw, ih) - blitFrame(image, self.tileQuads[path], x, y, flip, redraw) + blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw) end return SpriteRenderer diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 43696871..290c4ecc 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -7,11 +7,13 @@ -- the text is exhausted and A is pressed, then calls onDone. local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") local TextBox = {} TextBox.__index = TextBox +TextBox.isTextBox = true -- theme-free fallbacks; geometry resolves against Theme.textBox at -- construction time, so an unthemed boot stays byte-identical @@ -344,6 +346,11 @@ function TextBox:update(dt) end function TextBox:draw() + if Runtime.wantsHook("battle.bottom_ui_visible") + and Runtime.call("battle.bottom_ui_visible", function() return true end, + self) == false then + return + end -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 6a465acf..0f754359 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -27,6 +27,7 @@ -- game regenerates all of it from wCurMap on the next map load anyway. local bit = require("bit") +local MapContext = require("src.save_convert.MapContext") local GenSave = {} @@ -1023,6 +1024,44 @@ function GenSave.encode(save, data, template) setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0) end + -- Current-map engine state (see src/save_convert/MapContext.lua). A + -- Continue restores this window from the save and never rebuilds it, so a + -- zero-filled one boots into a garbled map on a silent hang (#889). + -- + -- Rebuilt when there is no template at all (a save that began as a New Game + -- in this port), and when the template was saved on a DIFFERENT map than the + -- one the player is standing on now -- an imported save that has since been + -- played carries the old map's header, which is just as unbootable. A + -- template still on its own map keeps its bytes untouched: they are the + -- game's own, including live NPC positions, and preserving them is what + -- makes import -> export byte-identical. + local mapId = save.player and save.player.map + if mapId then + local rebuild = true + if src then + -- compare the way the byte was written (masked), and rebuild when the + -- map has no index at all rather than trusting a stale template + local index = cw.mapsIndex[mapId] + rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF) + end + if rebuild then + local ctx = MapContext.build(data, mapId, + (save.player and save.player.x) or 0, (save.player and save.player.y) or 0) + if ctx then + for offset, values in pairs(ctx.writes) do + for i, value in ipairs(values) do + setByte(buf, O.mainData + offset + i - 1, value) + end + end + for i, value in ipairs(ctx.spriteData) do + setByte(buf, O.spriteData + i - 1, value) + end + -- sTileAnimations, the byte between sCurBoxData and the checksum + setByte(buf, O.checksumEnd - 1, ctx.tileAnimations) + end + end + end + -- play time: split save.playTime (seconds) back into H/M/S/F. The real -- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so -- mirror that cap rather than letting hours overflow a single byte. diff --git a/src/save_convert/MapContext.lua b/src/save_convert/MapContext.lua new file mode 100644 index 00000000..0443a992 --- /dev/null +++ b/src/save_convert/MapContext.lua @@ -0,0 +1,263 @@ +-- MapContext -- the current map's engine state, as a vanilla Gen1 save has to +-- carry it (#889). +-- +-- Continuing a real save never re-loads the map header. LoadMainData +-- (engine/menus/save.asm) copies sMainData back into WRAM and then sets +-- BIT_NO_PREVIOUS_MAP in wCurMapTileset; LoadMapHeader (home/overworld.asm) +-- clears that bit and RETURNS IMMEDIATELY when it was set, so every byte it +-- would otherwise have written -- the map header, connections, warps, signs, +-- sprites, the tileset header, the map's music -- comes straight out of the +-- save file. A cartridge save always has them because the game saved them. +-- +-- An export from this port did not: GenSave models the player's progress, not +-- the engine scratch around it, and left that whole window zero-filled unless +-- it had an imported SRAM image to copy from. The game then continued into +-- tileset 0 with a $0000 map-data pointer and sound id 0, which is the +-- garbled overworld / silent hang users reported after exporting a save that +-- began as a New Game in the port. +-- +-- This module rebuilds the window from the extracted ROM data, replaying the +-- same writes LoadMapHeader makes, so a port-origin save boots on hardware. +-- The offsets are all relative to wMainDataStart (= sMainData in a .sav) and +-- were summed from ram/wram.asm, then checked byte-for-byte against a real +-- cartridge save: rebuilding its current map reproduces the bytes it already +-- held, everywhere except the stale tails of disabled connection slots (which +-- the engine never reads once the connected-map byte is $FF). +-- +-- Pure Lua, no love.*: shared by the runtime exporter, the CLI and the tests. + +local MapContext = {} + +-- wMainDataStart-relative offsets. Anchors: wCurMap is +103 (the offset +-- GenSave already decodes the player's map from), and the run from there is +-- wCurMap, wCurrentTileBlockMapViewPointer(2), wYCoord, wXCoord, wYBlockCoord, +-- wXBlockCoord, wLastMap, wUnusedLastMapWidth, wCurMapHeader -- so the header +-- lands at +112, and every later label follows from the declaration sizes in +-- ram/wram.asm (MAX_WARP_EVENTS 32, MAX_BG_EVENTS 16, MAX_OBJECT_EVENTS 16, +-- SPRITE_SET_LENGTH 11). +local O = { + mapMusicSoundID = 100, -- wMapMusicSoundID + mapMusicROMBank = 101, -- wMapMusicROMBank + viewPointer = 104, -- wCurrentTileBlockMapViewPointer (2) + yCoord = 106, + xCoord = 107, + yBlockCoord = 108, + xBlockCoord = 109, + curMapHeader = 112, -- wCurMapHeader (10) + connectionHeaders = 122, -- wNorth/South/West/EastConnectionHeader (4 x 11) + mapBackgroundTile = 182, + numberOfWarps = 183, + warpEntries = 184, -- MAX_WARP_EVENTS x 4 + numSigns = 441, + signCoords = 442, -- MAX_BG_EVENTS x 2 + signTextIDs = 474, -- MAX_BG_EVENTS + numSprites = 490, + mapSpriteData = 493, -- MAX_OBJECT_EVENTS x 2 + mapSpriteExtra = 525, -- MAX_OBJECT_EVENTS x 2 + currentMapHeight2 = 557, + currentMapWidth2 = 558, + tilesetHeader = 564, -- wTilesetBank .. wGrassTile (11) +} +MapContext.OFFSETS = O + +local MAX_WARP_EVENTS = 32 +local MAX_BG_EVENTS = 16 +local MAX_OBJECT_EVENTS = 16 +local CONNECTION_STRUCT = 11 -- map_connection_struct (macros/ram.asm) +local SPRITE_STRUCT = 16 -- one wSpriteStateData1/2 entry +local NUM_SPRITE_STRUCTS = 16 + +-- wOverworldMap, the tile-block map the view pointer indexes. Recovered from +-- the event_displacement formula below applied to a real save's coordinates, +-- and it is the same $C6E8 every Gen1 reference quotes. +local OVERWORLD_MAP = 0xC6E8 + +-- Audio header tables start at $4000 in each of the three audio banks, and +-- constants/music_constants.asm derives every song id arithmetically from +-- that base ("Song ids are calculated by address", music_const: +-- (Music_X - SFX_Headers_1) / 3). So the extracted header address IS the id, +-- and no separate MapSongBanks extraction is needed. +local SFX_HEADERS_BASE = 0x4000 + +local function soundId(address) + if type(address) ~= "number" then return nil end + local delta = address - SFX_HEADERS_BASE + if delta < 0 or delta % 3 ~= 0 then return nil end + local id = delta / 3 + if id > 0xFF then return nil end + return id +end + +-- LoadMapHeader parses the map's object data (data/maps/objects/*.asm) into +-- five separate WRAM arrays. Walk it exactly as the engine does; `bytes` is +-- the 1-based array of raw object-data bytes the extractor captured. +local function parseObjects(bytes) + local pos = 1 + local function take() + local b = bytes[pos] + pos = pos + 1 + return b + end + local out = { warps = {}, signCoords = {}, signTexts = {}, + spriteData = {}, spriteExtra = {}, sprites = {} } + + out.backgroundTile = take() + + local warpCount = take() + if warpCount == nil then return nil end + out.warpCount = warpCount + for _ = 1, math.min(warpCount, MAX_WARP_EVENTS) * 4 do + out.warps[#out.warps + 1] = take() + end + + local signCount = take() + if signCount == nil then return nil end + out.signCount = signCount + for _ = 1, math.min(signCount, MAX_BG_EVENTS) do + out.signCoords[#out.signCoords + 1] = take() -- Y + out.signCoords[#out.signCoords + 1] = take() -- X + out.signTexts[#out.signTexts + 1] = take() + end + + local spriteCount = take() + if spriteCount == nil then return nil end + out.spriteCount = spriteCount + for _ = 1, math.min(spriteCount, MAX_OBJECT_EVENTS) do + local picture, mapY, mapX = take(), take(), take() + local movement1, movement2, textId = take(), take(), take() + if textId == nil then return nil end + out.sprites[#out.sprites + 1] = { + picture = picture, mapY = mapY, mapX = mapX, movement1 = movement1, + } + -- wMapSpriteData: movement byte 2, then the text id with its trainer/item + -- flag bits masked off (LoadMapHeader's `and $3f`). + out.spriteData[#out.spriteData + 1] = movement2 + out.spriteData[#out.spriteData + 1] = textId % 0x40 + -- BIT_TRAINER ($40) is tested before BIT_ITEM ($80), as LoadMapHeader does + if textId % 0x80 >= 0x40 then -- trainer: class, then party id + out.spriteExtra[#out.spriteExtra + 1] = take() + out.spriteExtra[#out.spriteExtra + 1] = take() + elseif textId >= 0x80 then -- item ball: item id, second byte unused + out.spriteExtra[#out.spriteExtra + 1] = take() + out.spriteExtra[#out.spriteExtra + 1] = 0 + else + out.spriteExtra[#out.spriteExtra + 1] = 0 + out.spriteExtra[#out.spriteExtra + 1] = 0 + end + end + return out +end + +-- build(data, mapId, x, y) -> ctx, err +-- +-- ctx.writes [wMainDataStart-relative offset] = array of bytes +-- ctx.spriteData 512 bytes for sSpriteData (wSpriteStateData1 then 2) +-- ctx.tileAnimations the byte sTileAnimations holds +-- +-- Returns nil plus a reason when the data set predates the extractor fields +-- this needs (an older ROM cache), so callers can fall back rather than fail. +function MapContext.build(data, mapId, x, y) + local map = data and data.maps and data.maps[mapId] + if not map then return nil, "unknown map " .. tostring(mapId) end + local sram = map.sram + if not (sram and sram.header and sram.objects) then + return nil, "map cache has no saved-map bytes (re-import the ROM)" + end + x, y = math.floor(tonumber(x) or 0), math.floor(tonumber(y) or 0) + + local writes = {} + local header = sram.header + writes[O.curMapHeader] = header + local height, width = header[2], header[3] + local connectionFlags = header[10] + + -- Connections: LoadMapHeader writes $FF over all four connected-map bytes + -- and then copies 11 bytes per present direction, north/south/west/east. + -- The disabled slots' remaining bytes are never read. + local conn = {} + for i = 1, 4 * CONNECTION_STRUCT do conn[i] = 0 end + for slot = 0, 3 do conn[slot * CONNECTION_STRUCT + 1] = 0xFF end + local pos = 1 + for slot, flag in ipairs({ 0x08, 0x04, 0x02, 0x01 }) do + if math.floor(connectionFlags / flag) % 2 == 1 then + for i = 0, CONNECTION_STRUCT - 1 do + conn[(slot - 1) * CONNECTION_STRUCT + 1 + i] = (sram.connections or {})[pos + i] or 0 + end + pos = pos + CONNECTION_STRUCT + end + end + writes[O.connectionHeaders] = conn + + local parsed = parseObjects(sram.objects) + if not parsed then return nil, "malformed object data for " .. tostring(mapId) end + writes[O.mapBackgroundTile] = { parsed.backgroundTile } + writes[O.numberOfWarps] = { parsed.warpCount } + writes[O.warpEntries] = parsed.warps + writes[O.numSigns] = { parsed.signCount } + writes[O.signCoords] = parsed.signCoords + writes[O.signTextIDs] = parsed.signTexts + writes[O.numSprites] = { parsed.spriteCount } + writes[O.mapSpriteData] = parsed.spriteData + writes[O.mapSpriteExtra] = parsed.spriteExtra + + -- "map height/width in 2x2 tile blocks", doubled at the end of LoadMapHeader + writes[O.currentMapHeight2] = { (height * 2) % 256 } + writes[O.currentMapWidth2] = { (width * 2) % 256 } + + -- The tileset header, as predef LoadTilesetHeader would have copied it. + local tilesets = data.tilesets or {} + local tilesetDef = tilesets[map.tileset] + local tileAnimations = 0 + if tilesetDef and tilesetDef.header then + local row = {} + for i = 1, 11 do row[i] = tilesetDef.header[i] end + writes[O.tilesetHeader] = row + tileAnimations = tilesetDef.header[12] or 0 + end + + -- MapSongBanks: without it the game continues with sound id 0 and audio + -- bank 0, which is what actually hangs a Continue on a white screen. + local audio = data.audio + local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId] + local song = songLabel and audio.songs and audio.songs[songLabel] + local id = song and soundId(song.address) + if id and song.bank then + writes[O.mapMusicSoundID] = { id } + writes[O.mapMusicROMBank] = { song.bank % 256 } + end + + -- Player position within its block, and the upper-left corner of the view. + -- The pointer is the same expression the warp_to tables are assembled with + -- (macros/coords.asm event_displacement). + writes[O.yBlockCoord] = { y % 2 } + writes[O.xBlockCoord] = { x % 2 } + local view = OVERWORLD_MAP + 7 + width + + (width + 6) * math.floor(y / 2) + math.floor(x / 2) + writes[O.viewPointer] = { view % 256, math.floor(view / 256) % 256 } + + -- sSpriteData. LoadMapHeader zeroes structs 1-15, sets their image index to + -- $ff (off screen until the engine places them), then fills in each map + -- object's picture id and its map Y/X plus movement byte 1. Struct 0 is the + -- player, which SpecialEnterMap rebuilds through ResetPlayerSpriteData. + local spriteData = {} + for i = 1, NUM_SPRITE_STRUCTS * SPRITE_STRUCT * 2 do spriteData[i] = 0 end + local data2 = NUM_SPRITE_STRUCTS * SPRITE_STRUCT + for slot = 1, NUM_SPRITE_STRUCTS - 1 do + spriteData[slot * SPRITE_STRUCT + 2 + 1] = 0xFF + end + for index, sprite in ipairs(parsed.sprites) do + local base = index * SPRITE_STRUCT + spriteData[base + 1] = sprite.picture + spriteData[data2 + base + 4 + 1] = sprite.mapY + spriteData[data2 + base + 5 + 1] = sprite.mapX + spriteData[data2 + base + 6 + 1] = sprite.movement1 + end + + return { + writes = writes, + spriteData = spriteData, + tileAnimations = tileAnimations, + } +end + +return MapContext diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index b4dc9d34..3df6122f 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -42,11 +42,17 @@ local DATA_MODULES = { moves = { "data.generated.moves", "data/generated/moves.lua" }, items = { "data.generated.items", "data/generated/items.lua" }, maps = { "data.generated.maps", "data/generated/maps.lua" }, + -- tilesets/audio are only read by src/save_convert/MapContext.lua, to + -- rebuild the current map's engine state on export (#889) + tilesets = { "data.generated.tilesets", "data/generated/tilesets.lua" }, + audio = { "data.generated.audio", "data/generated/audio.lua" }, charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" }, } +local OPTIONAL_MODULES = { tilesets = true, audio = true } + -- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm -- inserts events pokered does not have (the Jessie & James fights, catch -- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co @@ -132,7 +138,10 @@ local function ensureData(gameVersion) if not mod then local e mod, e = loadTable(spec[1], spec[2]) - if not mod then return nil, e end + -- tilesets/audio only sharpen the export (MapContext); a cache + -- without them still imports and exports, just without the + -- rebuilt map window, so they must not fail the whole load + if not mod and not OPTIONAL_MODULES[name] then return nil, e end end data[name] = mod end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 650b364d..1a2c59ee 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) return end + if result == "kept" then + if battle then + list:close() + showMessages(game, payload, function() + battle:itemUsed({}) + end) + else + showMessages(game, payload, closePicker) + end + return + end + if result == "consumed" then consume(game, id) -- refresh counts in the list @@ -404,7 +416,7 @@ local function useItem(game, battle, id, list) showMessages(game, payload) return end - if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then + if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then -- TMs/HMs boot up and announce their move before the target picker -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) if def and def.machine then diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 332e90ef..7fed06b7 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -67,6 +67,7 @@ local function withdraw(game) game.stack:push(ListMenu.new(game, Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_withdraw", onChoose = function(item, list) local mon = box[item.value] if not mon then return end @@ -114,6 +115,7 @@ local function deposit(game) end game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_deposit", onChoose = function(item, list) local mon = game.save.party[item.value] if not mon then return end @@ -160,6 +162,7 @@ local function release(game) game.stack:push(ListMenu.new(game, Strings("BOX %d (RELEASE)", game.save.currentBox), items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_release", onChoose = function(_, list) local mon = box[list.index] if not mon then return end @@ -193,6 +196,7 @@ local function changeBox(game) end game.stack:push(ListMenu.new(game, "CHANGE BOX", items, { noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) + kind = "pc_box_change", onChoose = function(item, list) -- the original asks BEFORE switching ("When you change a #MON -- BOX, data will be saved. OK?"); declining aborts the change diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 580381f0..b12db5fb 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -31,6 +31,7 @@ -- to just THE END. local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local Music = require("src.core.Music") local Strings = require("src.core.Strings") @@ -64,11 +65,12 @@ local MON_PREP_FRAMES = 9 -- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile -- sequences into the extracted title/copyright.png strip (tiles $60-$72: --- (c)'95.'96.'98 + Nintendo + Creatures inc.); the GAME FREAK inc. row is --- the title/gamefreak_inc.png strip (GameFreakLogoGraphics, tiles --- $73-$7B), with the intro's composed gamefreak_text.png as a fallback --- for pre-regeneration data. -local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98 +-- Red/Blue (c)'95.'96.'98, Yellow (c)1995-1999 + NineTile) + Nintendo + +-- Creatures inc.; the GAME FREAK inc. row is title/gamefreak_inc.png +-- (GameFreakLogoGraphics, tiles $73-$7B), with the intro's composed +-- gamefreak_text.png as a fallback for pre-regeneration data. +local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 } -- (c)'95.'96.'98 +local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 } -- (c)1995-199 local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } -- Nintendo local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } -- Creatures inc. @@ -140,6 +142,12 @@ function Credits.new(game, onDone, onTheEnd) and title.gamefreakInc.path) or tryImage(intro and intro.gamefreakText and intro.gamefreakText.path) + self.yellowCopy = GameVersion.isYellow() + or (title and title.layout == "yellow_pikachu") + self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB + self.nineImg = self.yellowCopy and tryImage( + title and title.nine and title.nine.path + or "assets/generated/title/nine.png") or nil return self end @@ -255,6 +263,17 @@ function Credits:drawPage(screen, xoff, shade) if screen.copyright then self:drawCopyright(xoff) end end +function Credits:drawCopyPrefix(x, y) + local img = self.copyImg + for _, t in ipairs(self.copyPrefix) do + love.graphics.draw(img, self.copyQuads[t], x, y) + x = x + 8 + end + if self.nineImg then + love.graphics.draw(self.nineImg, x, y) + end +end + function Credits:drawCopyright(xoff) local img = self.copyImg if img then @@ -267,11 +286,11 @@ function Credits:drawCopyright(xoff) end return x end - row(COPY_PREFIX, xoff + 16, 56) + self:drawCopyPrefix(xoff + 16, 56) row(COPY_NINTENDO, xoff + 80, 56) - row(COPY_PREFIX, xoff + 16, 72) + self:drawCopyPrefix(xoff + 16, 72) row(COPY_CREATURES, xoff + 80, 72) - row(COPY_PREFIX, xoff + 16, 88) + self:drawCopyPrefix(xoff + 16, 88) if self.gfImg then love.graphics.draw(self.gfImg, xoff + 80, 88) else diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index b59e6ee1..f5f1a09d 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -94,7 +94,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor) -- same number width as the list (constants.dexDigits), so a dex past 999 -- prints the extra digit everywhere at once local digits = (game.data.constants or {}).dexDigits or 3 - Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32) + Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32) local owned = forceOwned or (game.save.pokedex and game.save.pokedex.owned[def.id]) -- height/weight print only once owned, like the description @@ -105,8 +105,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor) -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via -- engine/gfx/load_pokedex_tiles.asm) if e.heightM then - Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44) - Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54) + Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44) + Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54) else Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44) Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54) diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 15f14be2..a7a0e197 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -1,31 +1,8 @@ --- Boot splash + attract movie, a faithful port of PlayIntro --- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm) --- using the real extracted art (data/generated/field.lua `intro` manifest). --- --- Three frame-counted phases: --- 1. copyright card, 180 frames (intro.asm:311-312). --- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then --- the big star streaks down-left for 40 frames while the studio logo --- sits centered in the letterbox band (the GAME FREAK logo + letter --- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228), --- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small --- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower --- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold --- (intro.asm:329-331). --- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played --- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG --- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left --- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted --- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap, --- retreat, crouch and final lunge, ending in a 24-frame fade to white --- (GBFadeOutToWhite, home/fade.asm:26-40). --- --- Any of A/B/START skips the whole movie (CheckForUserInterruption). --- Pops itself and calls onDone() when finished or skipped. All art loads --- through pcall and every missing graphic degrades to a text/rect --- fallback, so the movie stays headless-safe. +-- ..(engine/movie/intro.asm ln 8) +-- ..(engine/movie/splash.asm ln 27) local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") local Music = require("src.core.Music") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") @@ -76,15 +53,20 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209) local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331) --- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15 --- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets --- cancel) +-- ..(engine/movie/splash.asm ln 211) local LOGO_X, LOGO_Y = 72, 56 +local TEXT_X, TEXT_Y = 40, 80 + +-- CopyrightTextString (engine/movie/title.asm). Red/Blue years are +-- (c)'95.'96.'98; Yellow's sheet spells (c)1995-1999 and finishes the +-- last digit with NineTile (title screen). Intro originally overflows +-- into the font "A" tile for that digit; we draw NineTile so both +-- screens read 1999. +local COPY_PREFIX_RB = { 0, 1, 2, 1, 3, 1, 4 } +local COPY_PREFIX_YELLOW = { 0, 1, 2, 3, 1, 2 } +local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } +local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } --- The studio logo (assets/logo/minilogo.png) stands in for both the --- GAME FREAK logo and its letter row, so it gets the whole band between --- the letterbox bars (y 32..112) down to where the star waves spawn --- (y=88): fit it inside this box, centered, aspect preserved. local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 } -- the 4 waves of small stars: screen X positions, all spawning at y=88 @@ -155,10 +137,25 @@ function IntroMovie.new(game, onDone) self.studio = intro.studio or {} self.skipAll = intro.skip and true or false local function img(e) return tryImage(e and e.path) end - self.copyright = tryImage("assets/generated/title/copyright.png") - -- studio mark replaces the GAME FREAK logo + splash text entirely; the - -- extracted logo stays as the fallback if the asset is missing - self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png") + local titleCfg = game.data.field and game.data.field.title or {} + self.copyright = img(titleCfg.copyright) + or tryImage("assets/generated/title/copyright.png") + self.copyQuads = {} + if self.copyright then + local iw, ih = self.copyright:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + self.gfInc = img(titleCfg.gamefreakInc) + or tryImage("assets/generated/title/gamefreak_inc.png") + -- ..(pokeyellow engine/movie/title.asm CopyrightTextString / NineTile) + self.yellowCopy = GameVersion.isYellow() + or titleCfg.layout == "yellow_pikachu" + self.copyPrefix = self.yellowCopy and COPY_PREFIX_YELLOW or COPY_PREFIX_RB + self.nineImg = self.yellowCopy and ( + img(titleCfg.nine) or tryImage("assets/generated/title/nine.png")) or nil + self.studioLogo = tryImage(self.studio.logo) if self.studioLogo then self.studioLogo:setFilter("nearest", "nearest") local iw, ih = self.studioLogo:getDimensions() @@ -306,24 +303,12 @@ function IntroMovie:drawSplash() if self.studioLogo then love.graphics.draw(self.studioLogo, self.studioX, self.studioY, 0, self.studioScale, self.studioScale) - elseif self.logo then - love.graphics.draw(self.logo, LOGO_X, LOGO_Y) + else + if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end + if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end end love.graphics.setColor(1, 1, 1, 1) end - if t >= STAR_START and t < FLASH_START then - -- big star: from OAM (160,0) moving +4Y/-4X per frame - -- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60) - local n = t - STAR_START + 1 - local sx, sy = 152 - 4 * n, -16 + 4 * n - if self.bigStar then - love.graphics.draw(self.bigStar, sx, sy) - else - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) - love.graphics.setColor(1, 1, 1, 1) - end - end if t >= WAVES_START then -- small stars: wave w spawns at y=88 every 24 frames, everything falls -- +1px per 3-frame substep until the wave loop ends; the lower star in @@ -351,6 +336,18 @@ function IntroMovie:drawSplash() end end drawBars() + if t >= STAR_START and t < FLASH_START then + -- ..(engine/movie/splash.asm ln 32) + local n = t - STAR_START + 1 + local sx, sy = 152 - 4 * n, -16 + 4 * n + if self.bigStar then + love.graphics.draw(self.bigStar, sx, sy) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end + end end function IntroMovie:drawFight() @@ -379,17 +376,48 @@ function IntroMovie:drawFight() end end +function IntroMovie:drawCopyPrefix(x, y) + for _, t in ipairs(self.copyPrefix) do + love.graphics.draw(self.copyright, self.copyQuads[t], x, y) + x = x + 8 + end + if self.nineImg then + love.graphics.draw(self.nineImg, x, y) + end +end + +function IntroMovie:drawCopyright() + if self.studio.card or self.studio.credit then + local card = self.studio.card or "" + local credit = self.studio.credit or "" + love.graphics.setColor(0, 0, 0, 1) + Font.draw(card, (160 - #card * 8) / 2, 64) + Font.draw(credit, (160 - #credit * 8) / 2, 80) + elseif self.copyright and self.gfInc then + local function row(seq, x, y) + for _, t in ipairs(seq) do + love.graphics.draw(self.copyright, self.copyQuads[t], x, y) + x = x + 8 + end + end + for _, y in ipairs({ 56, 72, 88 }) do self:drawCopyPrefix(16, y) end + row(COPY_NINTENDO, 80, 56) + row(COPY_CREATURES, 80, 72) + love.graphics.draw(self.gfInc, 80, 88) + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("Nintendo"), 80, 56) + Font.draw(Strings("Creatures inc."), 80, 72) + Font.draw(Strings("GAME FREAK inc."), 16, 88) + end + love.graphics.setColor(1, 1, 1, 1) +end + function IntroMovie:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) if self.phase == 1 then - -- custom boot card (replaces the Nintendo / GAME FREAK copyright - -- card; no (c) glyph in the charmap, keep it ASCII-safe) - love.graphics.setColor(0, 0, 0, 1) - local credit = self.studio.credit or Strings("bois club") - Font.draw("2026", (160 - 4 * 8) / 2, 48) - Font.draw(credit, (160 - #credit * 8) / 2, 64) - Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) + self:drawCopyright() elseif self.phase == 2 then self:drawSplash() else diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 5c1f0eaa..b3856a0c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts) local self = setmetatable({}, ListMenu) self.game = game self.title = title + self.kind = opts.kind or title self.items = items self.index = 1 self.scroll = 0 diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 9803e12f..8c5c08b5 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -265,7 +265,8 @@ function OakSpeech.new(game, onDone) or "assets/generated/intro/shrink2.png") -- RedSprite: the walking sprite the pic shrinks into (frame 0 = -- standing, facing down) - local red = game.data.sprites and game.data.sprites.SPRITE_RED + local playerSprites = (game.data.field and game.data.field.playerSprites) or {} + local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED self.walkSheet = tryImage(red and red.image) return self end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 1187dc6c..cca24cd9 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -392,14 +392,35 @@ local function buildRows(game) return true end }, -- fast-forward the logic clock only; music and sfx keep their tempo - -- (src/core/GameSpeed.lua), so this is safe to leave on - { id = "speed", label = Strings("GAME SPEED"), + -- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category + -- (RFC 0007): overworld walking, battle turns and menu navigation each + -- cycle their own multiplier -- GameSpeed.CATEGORIES is the single + -- source of truth for which three rows exist. + { id = "speedOverworld", label = Strings("OVERWORLD SPEED"), value = function(g) - return GameSpeed.levelLabel(g.save.options.speed) + return GameSpeed.levelLabel(g.save.options.speedOverworld) end, step = function(g, dir) local o = g.save.options - o.speed = GameSpeed.cycle(o.speed, dir) + o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir) + return true + end }, + { id = "speedBattle", label = Strings("BATTLE SPEED"), + value = function(g) + return GameSpeed.levelLabel(g.save.options.speedBattle) + end, + step = function(g, dir) + local o = g.save.options + o.speedBattle = GameSpeed.cycle(o.speedBattle, dir) + return true + end }, + { id = "speedMenu", label = Strings("MENU SPEED"), + value = function(g) + return GameSpeed.levelLabel(g.save.options.speedMenu) + end, + step = function(g, dir) + local o = g.save.options + o.speedMenu = GameSpeed.cycle(o.speedMenu, dir) return true end }, -- the manager's discoverable home (18-mod-manager-ux); inert until @@ -585,8 +606,14 @@ function OptionsMenu:update(dt) end function OptionsMenu:draw() + -- Through Strings, like every other label on this menu. CANCEL is + -- appended AFTER the rows hook (see the header), which is what keeps a mod + -- from orphaning the exit -- but it also means a translation mod never sees + -- this string, and cannot: there is no row for it to rewrite. So the one + -- word a Spanish player could not read on a fully translated OPTIONS menu + -- was the way out of it. OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, - "CANCEL", #self.rows + 1) + Strings("CANCEL"), #self.rows + 1) end return OptionsMenu diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 5e31ca39..09b9e60b 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -44,7 +44,7 @@ local function askQuantity(game, list, count, id, cb) cb(1) return end - list.footer = "How many?" + list.footer = Strings("How many?") local QuantityBox = require("src.ui.QuantityBox") game.stack:push(QuantityBox.new(game, { max = count, @@ -72,6 +72,7 @@ end local function withdraw(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { + kind = "pc_item_withdraw", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) @@ -110,6 +111,7 @@ local function deposit(game) if not Bag.isBadge(id) then depositable[id] = count end end game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { + kind = "pc_item_deposit", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) @@ -131,6 +133,7 @@ end local function toss(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { + kind = "pc_item_toss", messageBox = true, noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570) onChoose = function(item, list) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index f7d3ffe5..f628c1db 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -103,11 +103,40 @@ local YELLOW_CYCLE_SPECIES = { "JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA", "GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE", } -local CYCLE_FRAMES = 240 -- the original waits ~4s between picks +-- ..(engine/movie/title.asm ln 227) +local HOLD_FRAMES = 200 +local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true } + +-- ..(engine/movie/title2.asm ln 13) +local function scrollFrames(steps, offset) + local frames = {} + for _, step in ipairs(steps) do + for _ = 1, step[2] do + frames[#frames + 1] = offset + offset = offset - step[1] + end + end + return frames +end +local OUT_FRAMES = scrollFrames( + { { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 }, + { 8, 3 }, { 9, 3 } }, 0) +local IN_FRAMES = scrollFrames( + { { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 }, + { 1, 1 } }, 120) + +-- ..(engine/movie/title2.asm ln 85) +local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 } +local BALL_REST = 100 local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, path) + -- resolve through Assets so a mod's derived art (save/mod-derived/) + -- wins here the way it does for every other generated sheet -- but + -- load uncached, because on NX the per-version overlay redirects the + -- open itself and a cached image would leak across Yellow/Blue boots + local ok, img = pcall(love.graphics.newImage, + require("src.render.Assets").resolve(path)) return ok and img or nil end @@ -151,16 +180,57 @@ function TitleState.new(game, opts) -- branding comes from field.title with the shipped art as fallback, so -- a total conversion rebrands the title without replacing the screen local title = (game.data.field and game.data.field.title) or {} + -- field.title itself is extraction data the field schema never exposes; + -- boot.title is the mod-reachable half of the same seam, so its keys + -- override here (a localized ribbon, a rebranded logo) + local boot = game.data.field and game.data.field.boot + if boot and type(boot.title) == "table" then + local merged = {} + for key, value in pairs(title) do merged[key] = value end + for key, value in pairs(boot.title) do merged[key] = value end + title = merged + end self.title = title self.logo = tryImage(imagePath(title.logo) or "assets/logo/pokemon_logo.png") - -- versionRibbon is the file-12 key; version is the importer's + -- versionRibbon is the file-12 key; version is the importer's. The + -- vanilla sheet is two fragments the draw pass repositions, so an + -- explicit ribbon (a conversion's or a translation's continuous art) + -- draws whole instead. + self.versionFull = imagePath(title.versionRibbon) ~= nil self.version = tryImage(imagePath(title.versionRibbon or title.version) or "assets/generated/title/red_version.png") - self.player = tryImage("assets/generated/title/player.png") + self.player = tryImage(imagePath(title.player) + or "assets/generated/title/player.png") + -- ..(engine/movie/title2.asm ln 85) + if self.player then + local pw, ph = self.player:getDimensions() + self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph) + self.playerQuads = { + { love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 }, + { love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 }, + { love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 }, + } + end + self.copyImg = tryImage(imagePath(title.copyright) + or "assets/generated/title/copyright.png") + self.copyQuads = {} + if self.copyImg then + local iw, ih = self.copyImg:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + self.gfInc = tryImage(imagePath(title.gamefreakInc) + or "assets/generated/title/gamefreak_inc.png") self.blue = GameVersion.isBlue() self.yellow = GameVersion.isYellow() or title.layout == "yellow_pikachu" + -- ..(pokeyellow engine/movie/title.asm .tileScreenCopyrightTiles / NineTile) + self.nineImg = self.yellow and tryImage(imagePath(title.nine) + or "assets/generated/title/nine.png") or nil + self.copyPrefix = self.yellow + and { 0, 1, 2, 3, 1, 2 } or { 0, 1, 2, 1, 3, 1, 4 } -- Yellow title is a fixed Pikachu composition (title_yellow.asm), not -- TitleMons cycling. Prefer composed pikachu.png from the Yellow import. self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu) @@ -183,7 +253,10 @@ function TitleState.new(game, opts) self.blinkTimer = 0 self.blinkAt = nil else - self.phase = "loop" + -- ..(engine/movie/title.asm ln 28) + self.scy = 0x40 + self.phase = "drop" + self.dropStep, self.dropLeft = 1, nil self.showBubble = true end local defaultCycle = self.yellowLayout and { "PIKACHU" } @@ -196,14 +269,15 @@ function TitleState.new(game, opts) self.cycleIndex = 1 self.timer = 0 self.blink = 0 + self.scrollPhase = "hold" + self.scrollFrame = 1 + self.monOffset = 0 + self.ballY = BALL_REST return self end function TitleState:enter() - -- Yellow defers the title theme until after the logo drop and - -- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after - -- WaitForSoundToFinish on PikachuCry1) - if self.yellowLayout then return end + if self.phase ~= "loop" then return end self:startMusic() end @@ -220,6 +294,11 @@ end local DROP_STEPS = { { -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 }, } +local SETTLE_FRAMES = 36 + +-- ..(engine/movie/title.asm ln 201) +local RIBBON_FRAMES = {} +for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end -- the boot cinematic up to the interactive loop; one call per frame function TitleState:updateSequence() @@ -243,12 +322,23 @@ function TitleState:updateSequence() self.dropLeft = nil end elseif self.phase == "settle" then - -- ld c, 36 / DelayFrames, then the whoosh and the bubble self.timer = self.timer + 1 - if self.timer >= 36 then + if self.timer >= SETTLE_FRAMES then Sound.play(data, "Intro_Whoosh") self.showBubble = true - self.phase = "bubble" + self.phase = self.yellowLayout and "bubble" or "ribbon" + self.ribbonOffset = RIBBON_FRAMES[1] + self.timer = 0 + end + elseif self.phase == "ribbon" then + self.timer = self.timer + 1 + local offset = RIBBON_FRAMES[self.timer + 1] + if offset then + self.ribbonOffset = offset + else + self.ribbonOffset = nil + self:startMusic() + self.phase = "loop" self.timer = 0 end elseif self.phase == "bubble" then @@ -347,8 +437,12 @@ function ContinueInfo:draw() -- box at (4,7), 8x14 content; labels double-spaced from (5,9) Font.drawBox(4, 7, 16, 10) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("PLAYER"), 40, 72) - Font.draw((save.player and save.player.name) or "RED", 96, 72) + -- the name follows the label's real width (one space after it), so a + -- localized label longer than PLAYER's six glyphs cannot run into it + local playerLabel = Strings("PLAYER") + Font.draw(playerLabel, 40, 72) + Font.draw((save.player and save.player.name) or "RED", + math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72) local badges = require("src.inventory.Badges").count(self.game.data, save) Font.draw(Strings("BADGES"), 40, 88) Font.draw(("%2d"):format(badges), 128, 88) @@ -401,17 +495,70 @@ function TitleState:openMenu() end local th = #items * 2 + 2 local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) - -- full-width title LOGO zones would recolor this box; see sgbPalettes - menu.titleUiBox = { 0, 0, 12, th - 1 } + -- full-width title LOGO zones would recolor this box; see sgbPalettes. + -- Menu.new may have grown tw for longer (e.g. localized) labels, so the + -- recolor zone follows the box's real width instead of the vanilla 13. + menu.titleUiBox = { 0, 0, menu.tw - 1, th - 1 } game.stack:push(menu) end +-- ..(engine/movie/title.asm ln 271) +function TitleState:pickNewMon() + if #self.cycleSpecies < 2 then return end + local pick = self.cycleIndex + while pick == self.cycleIndex do + pick = love.math.random(1, #self.cycleSpecies) + end + self.cycleIndex = pick +end + +function TitleState:setCyclePhase(phase) + self.scrollPhase = phase + self.scrollFrame = 1 + self.timer = 0 + if phase == "in" then + self:pickNewMon() + self.monOffset = IN_FRAMES[1] + elseif phase == "out" then + self.monOffset = OUT_FRAMES[1] + elseif phase == "ball" then + self.ballY = BALL_FRAMES[1] + else + self.monOffset = 0 + end +end + +function TitleState:updateCycle() + local phase = self.scrollPhase + if phase == "hold" then + if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end + return + end + local frames = phase == "out" and OUT_FRAMES + or phase == "ball" and BALL_FRAMES or IN_FRAMES + self.scrollFrame = self.scrollFrame + 1 + local value = frames[self.scrollFrame] + if value then + if phase == "ball" then self.ballY = value else self.monOffset = value end + return + end + if phase == "out" then + -- ..(engine/movie/title.asm ln 235) + self:setCyclePhase( + STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in") + elseif phase == "ball" then + self:setCyclePhase("in") + else + self:setCyclePhase("hold") + end +end + function TitleState:update(dt) + if self.phase ~= "loop" then + self:updateSequence() + return + end if self.yellowLayout then - if self.phase ~= "loop" then - self:updateSequence() - return -- input is ignored until the cinematic lands (title.asm) - end self:updateBlink() local input = self.game.input if input:wasPressed("start") or input:wasPressed("a") then @@ -426,21 +573,7 @@ function TitleState:update(dt) end self.timer = self.timer + 1 self.blink = (self.blink + 1) % 60 - if not self.yellowLayout and self.timer >= CYCLE_FRAMES then - self.timer = 0 - -- random pick that never repeats the current one - if #self.cycleSpecies > 1 then - local pick = self.cycleIndex - while pick == self.cycleIndex do - pick = love.math.random(1, #self.cycleSpecies) - end - self.cycleIndex = pick - end - self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in - end - if self.slideIn and self.slideIn > 0 then - self.slideIn = self.slideIn - 1 - end + self:updateCycle() local input = self.game.input if input:wasPressed("start") or input:wasPressed("a") then -- the title mon cries when you leave the title (.finishedWaiting); @@ -452,15 +585,14 @@ function TitleState:update(dt) end end --- The original tilemap (engine/movie/title.asm): logo at tile (2,1), --- the version ribbon at (7,8), Red's title art as OAM at px (82,80), --- the title mon in the 7x7 box at tile (5,10), copyright on row 17. --- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu --- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM. +-- ..(engine/movie/title.asm ln 28) function TitleState:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - local scrollY = self.yellowLayout and -(self.scy or 0) or 0 + local scrollY = -(self.scy or 0) + -- ..(engine/movie/title.asm ln 28) + local preRibbon = not self.yellowLayout + and (self.phase == "drop" or self.phase == "settle") if self.logo then love.graphics.draw(self.logo, 16, 8 + scrollY) else @@ -488,24 +620,29 @@ function TitleState:draw() -- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon -- (pokeyellow gfx/title/blue_version.png, unreferenced by title code); -- the Yellow fallback layout draws no ribbon at all. - if self.version and not self.yellow then + if self.version and not self.yellow and not preRibbon then local iw, ih = self.version:getDimensions() - if self.blue then + local rx = self.ribbonOffset or 0 + if self.versionFull then + -- a continuous ribbon (versionRibbon) centers as one piece + love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64) + elseif self.blue then love.graphics.draw(self.version, - love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64) + love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64) else love.graphics.draw(self.version, - love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64) + love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64) love.graphics.draw(self.version, - love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64) + love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64) end end - local sprite, spriteTrueColor = self:currentSprite() + local sprite, spriteTrueColor + if self.scrollPhase ~= "ball" then + sprite, spriteTrueColor = self:currentSprite() + end if sprite then local w, h = sprite:getDimensions() - local slide = (self.slideIn or 0) * 8 -- scroll in from the right - -- bottom-aligned and centered in the (5,10)-(11,16) tile box - local x = 40 + math.floor((56 - w) / 2) + slide + local x = 40 + math.floor((56 - w) / 2) + self.monOffset local y = 136 - h love.graphics.draw(sprite, x, y) -- a full-color mon keeps its own palette through the SGB pass, minus @@ -521,13 +658,34 @@ function TitleState:draw() end end -- Red is OAM in the original: he draws over the mon's box edge - if self.player then + if self.playerQuads then + for _, part in ipairs(self.playerQuads) do + love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3]) + end + love.graphics.draw(self.player, self.ballQuad, 82, self.ballY) + elseif self.player then love.graphics.draw(self.player, 82, 80) end end + self:drawCopyright(136 + (preRibbon and 0 or scrollY)) +end + +function TitleState:drawCopyright(y) + if not self.title.copyrightText and self.copyImg and self.gfInc then + local x = 16 + for _, t in ipairs(self.copyPrefix) do + love.graphics.draw(self.copyImg, self.copyQuads[t], x, y) + x = x + 8 + end + if self.nineImg then + love.graphics.draw(self.nineImg, x, y) + x = x + 8 + end + love.graphics.draw(self.gfInc, x, y) + return + end love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.title.copyrightText or Strings("2026 bois club games"), - 1, 136 + scrollY) + Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d6fcdb56..d0b48898 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts local COMPASS = { up = "north", down = "south", left = "west", right = "east" } local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps +-- after a wild battle before another random battle can start. +local WILD_ENCOUNTER_GRACE_STEPS = 3 + -- Fly animation coord paths (engine/overworld/player_animations.asm): -- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's -- Delay3). The port anchors a path on the player's own position instead @@ -81,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } -- above (screen = tile*8 + pixel - 8/16), measured against the player -- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40 -- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over --- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at --- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of +-- is the delta from the sprite's top-left, which the vanilla +-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that +-- origin while keeping these offsets frame-relative. `tile` indexes the +-- three stacked 8x8 tiles of -- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd -- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile -- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a @@ -206,7 +212,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH) return out end -function OverworldState:enter(mapId, x, y, facing) +function OverworldState:enter(mapId, x, y, facing, opts) Game = require("src.core.Game") Game.overworld = self Collision.load(Game.data) -- tile-pair (elevation) collisions @@ -224,10 +230,12 @@ function OverworldState:enter(mapId, x, y, facing) -- a fresh entry, or a stale flag can freeze player input forever self.engaging = false self.emote = nil + -- volatile WRAM state in pokered; never serialize across save/load + self.wildEncounterGraceSteps = 0 -- survives save/load: a loaded game may start inside a building whose -- exit mat is a LAST_MAP warp self.lastOutdoor = Game.save.lastOutdoor - self:setMap(mapId, x, y, facing, { via = "boot" }) + self:setMap(mapId, x, y, facing, opts or { via = "boot" }) -- boot/load: derive the flag from the tile the save left us standing on, -- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a -- door mat can still walk straight back out (issue #378) @@ -282,7 +290,7 @@ end function OverworldState:setMap(mapId, x, y, facing, opts) local fromMapId = self.map and self.map.id - if fromMapId then + if fromMapId and not (opts and opts.checkpoint) then Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId }) end -- ambient choreography is per-map: parallel runners die here, and the @@ -451,8 +459,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts) local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce self.keepMusicOnce = nil if not keepMusic then - require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, - self.player.surfing) + -- ..(home/overworld.asm ln 2346) + local Music = require("src.core.Music") + Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, + Music.MAP_FADE) end -- forced bike/surf tiles fire the moment the player is placed on the @@ -460,13 +470,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- (home/overworld.asm) -- a warp can land directly on one (the Route -- 16/18 gate exits), and the scripted door-mat walkout that follows -- suppresses onStepComplete, so waiting for a plain step never mounts - self:checkForcedMovement() + if not (opts and opts.checkpoint) then self:checkForcedMovement() end -- Seafoam B4F's map script pushes off the B3F stair warps every frame -- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the -- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts -- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit -- idle on those cells waiting for a player step. - self:checkSeafoamCurrent() + if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end -- snap the camera immediately: the overworld doesn't update while a -- Transition is on top, so a stale camera would show the new map at @@ -476,27 +486,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- fires before the onEnter chain so a listener sees the map in the same -- state the map script does - Runtime.emit("map.entered", { - mapId = mapId, map = self.map, fromMapId = fromMapId, - via = (opts and opts.via) - or (opts and opts.seamless and "connection") - or (fromMapId and "warp" or "boot"), - }) + if not (opts and opts.checkpoint) then + Runtime.emit("map.entered", { + mapId = mapId, map = self.map, fromMapId = fromMapId, + via = (opts and opts.via) + or (opts and opts.seamless and "connection") + or (fromMapId and "warp" or "boot"), + }) + end -- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers). -- fromMapId lets elevators seed a valid walk-out floor when the ROM -- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and -- the player B-cancels the floor menu without .UpdateWarp. - local hooks = mapScripts.get(mapId) - if hooks and hooks.onEnter then - hooks.onEnter(Game, self, fromMapId) + if not (opts and opts.checkpoint) then + local hooks = mapScripts.get(mapId) + if hooks and hooks.onEnter then + hooks.onEnter(Game, self, fromMapId) + end end self:rebuildNeighbors() Logger.info("map: %s at (%d,%d)", mapId, x, y) -- Route22Gate_Script rewrites wLastMap from the player's Y on entry -- too (not only on step), so a save/load mid-gate keeps exits correct - self:syncLastMapRewrite() + if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end end -- Neighbor maps drawn at the composed connection offsets: at least the @@ -760,8 +774,8 @@ function OverworldState:pushBattle(battle) local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0 -- the battle theme starts with the wipe, not after it -- (audio/play_battle_music.asm runs before the transition) - if battle.computeMusicKind then - require("src.core.Music").playBattle(Game.data, battle:computeMusicKind()) + if battle.playBattleTheme then + battle:playBattleTheme() end -- The fade back in from white on the way out is BattleState:finish()'s @@ -969,8 +983,14 @@ function OverworldState:update(dt) -- the bird carries the player in on landing, with its own -- SFX_FLY (EnterMapAnim .flyAnimation) self.arriveWarp = "fly" + -- keep the sprite hidden through the warp fade-out (#916): flyAnim + -- just went nil but flyArrive is not armed until startWarpTo's + -- midpoint, and the overworld keeps drawing beneath the veil, so + -- without this the trainer pops back in at the old cell for 32 frames + self.playerHidden = true self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" }) else + self.playerHidden = false self.player.inputLocked = false end return @@ -1002,6 +1022,10 @@ function OverworldState:update(dt) self.player.spinFrames = nil self.player.spinRise = nil self.player.inputLocked = false + -- keep the sprite hidden through the warp fade-out (#916): the spin is + -- over but the arrival spin-drop is not armed until startWarpTo's + -- midpoint, so without this the standing trainer shows under the veil + self.playerHidden = true self:warpToHealPoint(onDone, { arrive = "teleport" }) return end @@ -1079,8 +1103,10 @@ function OverworldState:update(dt) local mapId = self.pendingSeamMusic self.pendingSeamMusic = nil if mapId == self.map.id then - require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, - self.player.surfing) + -- ..(home/overworld.asm ln 677) + local Music = require("src.core.Music") + Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, + Music.MAP_FADE) end end if stepped and not scripted then @@ -3045,6 +3071,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText if theme then require("src.core.Music").play(Game.data, theme) end end local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) + battle.checkpointOrigin = { + kind = "trainer_encounter", + map = self.map.id, + npcId = npc.id, + trainerClass = d.trainerClass, + partyIndex = d.trainerParty or 1, + event = header and header.event or nil, + } -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer @@ -3450,6 +3484,10 @@ end function OverworldState:onStepComplete() local p = self.player + local suppressWildEncounter = self.wildEncounterGraceSteps > 0 + if suppressWildEncounter then + self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1 + end self.todSteps = (self.todSteps or 0) + 1 -- UpdatePikachuHappinessAndMood rides the step counter (poison.asm) require("src.world.PikachuFollower").onStep(Game.save) @@ -3562,6 +3600,9 @@ function OverworldState:onStepComplete() -- wild encounters in grass, on water while surfing, or -- on indoor -- maps whose tileset is not FOREST -- on EVERY tile -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) + -- The cooldown is checked after all other step processing so repel and + -- movement systems continue to advance during the protected steps. + if suppressWildEncounter then return end local encDef = Game.data.encounters[self.map.id] local enc local indoor = Game.data.field.indoorEncounters @@ -3582,6 +3623,10 @@ function OverworldState:onStepComplete() end local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level) + battle.checkpointOrigin = { + kind = "wild_encounter", + map = self.map.id, + } -- map.ghostBattles: unidentifiable without the named item (the -- Pokemon Tower's Silph Scope) local ghost = Map.ghostBattles(self.map.def) @@ -3944,6 +3989,9 @@ end -- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the -- blackout (pret HandlePlayerBlackOut) so the map script can HealParty. function OverworldState:afterBattle(result, battle) + if battle and battle.kind == "wild" then + self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS + end local lead = Game.save.party[1] Logger.info("battle over: %s (lead %s %d/%d)", tostring(result), lead and lead.species or "-", lead and lead.hp or 0, @@ -3986,6 +4034,39 @@ function OverworldState:afterBattle(result, battle) end end +-- Rebind the data-only continuation attached to a supported battle checkpoint. +-- The overworld was reconstructed first, so transient input/NPC freezes from +-- the original encounter are intentionally not resumed. +function OverworldState:restoreBattleContinuation(battle, origin) + local game = battle and battle.game + if not game or type(origin) ~= "table" or not self.map + or origin.map ~= self.map.id then + return false + end + if origin.kind == "wild_encounter" and battle.kind == "wild" then + battle.onFinish = function(result) self:afterBattle(result, battle) end + return true + end + if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer" + or origin.trainerClass ~= battle.oppClass + or origin.partyIndex ~= (battle.partyIndex or 1) + or type(origin.npcId) ~= "string" then + return false + end + battle.onFinish = function(result) + if result == "win" then + game.save.defeatedTrainers[origin.npcId] = true + if origin.event then game.save.flags[origin.event] = true end + self:checkVictoryRewards(battle.oppClass, battle.partyIndex) + end + self:afterBattle(result, battle) + self.engaging = false + local npc = self.npcPool and self.npcPool[origin.npcId] + if npc then npc.frozen = false end + end + return true +end + -- ------------------------------------------------------------------------- -- warps -- ------------------------------------------------------------------------- @@ -4136,6 +4217,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) + -- the departure-side hide from flyAnim/teleportOut ends here, on the new + -- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines + -- down, so the player is never drawable mid-fade nor standing bare on the + -- landing frame (#916) + self.playerHidden = false -- The warp we land ON stays inert for the completed-step check until we -- physically step off it, so a warp whose destination cell is itself a -- warp cannot bounce us straight back (elevator cars, stacked stair/door @@ -4735,9 +4821,15 @@ function OverworldState:drawWorld() end end local quad = self.rodQuads[oam.tile] - -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) - local rx = p.px - cam.x + oam.dx - local ry = p.py - cam.y - 4 + oam.dy + -- Place the rod against the active sprite's anchored top-left. The + -- vanilla result is still (px-cam, py-cam-4), while custom larger + -- sheets keep the rod attached to their feet. + -- Fishing always uses the on-foot player sheet; read its fields + -- directly so this FX pass does not advance pose-side animation. + local sprite, px, py = p.sprite, p.px, p.py + local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y) + local rx = sx + oam.dx + local ry = sy + oam.dy love.graphics.setColor(1, 1, 1, 1) if quad and oam.flip then love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) @@ -4860,7 +4952,8 @@ function OverworldState:drawWorld() g.npc:draw(cam.x - g.ox, cam.y - g.oy) end for _, e in ipairs(self.entities) do - if not ((self.flyAnim or self.flyArrive) and e == self.player) then + if not ((self.flyAnim or self.flyArrive or self.playerHidden) + and e == self.player) then e:draw(cam.x, cam.y) -- tall grass overdraws the sprite's feet (GB sprite priority); -- the overdraw is BG tiles, so it rides the shake offset too @@ -4911,7 +5004,8 @@ function OverworldState:drawWorld() items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g } end for _, e in ipairs(self.entities) do - if not ((self.flyAnim or self.flyArrive) and e == self.player) then + if not ((self.flyAnim or self.flyArrive or self.playerHidden) + and e == self.player) then items[#items + 1] = { y = e.py + 16, kind = "entity", e = e } end end diff --git a/src/world/Player.lua b/src/world/Player.lua index 5fb257e8..65406b85 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -335,8 +335,13 @@ function Player:draw(camX, camY) local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing] if fishTile then sprite:draw(px, py, camX, camY, facing, 0, false, true) - sprite:drawTile(fishTile, math.floor(px - camX), - math.floor(py - camY) - 4 + 8, facing == "right") + -- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's + -- actual anchored frame origin so larger/custom sheets keep the pose at + -- their feet instead of falling back to the vanilla 16x16 top-left. + local sx, sy = sprite:getScreenOrigin(px, py, camX, camY) + sprite:drawTile(fishTile, sx, + sy + math.max(0, sprite.frameHeight - 8), + facing == "right") return end sprite:draw(px, py, camX, camY, facing, phase, flip) diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 6adadc51..566afc9d 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,13 +6,68 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Assets = require("src.render.Assets") local MapLoader = require("src.world.MapLoader") +local Party = require("src.pokemon.Party") local Runtime = require("src.mods.Runtime") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local overviewShades = {} + +Assets.register(function() overviewShades = {} end) + +local function shadeDigit(sum, pixelCount) + return tostring(math.max(0, math.min(3, + math.floor((1 - sum / pixelCount) * 3 + 0.5)))) +end + +local function mapTileRows(map) + local tileset = map.tileset + if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end + local cached = overviewShades[tileset.image] + if not cached then + local ok, pixels = pcall(Assets.imageData, tileset.image) + if not ok then return nil end + cached = { pixels = pixels, shades = {} } + overviewShades[tileset.image] = cached + end + local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow + for ty = 0, map.heightCells * 2 - 1 do + local row, detailTop, detailBottom = {}, {}, {} + for tx = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(tx, ty) + local shades = cached.shades[tile] + if shades == nil then + local sums = { 0, 0, 0, 0 } + local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = cached.pixels:getPixel(ox + px, oy + py) + local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[quadrant] = sums[quadrant] + + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + shades = { + shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64), + shadeDigit(sums[1], 16), shadeDigit(sums[2], 16), + shadeDigit(sums[3], 16), shadeDigit(sums[4], 16), + } + cached.shades[tile] = shades + end + row[#row + 1] = shades[1] + detailTop[#detailTop + 1] = shades[2] .. shades[3] + detailBottom[#detailBottom + 1] = shades[4] .. shades[5] + end + rows[#rows + 1] = table.concat(row) + detailRows[#detailRows + 1] = table.concat(detailTop) + detailRows[#detailRows + 1] = table.concat(detailBottom) + end + return rows, detailRows +end function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) @@ -43,6 +98,53 @@ function WorldAPI:current() facing = p and p.facing } end +-- A compact, read-only view of the active map for minimaps and companion UIs. +-- `rows` describes collision terrain; optional `tileRows` reduces each real +-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). +-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify +-- exits and item spots that are still active without exposing world internals. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + local map, rows, markers = ow.map, {}, {} + for y = 0, map.heightCells - 1 do + local row = {} + for x = 0, map.widthCells - 1 do + row[#row + 1] = map:isWarpTileCell(x, y) and "+" + or map:isWaterCell(x, y) and "~" + or map:isWalkableCell(x, y) and "." or " " + end + rows[#rows + 1] = table.concat(row) + end + local def = map.def or {} + for _, warp in ipairs(def.warps or {}) do + markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y } + end + local game, save = self.game, self.game.save or {} + for _, obj in ipairs(def.objects or {}) do + if obj.item and obj.item ~= "0" and obj.item ~= 0 + and ow.objectVisible(save, map.id, obj) then + markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y } + end + end + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + local key = map.id .. "_" .. item.x .. "_" .. item.y + if not (save.hiddenTaken and save.hiddenTaken[key]) then + markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y } + end + end + local tileRows, tileDetailRows = mapTileRows(map) + return { mapId = map.id, width = map.widthCells, + height = map.heightCells, rows = rows, markers = markers, + tileRows = tileRows, + tileWidth = tileRows and map.widthCells * 2, + tileHeight = tileRows and map.heightCells * 2, + tileDetailRows = tileDetailRows, + tileDetailWidth = tileDetailRows and map.widthCells * 4, + tileDetailHeight = tileDetailRows and map.heightCells * 4 } +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) @@ -164,6 +266,49 @@ function WorldAPI:queueScript(rows, extra) return true end +-- The supported way to start a wild encounter. Hand-rolling this -- build a +-- BattleState, push it -- silently costs evolutions and blackout-on-loss +-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle +-- theme (both owned by pushBattle). Nothing raises when they are missing. +function WorldAPI:startWildBattle(species, level) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + if not self.game.data.pokemon[species] then + return nil, "unknown species: " .. tostring(species) + end + -- Pokemon.new writes the level through verbatim -- into level, the stat + -- calc and the exp curve -- so a fraction has to be refused here rather + -- than round somewhere downstream. The % test also catches NaN, which + -- passes both range comparisons. + level = tonumber(level) + if not level or level % 1 ~= 0 or level < 1 or level > 100 then + return nil, "level must be a whole number 1..100" + end + -- overworld() resolves the world from UNDER whatever sits on top of it, + -- so from a battle hook this would otherwise stack a second battle over + -- the live one -- and on a loss its afterBattle blacks out and warps + -- with the outer battle still on the stack. + local BattleTransition = require("src.render.BattleTransition") + for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do + if state.awardExp or getmetatable(state) == BattleTransition then + return nil, "a battle is already running" + end + end + if ow.transitioning then return nil, "the world is mid-warp" end + -- BattleState.newWild marks the species SEEN before it reports an empty + -- party, so the party check comes first: a refused call must not leave a + -- Pokedex entry behind. + local save = self.game.save + if not (save and Party.firstHealthy(save.party or {})) then + return nil, "no healthy party" + end + local battle = require("src.battle.BattleState") + .newWild(self.game, species, level) + battle.onFinish = function(result) ow:afterBattle(result, battle) end + ow:pushBattle(battle) + return true +end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/tests/build_rom_data_cli_test.py b/tests/build_rom_data_cli_test.py new file mode 100644 index 00000000..e8800c2a --- /dev/null +++ b/tests/build_rom_data_cli_test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""ROM-free regression tests for source-build version detection and routing.""" + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase, main, mock + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +import build_rom_data # noqa: E402 + + +class BuildRomDataCliTest(TestCase): + def run_builder(self, sha1, *extra): + manifest = {"romSha1": sha1, "symbols": {}} + rom = SimpleNamespace(sha1=sha1) + with mock.patch.object(build_rom_data, "RomImage", return_value=rom), \ + mock.patch.object(build_rom_data, "load_manifest", return_value=manifest), \ + mock.patch.object(build_rom_data, "build") as build, \ + mock.patch.object(build_rom_data.os, "makedirs"), \ + redirect_stdout(StringIO()), redirect_stderr(StringIO()): + result = build_rom_data.main([ + "--rom", "fixture.gb", "--only", "constants", *extra]) + return result, build + + def test_blue_rom_selects_blue_manifest_and_cache_paths(self): + result, build = self.run_builder( + build_rom_data.CANONICAL_BLUE_SHA1) + + self.assertEqual(result, 0) + args = build.call_args.args + self.assertEqual(args[3], "blue/data/generated") + self.assertEqual(args[4], "blue/assets/generated") + + def test_red_rom_keeps_historical_root_paths(self): + result, build = self.run_builder(build_rom_data.CANONICAL_RED_SHA1) + + self.assertEqual(result, 0) + args = build.call_args.args + self.assertEqual(args[3], "data/generated") + self.assertEqual(args[4], "assets/generated") + + def test_explicit_output_paths_are_preserved(self): + result, build = self.run_builder( + build_rom_data.CANONICAL_YELLOW_SHA1, + "--out", "/tmp/custom-data", "--assets", "/tmp/custom-assets") + + self.assertEqual(result, 0) + args = build.call_args.args + self.assertEqual(args[3], "/tmp/custom-data") + self.assertEqual(args[4], "/tmp/custom-assets") + + def test_unknown_rom_is_rejected_before_build(self): + unknown = "0" * 40 + result, build = self.run_builder(unknown) + + self.assertEqual(result, 1) + build.assert_not_called() + + +if __name__ == "__main__": + main() diff --git a/tests/drivers/export_sav_bug889_test.lua b/tests/drivers/export_sav_bug889_test.lua new file mode 100644 index 00000000..f258f318 --- /dev/null +++ b/tests/drivers/export_sav_bug889_test.lua @@ -0,0 +1,51 @@ +-- Driver (#889): export a .sav from a save that never came from a ROM import, +-- the case that used to write a save with no map context at all -- no map +-- header, no tileset header, sound id 0 -- so a real Game Boy continued into a +-- garbled map and hung on a white screen. +-- +-- Saves the game in two places (an interior and an outdoor map with +-- connections), exports each, and prints the bytes the engine reads back on +-- Continue so a human can eyeball them, plus the export path to load in an +-- emulator. +return function(game) + local U = dofile("tests/drivers/util.lua") + local SaveFileIO = require("src.import.SaveFileIO") + local SaveData = require("src.core.SaveData") + local GameVersion = require("src.core.GameVersion") + + local version = GameVersion.get() + local spots = { + { "REDS_HOUSE_2F", 3, 6 }, + { "PALLET_TOWN", 5, 6 }, + } + + for _, spot in ipairs(spots) do + local map, x, y = spot[1], spot[2], spot[3] + U.teleport(game, map, x, y, "down") + U.wait(30) + -- the same sync the in-game SAVE does before writing (OverworldState: + -- captureSave), so the slot on disk holds the position we just walked to + local top = game.stack:top() + if top and top.captureSave then top:captureSave(game.save) end + SaveData.save(game.save) + local ok, pathOrErr = SaveFileIO.exportActiveSlot(version) + if not ok then + U.log(("export_sav_bug889: %s FAILED: %s"):format(map, tostring(pathOrErr))) + else + local bytes = love.filesystem.read( + ("exports/%s/gen1recomp-%s-%s.sav"):format( + version, version, SaveData.activeSlot(version) or "save")) + local main = 0x2598 + 11 + local function u8(off) return bytes:byte(main + off + 1) end + local hdr = {} + for i = 0, 9 do hdr[#hdr + 1] = ("%02X"):format(u8(112 + i)) end + U.log(("export_sav_bug889: %s -> %s"):format(map, pathOrErr)) + U.log((" wCurMap=%02X wCurMapHeader=%s music=%02X/%02X tilesetBank=%02X"): + format(u8(103), table.concat(hdr, " "), u8(100), u8(101), u8(564))) + end + end + + U.log("export_sav_bug889: done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua index d46dcabe..69f4a2a4 100644 --- a/tests/drivers/hall_of_fame_bug704_test.lua +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -195,7 +195,7 @@ return function(game) ow:queueScript(slice, { npc = rival }) local startY = ow.player.cellY - local minY, walkShot = startY, false + local minY, walkShot, sharedCell = startY, false, false local hof for i = 1, 5000 do local top = game.stack:top() @@ -205,8 +205,9 @@ return function(game) end local w = game.overworld if w and w.map and w.map.id == "CHAMPIONS_ROOM" then - local y = w.player.cellY + local x, y = w.player.cellX, w.player.cellY if y < minY then minY = y end + if x == rival.cellX and y == rival.cellY then sharedCell = true end if y <= 2 and not walkShot then walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png") end @@ -215,6 +216,7 @@ return function(game) end check("the player walked out of the room before the warp (#704)", minY < startY) + check("the player routed around the rival", not sharedCell) check("walk-out screenshot", walkShot) check("the induction started", hof ~= nil) if not hof then diff --git a/tests/drivers/online_match_host.lua b/tests/drivers/online_match_host.lua index 8e9be6de..0a6c9123 100644 --- a/tests/drivers/online_match_host.lua +++ b/tests/drivers/online_match_host.lua @@ -55,8 +55,12 @@ return function(game) U.wait(10) -- the GAME SPEED forcing under test: a link session pins the logic clock - -- to 1X no matter what the option or POKEPORT_SPEED says - game.save.options.speed = 10 + -- to 1X no matter what the option or POKEPORT_SPEED says. RFC 0007: set + -- all three per-category speeds high, since the link lock has to win over + -- every one of them, not just whichever category happens to be active. + game.save.options.speedOverworld = 10 + game.save.options.speedBattle = 10 + game.save.options.speedMenu = 10 U.wait(2) log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed()) diff --git a/tests/drivers/online_match_join.lua b/tests/drivers/online_match_join.lua index 48f8545d..31d3a593 100644 --- a/tests/drivers/online_match_join.lua +++ b/tests/drivers/online_match_join.lua @@ -55,7 +55,11 @@ return function(game) local link = LinkState.new(game) game.stack:push(link) U.wait(10) - game.save.options.speed = 20 + -- RFC 0007: set all three per-category speeds high, since the link lock + -- has to win over every one of them, not just whichever is active. + game.save.options.speedOverworld = 20 + game.save.options.speedBattle = 20 + game.save.options.speedMenu = 20 U.wait(2) log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed()) diff --git a/tests/drivers/route1_seam_pacing_bug487_test.lua b/tests/drivers/route1_seam_pacing_bug487_test.lua index 981b0e15..48ea282a 100644 --- a/tests/drivers/route1_seam_pacing_bug487_test.lua +++ b/tests/drivers/route1_seam_pacing_bug487_test.lua @@ -46,7 +46,9 @@ return function(game) os.getenv("POKEPORT_DRIVER") == nil) check("no fast-forward multiplier is set", (tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1 - and (game.save.options.speed or 1) == 1) + and (game.save.options.speedOverworld or 1) == 1 + and (game.save.options.speedBattle or 1) == 1 + and (game.save.options.speedMenu or 1) == 1) check("a window is up to watch (this is a visual call)", love.window ~= nil and love.window.isOpen and love.window.isOpen()) U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap), diff --git a/tests/drivers/title_cycle_test.lua b/tests/drivers/title_cycle_test.lua new file mode 100644 index 00000000..82f68d23 --- /dev/null +++ b/tests/drivers/title_cycle_test.lua @@ -0,0 +1,66 @@ +-- ..(engine/movie/title.asm ln 28) +-- ..(engine/movie/title2.asm ln 13) +-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local shot = 0 + local function grab(tag) + shot = shot + 1 + U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag)) + end + + U.wait(30) + grab("copyright") + -- ..(engine/movie/splash.asm ln 230) + local movie = game.stack:top() + while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end + grab("star_topbar") + while movie.timer < 88 do U.wait(1) end + grab("star_middle") + while movie.timer < 100 do U.wait(1) end + grab("star_lowbar") + while movie.timer < 130 do U.wait(1) end + grab("gamefreak") + + U.tap(game, "start") + U.wait(2) + local title = game.stack:top() + U.log("top is", tostring(title and title.screenId)) + if not (title and title.scrollPhase) then + U.log("no TitleState on top; nothing below can run") + while true do coroutine.yield() end + end + + grab("drop_early") + U.wait(14) + grab("drop_late") + while title.phase == "drop" do U.wait(1) end + grab("settle") + while title.phase == "settle" do U.wait(1) end + grab("ribbon_start") + U.wait(10) + U.shot(game, DIR .. "/title_ribbon_mid.png") + while title.phase ~= "loop" do U.wait(1) end + grab("landed") + + title.cycleIndex = 1 + title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0 + title.monOffset = 0 + while title.scrollPhase == "hold" do U.wait(1) end + grab("out_a") + U.wait(6) + grab("out_b") + while title.scrollPhase == "out" do U.wait(1) end + U.log("after the scroll out the phase is", title.scrollPhase) + for _ = 1, 5 do + grab("ball") + U.wait(1) + end + while title.scrollPhase == "ball" do U.wait(1) end + grab("in") + U.wait(30) + grab("next_mon") + U.log("captured", DIR) + while true do coroutine.yield() end +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 52a34344..33983c8a 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -65,8 +65,12 @@ function U.newGame(game) U.wait(5) U.tap(game, "start") -- skip intro movie U.wait(10) - U.tap(game, "a") -- title -> menu - U.wait(5) + local title = game.stack:top() + for _ = 1, 60 do + U.tap(game, "a") + U.wait(5) + if game.stack:top() ~= title then break end + end -- menu: CONTINUE may or may not exist; NEW GAME is first without a save U.tap(game, "a") U.wait(10) diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua new file mode 100644 index 00000000..e8d1bf5f --- /dev/null +++ b/tests/engine/battle_checkpoint_boundary.lua @@ -0,0 +1,85 @@ +-- Battle checkpoints are exposed only at a settled, reconstructable player +-- decision boundary. This suite is ROM-free and exercises the public engine +-- checkpoint capability against the fixture battle implementation. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint boundary") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") + +local Data = Fixtures.fresh() + +local function makeGame() + local save = SaveData.newGame() + save.meta.playthroughId = "battle-playthrough" + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = save.player.map }, + player = { + cellX = save.player.x, cellY = save.player.y, + facing = save.player.facing, surfing = false, + }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + local game = { data = Data, save = save, stack = stack, overworld = overworld } + stack.states[1] = overworld + local battle = BattleState.newWild(game, "FIXMON_B", 12) + battle.phase = "menu" + battle.queue = {} + battle.checkpointOrigin = { kind = "wild_encounter" } + battle.onFinish = function() end + stack.states[2] = battle + return game, overworld, battle +end + +local game, overworld, battle = makeGame() +T.same(Checkpoint.inspect(game), { + canCapture = true, canRestore = true, kind = "battle", +}, "settled standard wild battle is a checkpoint boundary") + +local function refused(mutator, code, label) + local game2, ow2, battle2 = makeGame() + mutator(game2, ow2, battle2) + local capability = Checkpoint.inspect(game2) + T.check(capability.canCapture == false and capability.reason == code, + label .. ": " .. tostring(capability.reason)) +end + +refused(function(_, _, b) b.phase = "messages" end, + "battle_phase_busy", "message phase is rejected") +refused(function(_, _, b) b.queue = { { text = "busy" } } end, + "battle_phase_busy", "nonempty action queue is rejected") +refused(function(_, _, b) b.waitFrames = 1 end, + "battle_phase_busy", "partial wait is rejected") +refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end, + "battle_phase_busy", "unfinished HP display synchronization is rejected") +refused(function(_, _, b) b.player.mustRecharge = true end, + "battle_phase_busy", "automatic locked action is rejected") +refused(function(_, ow) ow.runner = { isRunning = function() return true end } end, + "script_busy", "suspended script beneath battle is rejected") +refused(function(_, _, b) b.checkpointOrigin = nil end, + "battle_origin_unsupported", "unknown completion closure is rejected") +refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end, + "battle_variant_unsupported", "Safari battle is rejected") +refused(function(_, _, b) b.ghost = true end, + "battle_variant_unsupported", "ghost battle is rejected") +refused(function(_, _, b) b.demo = true end, + "battle_variant_unsupported", "old-man demo is rejected") +refused(function(_, _, b) b.kind = "link" end, + "link_battle_unsupported", "link battle is rejected") + +-- Ordinary overworld behavior remains unchanged by the battle branch. +game.stack.states[2] = nil +T.same(Checkpoint.inspect(game), { + canCapture = true, canRestore = true, kind = "overworld", +}, "settled overworld remains supported") + +T.finish() diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua new file mode 100644 index 00000000..84319a6b --- /dev/null +++ b/tests/engine/battle_checkpoint_capture.lua @@ -0,0 +1,168 @@ +-- Data-only capture of a settled battle checkpoint, including deterministic +-- gameplay RNG and normalized object-reference sets. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint capture") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local StateStack = require("src.core.StateStack") + +local Data = Fixtures.fresh() +local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState +local randomState = "fixture-rng-A" +love.math.getRandomState = function() return randomState end +love.math.setRandomState = function(state) randomState = state end + +local function makeGame(kind) + local save = SaveData.newGame() + save.meta.playthroughId = "battle-playthrough" + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.party = { + Pokemon.new(Data, "FIXMON_A", 20), + Pokemon.new(Data, "FIXMON_C", 15), + } + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function overworld:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + local game = { data = Data, save = save, stack = stack, overworld = overworld } + stack.states[1] = overworld + local battle + if kind == "trainer" then + battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.checkpointOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1", + trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, + event = "EVENT_BEAT_TRAINER_1", + } + else + battle = BattleState.newWild(game, "FIXMON_B", 12) + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + end + battle.phase, battle.queue = "menu", {} + battle.onFinish = function() end + stack.states[2] = battle + return game, battle +end + +local game, battle = makeGame("wild") +battle.turnCount = 7 +battle.runAttempts = 2 +battle.payDay = 45 +battle.player.stages.attack = 2 +battle.player.confusedTurns = 3 +battle.player.curTypes = { "FIRE", "FLYING" } +local originalMoveId = battle.player.curMoves[1].id +battle.player.curMoves[1].id = "FIX_CUT" +battle.player.curMoves[1].mimic = true +battle.mimicRestores = { + { battler = battle.player, entry = battle.player.curMoves[1], id = originalMoveId }, +} +battle.enemy.mon.hp = battle.enemy.mon.hp - 4 +battle.enemy.shownHP = battle.enemy.mon.hp +battle.enemy.stages.defense = -1 +battle.enemy.aiLayer2 = 1 +battle.enemy.thrashMove = battle.enemy.curMoves[1] +battle.enemy.thrashTurns = 2 +battle.participants = { [game.save.party[1]] = true, + [game.save.party[2]] = true } +battle.leveledUp = { [game.save.party[2]] = true } +battle.sideToxic = { enemy = 3 } +battle.sides[1].screens.reflect = { turns = 2 } +battle.field.weather = { id = "fixture-rain", turns = 4 } + +local snapshot, code, message = Checkpoint.capture(game) +T.check(snapshot ~= nil, "settled battle captures: " .. tostring(code or message)) +T.eq(snapshot and snapshot.kind, "battle", "checkpoint kind is battle") +if snapshot and snapshot.kind == "battle" then + T.eq(snapshot.rng.love, "fixture-rng-A", "LÖVE RNG state is captured") + T.same(snapshot.runtime.overworld, + { map = "FIX_TOWN", x = 2, y = 3, facing = "left", surfing = false }, + "return overworld point is captured") + T.same(snapshot.runtime.battle.origin, + { kind = "wild_encounter", map = "FIX_TOWN" }, + "semantic continuation origin is data-only") + T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured") + T.eq(snapshot.runtime.battle.rulesetId, "gen1_faithful", + "battle mechanics ruleset identity is captured") + T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured") + T.eq(snapshot.runtime.battle.player.stages.attack, 2, + "player stat stages are captured") + T.eq(snapshot.runtime.battle.player.confusedTurns, 3, + "player volatile status is captured") + T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" }, + "transformed battle types are captured") + T.same(snapshot.runtime.battle.mimicRestores, + { { side = "player", slot = 1, id = originalMoveId } }, + "Mimic restore pointers normalize to side and move slot") + T.eq(snapshot.runtime.battle.enemy.stages.defense, -1, + "enemy stat stages are captured") + T.eq(snapshot.runtime.battle.enemy.aiLayer2, 1, + "enemy AI selection layer is captured") + T.eq(snapshot.runtime.battle.enemy.thrashMoveSlot, 1, + "move-instance references normalize to move slots") + T.eq(snapshot.runtime.battle.enemy.thrashMove, nil, + "live move-instance references are not serialized as detached copies") + T.same(snapshot.runtime.battle.participants, { 1, 2 }, + "Pokemon-keyed participants normalize to party indices") + T.same(snapshot.runtime.battle.leveledUp, { 2 }, + "Pokemon-keyed level-up set normalizes to party indices") + T.same(snapshot.runtime.battle.sides[1].screens.reflect, { turns = 2 }, + "data-only side extensions are captured") + T.same(snapshot.runtime.battle.field.weather, + { id = "fixture-rain", turns = 4 }, + "data-only field extensions are captured") + local encoded = SaveSerializer.encode(snapshot) + T.check(type(encoded) == "string" and #encoded > 0, + "battle checkpoint passes the canonical data-only serializer") + + snapshot.save.money = 1 + snapshot.runtime.battle.player.stages.attack = -6 + T.check(game.save.money ~= 1, "checkpoint progress is detached") + T.eq(battle.player.stages.attack, 2, "checkpoint battle state is detached") +end + +local trainerGame, trainer = makeGame("trainer") +trainer.enemyIndex = 1 +trainer.aiUses = 2 +local trainerSnapshot = Checkpoint.capture(trainerGame) +T.eq(trainerSnapshot and trainerSnapshot.kind, "battle", + "ordinary trainer battle captures") +if trainerSnapshot and trainerSnapshot.kind == "battle" then + T.eq(trainerSnapshot.runtime.battle.oppClass, "OPP_FIX_YOUNGSTER", + "trainer class is captured") + T.eq(trainerSnapshot.runtime.battle.partyIndex, 1, + "trainer roster index is captured") + T.eq(#trainerSnapshot.runtime.battle.enemyParty, #trainer.enemyParty, + "complete enemy roster is captured") +end + +local extensionGame, extensionBattle = makeGame("wild") +extensionBattle.field.tokens[1] = { id = "callback-token", onExpire = function() end } +local unsafe, unsafeCode = Checkpoint.capture(extensionGame) +T.check(unsafe == nil and unsafeCode == "battle_extension_unsafe", + "callback-bearing battle extensions are rejected, not stripped") + +love.math.getRandomState = nil +local rngGame = makeGame("wild") +local noRng, rngCode = Checkpoint.capture(rngGame) +T.check(noRng == nil and rngCode == "rng_state_unavailable", + "battle capture fails closed without serializable gameplay RNG") + +love.math.getRandomState, love.math.setRandomState = oldGet, oldSet +T.finish() diff --git a/tests/engine/battle_checkpoint_continuation.lua b/tests/engine/battle_checkpoint_continuation.lua new file mode 100644 index 00000000..3219119f --- /dev/null +++ b/tests/engine/battle_checkpoint_continuation.lua @@ -0,0 +1,102 @@ +-- Engine-owned battle continuations replace unserializable onFinish closures +-- after a persistent checkpoint reconstructs the overworld and battle. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint continuation") +local GameMethods = require("src.core.Game") +local OverworldState = require("src.world.OverworldController") + +local function fakeOverworld() + local npc = { id = "FIX_TOWN_obj_1", frozen = true } + local ow = setmetatable({ + map = { id = "FIX_TOWN" }, + npcPool = { [npc.id] = npc }, + engaging = true, + }, { __index = OverworldState }) + ow.afterBattle = function(self, result, battle) + self.after = { result = result, battle = battle } + end + ow.checkVictoryRewards = function(self, class, party) + self.reward = { class = class, party = party } + end + return ow, npc +end + +local wildOw = fakeOverworld() +local wildGame = { save = { defeatedTrainers = {}, flags = {} } } +local wild = { game = wildGame, kind = "wild" } +T.check(wildOw:restoreBattleContinuation(wild, + { kind = "wild_encounter", map = "FIX_TOWN" }) == true, + "ordinary wild continuation binds") +wild.onFinish("run") +T.same(wildOw.after, { result = "run", battle = wild }, + "wild continuation returns through canonical afterBattle") + +local trainerOw, trainerNpc = fakeOverworld() +local trainerGame = { save = { defeatedTrainers = {}, flags = {} } } +local trainer = { + game = trainerGame, kind = "trainer", + oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, +} +local trainerOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", + npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1, + event = "EVENT_BEAT_FIX_TRAINER", +} +T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true, + "ordinary trainer continuation binds") +trainer.onFinish("win") +T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true, + "trainer win stamps the stable object id") +T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true, + "trainer win stamps the header event") +T.same(trainerOw.reward, + { class = "OPP_FIX_YOUNGSTER", party = 1 }, + "trainer win runs canonical victory rewards") +T.same(trainerOw.after, { result = "win", battle = trainer }, + "trainer win returns through canonical afterBattle") +T.check(trainerOw.engaging == false and trainerNpc.frozen == false, + "reconstructed trainer completion leaves overworld input unfrozen") + +local lossOw, lossNpc = fakeOverworld() +local lossGame = { save = { defeatedTrainers = {}, flags = {} } } +local lossBattle = { + game = lossGame, kind = "trainer", + oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, +} +T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true, + "trainer loss continuation binds") +lossBattle.onFinish("lose") +T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil, + "trainer loss does not stamp the trainer defeated") +T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil, + "trainer loss does not stamp the header event") +T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards") + +local mismatchOw = fakeOverworld() +T.check(mismatchOw:restoreBattleContinuation(trainer, { + kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id, + trainerClass = trainer.oppClass, partyIndex = 1, +}) == false, "continuation from another map is rejected") +T.check(mismatchOw:restoreBattleContinuation(trainer, { + kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id, + trainerClass = "OPP_OTHER", partyIndex = 1, +}) == false, "mismatched trainer identity is rejected") + +local ow = {} +local stack = { states = { ow } } +function stack:top() return self.states[#self.states] end +local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods }) +local entered, resumed = false, false +local battle = { + enter = function() entered = true end, + resumeCheckpoint = function() resumed = true end, +} +game:restoreCheckpointBattle(battle) +T.check(game.stack:top() == battle, "reconstructed battle is installed on stack") +T.check(resumed == true, "checkpoint-specific battle resume path runs") +T.check(entered == false, "ordinary battle intro is not replayed") + +T.finish() diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua new file mode 100644 index 00000000..1ea08ea1 --- /dev/null +++ b/tests/engine/battle_checkpoint_restore.lua @@ -0,0 +1,315 @@ +-- A battle checkpoint reconstructs a new controller from data, rather than +-- retaining the original table/closure, and restores gameplay RNG exactly. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint restore") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Checkpoint = require("src.core.Checkpoint") +local Damage = require("src.battle.Damage") +local Encounter = require("src.world.Encounter") +local GameMethods = require("src.core.Game") +local Music = require("src.core.Music") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local StateStack = require("src.core.StateStack") +local TrainerAI = require("src.battle.TrainerAI") + +local Data = Fixtures.fresh() +local oldRandom = love.math.random +local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState +local oldPlayBattle = Music.playBattle +Music.playBattle = function() end +local rng = 12345 +love.math.getRandomState = function() return tostring(rng) end +love.math.setRandomState = function(state) rng = assert(tonumber(state)) end +love.math.random = function(a, b) + rng = (rng * 1103515245 + 12345) % 2147483648 + local unit = rng / 2147483648 + if a == nil then return unit end + if b == nil then return math.floor(unit * a) + 1 end + return a + math.floor(unit * (b - a + 1)) +end + +local function makeGame(kind) + local save = SaveData.newGame() + save.meta.playthroughId = "battle-playthrough" + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + save.party = { + Pokemon.new(Data, "FIXMON_A", 20), + Pokemon.new(Data, "FIXMON_C", 15), + } + -- Strip new-game defaults that are intentionally absent from the tiny + -- fixture registry, then place the sanitized save on a fixture map. + SaveData.validate(save, Data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function overworld:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function overworld:restoreBattleContinuation(battle, origin) + battle.onFinish = function(result) + self.lastRestoredFinish = { result = result, origin = origin.kind } + end + return true + end + local game = setmetatable( + { data = Data, save = save, stack = stack, overworld = overworld }, + { __index = GameMethods }) + function game:restoreCheckpointSave(loaded) + self.save = loaded + self.overworld.map = { id = loaded.player.map } + self.overworld.player = { + cellX = loaded.player.x, cellY = loaded.player.y, + facing = loaded.player.facing, + surfing = loaded.player.surfing and true or false, + } + self.overworld.runner = { isRunning = function() return false end } + self.overworld.parallelRunners, self.overworld.pendingScripts = {}, {} + self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {} + self.stack.states = { self.overworld } + end + stack.states[1] = overworld + local battle + if kind == "trainer" then + battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.checkpointOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1", + trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, + event = "EVENT_BEAT_TRAINER_1", + } + else + battle = BattleState.newWild(game, "FIXMON_B", 12) + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + end + battle.phase, battle.queue = "menu", {} + battle.musicKind = battle:computeMusicKind() + battle.onFinish = function() end + stack.states[2] = battle + return game, battle +end + +local function settleOverworld(game) + game.stack.states = { game.overworld } + game.save.money = 999999 + game.save.party[1].hp = 1 + game.overworld.player.cellX = 8 + game.overworld.player.facing = "up" +end + +local game, originalBattle = makeGame("wild") +originalBattle.turnCount = 4 +originalBattle.runAttempts = 1 +originalBattle.player.stages.speed = 3 +local originalMoveId = originalBattle.player.curMoves[1].id +originalBattle.player.curMoves[1].id = "FIX_CUT" +originalBattle.player.curMoves[1].mimic = true +originalBattle.mimicRestores = { + { battler = originalBattle.player, entry = originalBattle.player.curMoves[1], + id = originalMoveId }, +} +originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5 +originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp +originalBattle.enemy.disabledSlot = 1 +originalBattle.enemy.disabledTurns = 2 +originalBattle.enemy.aiLayer2 = 1 +originalBattle.enemy.thrashTurns = 2 +originalBattle.enemy.mon.moves = { + { id = "FIX_SCRATCH", pp = 35 }, { id = "FIX_CUT", pp = 30 }, +} +originalBattle.enemy.curMoves = originalBattle.enemy.mon.moves +originalBattle.enemy.thrashMove = originalBattle.enemy.curMoves[1] +originalBattle.participants = { [game.save.party[1]] = true } +local checkpoint = assert(Checkpoint.capture(game)) +local encounterDef = { grass = { + rate = 256, buckets = { 128, 256 }, + slots = { { species = "FIXMON_A", level = 4 }, + { species = "FIXMON_B", level = 7 } }, +} } +Encounter.load(Data) +local function randomOutcomes(battle) + local damage, detail = Damage.compute(battle.ruleset, battle.player, + battle.enemy, Data.moves.FIX_CUT, { rng = battle.rng }) + local hit = Damage.accuracyRoll(battle.ruleset, Data.moves.FIX_CUT, + battle.player, battle.enemy, battle.rng) + local ai = TrainerAI.chooseMove(battle.enemy, battle.rng, nil) + local escaped = battle:runRollVanilla(1, 100) + local encounter = Encounter.roll(encounterDef, love.math.random) + local nextValue = love.math.random(1, 1000000) + return { + damage = damage, critical = detail.crit, hit = hit, + ai = ai.id, escaped = escaped, encounter = encounter, + nextValue = nextValue, + } +end +local expectedOutcomes = randomOutcomes(originalBattle) + +settleOverworld(game) +game.save.options.ruleset = "modern_clean" +rng = 777 +local restored, code, message = Checkpoint.restore(game, checkpoint) +T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code)) +local rebuilt = restored and game.stack:top() +if restored then + T.check(rebuilt ~= originalBattle, "restore creates a new battle controller") + T.eq(getmetatable(rebuilt), BattleState, "restored stack top is a BattleState") + T.eq(rebuilt.phase, "menu", "restored battle resumes at the decision menu") + T.eq(#rebuilt.queue, 0, "restored battle has no stale action queue") + T.eq(rebuilt.turnCount, 4, "turn count roundtrips") + T.eq(rebuilt.runAttempts, 1, "escape state roundtrips") + T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip") + T.check(rebuilt.mimicRestores and rebuilt.mimicRestores[1] + and rebuilt.mimicRestores[1].battler == rebuilt.player + and rebuilt.mimicRestores[1].entry == rebuilt.player.curMoves[1], + "Mimic restore references are rebuilt against the new battler") + rebuilt:restoreMimicked(rebuilt.player) + T.eq(rebuilt.player.curMoves[1].id, originalMoveId, + "restored Mimic move returns to its canonical id when battle copy leaves") + T.eq(rebuilt.player.curMoves[1].mimic, nil, + "restored Mimic marker clears with the battle copy") + -- Put the checkpointed battle state back before differential recapture. + rebuilt.player.curMoves[1].id = "FIX_CUT" + rebuilt.player.curMoves[1].mimic = true + rebuilt.mimicRestores = { + { battler = rebuilt.player, entry = rebuilt.player.curMoves[1], id = originalMoveId }, + } + T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips") + T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips") + T.eq(rebuilt.enemy.aiLayer2, 1, "enemy AI selection layer roundtrips") + T.check(rebuilt.enemy.thrashMove == rebuilt.enemy.curMoves[1], + "multi-turn move references rebuild against the new move list") + T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp, + "enemy Pokemon model roundtrips") + T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips") + T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp, + "party model roundtrips") + T.eq(game.save.options.ruleset, "modern_clean", + "current global ruleset option remains untouched") + T.check(rebuilt.ruleset == require("src.battle.rulesets.gen1_faithful"), + "restored battle keeps the mechanics ruleset it was captured with") + T.same(Checkpoint.capture(game), checkpoint, + "capture A, discard, restore A, capture A2 yields normalized A == A2") + local replayed = randomOutcomes(rebuilt) + T.same(replayed, expectedOutcomes, + "damage, critical, accuracy, AI, escape, encounter and next RNG replay exactly") + rebuilt.onFinish("run") + T.same(game.overworld.lastRestoredFinish, + { result = "run", origin = "wild_encounter" }, + "restored battle receives a reconstructed semantic continuation") +end + +local partyGame, switchedOriginal = makeGame("wild") +partyGame.save.party[1].hp = 0 +local activeMon = partyGame.save.party[2] +activeMon.status = "PAR" +activeMon.moves[1].pp = activeMon.moves[1].pp - 4 +switchedOriginal.player = BattleState.makeBattler( + Data, activeMon, true, partyGame.save) +switchedOriginal.sides[1].battlers = { switchedOriginal.player } +switchedOriginal.participants = { + [partyGame.save.party[1]] = true, + [partyGame.save.party[2]] = true, +} +local partyCheckpoint = assert(Checkpoint.capture(partyGame)) +settleOverworld(partyGame) +partyGame.save.party[2].status = nil +partyGame.save.party[2].moves[1].pp = 1 +restored, code, message = Checkpoint.restore(partyGame, partyCheckpoint) +T.check(restored == true, + "switched/status/PP checkpoint restores: " .. tostring(message or code)) +local partyRebuilt = partyGame.stack:top() +if restored then + T.eq(partyGame.save.party[1].hp, 0, + "fainted non-active party member roundtrips") + T.check(partyRebuilt.player.mon == partyGame.save.party[2], + "switched active Pokemon reconstructs against restored party identity") + T.eq(partyRebuilt.player.mon.status, "PAR", "active status roundtrips") + T.eq(partyRebuilt.player.mon.moves[1].pp, + partyCheckpoint.save.party[2].moves[1].pp, "reduced PP roundtrips") + T.check(partyRebuilt.participants[partyGame.save.party[1]] == true + and partyRebuilt.participants[partyGame.save.party[2]] == true, + "participant references rebuild against fainted and active party members") + T.same(Checkpoint.capture(partyGame), partyCheckpoint, + "switch, faint, status and PP differential recapture is exact") +end + +local trainerGame, trainerOriginal = makeGame("trainer") +trainerOriginal.turnCount = 6 +trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3 +trainerOriginal.enemy.shownHP = trainerOriginal.enemy.mon.hp +trainerOriginal.aiUses = 1 +trainerOriginal.participants = { [trainerGame.save.party[1]] = true, + [trainerGame.save.party[2]] = true } +local trainerCheckpoint = assert(Checkpoint.capture(trainerGame)) +settleOverworld(trainerGame) +restored, code, message = Checkpoint.restore(trainerGame, trainerCheckpoint) +T.check(restored == true, "trainer checkpoint restores: " .. tostring(message or code)) +local trainerRebuilt = trainerGame.stack:top() +if restored then + T.check(trainerRebuilt ~= trainerOriginal, + "trainer restore is independent of the original controller") + T.eq(trainerRebuilt.oppClass, "OPP_FIX_YOUNGSTER", "trainer class roundtrips") + T.eq(trainerRebuilt.enemyIndex, 1, "enemy roster index roundtrips") + T.eq(trainerRebuilt.aiUses, 1, "trainer AI item budget roundtrips") + T.same(Checkpoint.capture(trainerGame), trainerCheckpoint, + "trainer differential recapture is exact") +end + +local function clone(value) + return assert(SaveSerializer.decode(SaveSerializer.encode(value))) +end + +local beforeRejected = assert(Checkpoint.capture(trainerGame)) +local missingSpecies = clone(trainerCheckpoint) +missingSpecies.runtime.battle.enemyParty[1].species = "MISSING_SPECIES" +restored, code = Checkpoint.restore(trainerGame, missingSpecies) +T.check(restored == false and code == "invalid_content", + "unknown battle content is rejected before mutation") +T.same(Checkpoint.capture(trainerGame), beforeRejected, + "rejected battle content leaves runtime and RNG unchanged") + +local badOrigin = clone(trainerCheckpoint) +badOrigin.runtime.battle.origin.npcId = nil +restored, code = Checkpoint.restore(trainerGame, badOrigin) +T.check(restored == false and code == "battle_origin_unsupported", + "incomplete semantic continuation is rejected before mutation") +T.same(Checkpoint.capture(trainerGame), beforeRejected, + "rejected continuation leaves runtime and RNG unchanged") + +-- Fail after the new battle has been installed, when its RNG is applied. +-- The transaction must reconstruct the prior battle and restore its RNG. +local workingSetRandomState = love.math.setRandomState +local setCalls = 0 +love.math.setRandomState = function(state) + setCalls = setCalls + 1 + if setCalls == 1 then error("injected RNG restore failure") end + return workingSetRandomState(state) +end +local beforeFailure = assert(Checkpoint.capture(trainerGame)) +local rngBeforeFailure = rng +restored, code = Checkpoint.restore(trainerGame, trainerCheckpoint) +T.check(restored == false and code == "restore_failed", + "post-install RNG failure is returned as a structured restore failure") +T.eq(rng, rngBeforeFailure, "failed battle restore rolls RNG back exactly") +T.same(Checkpoint.capture(trainerGame), beforeFailure, + "failed battle restore rolls the complete runtime back exactly") +love.math.setRandomState = workingSetRandomState + +love.math.random = oldRandom +love.math.getRandomState, love.math.setRandomState = oldGet, oldSet +Music.playBattle = oldPlayBattle +T.finish() diff --git a/tests/engine/chip_analog_path.lua b/tests/engine/chip_analog_path.lua new file mode 100644 index 00000000..e974c266 --- /dev/null +++ b/tests/engine/chip_analog_path.lua @@ -0,0 +1,103 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local data = { audio = {} } + +local function pulseSong() + return ChipAsm.song{ + channels = { { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 15, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 15 }, + } } }, + } +end + +local function noiseSong() + return ChipAsm.sfx{ + channels = { { hw = 4, program = { + { noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } }, + } } }, + } +end + +do + local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false }) + local sawNeg, sawPos = false, false + for _ = 1, 512 do + local v = engine.channels[1]:sample() + if v < -1e-12 then sawNeg = true end + if v > 1e-12 then sawPos = true end + end + check(sawPos and not sawNeg, + "pulse DAC is unipolar (high = volume, low = 0)") +end + +do + local engine = ChipSynth.newEngine(data, noiseSong(), { + sfx = true, allowLoops = false, + }) + local sawNeg, sawPos = false, false + for _ = 1, 2048 do + local v = engine.channels[1]:sample() + if v < -1e-12 then sawNeg = true end + if v > 1e-12 then sawPos = true end + end + check(sawPos and not sawNeg, + "noise DAC is unipolar (LFSR high = volume, low = 0)") +end + +local function crossingsAndSign(engine, frames) + local count, prev = 0, nil + local sawNeg, sawPos = false, false + for _ = 1, frames do + local sample = engine:sample() + if sample < -1e-12 then sawNeg = true end + if sample > 1e-12 then sawPos = true end + if prev and prev * sample < 0 then count = count + 1 end + prev = sample + end + return count, sawNeg, sawPos +end + +do + local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false }) + local count, sawNeg, sawPos = crossingsAndSign(engine, 4000) + check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0") + check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count)) +end + +do + local engine = ChipSynth.newEngine(data, noiseSong(), { + sfx = true, allowLoops = false, + }) + local count, sawNeg, sawPos = crossingsAndSign(engine, 8000) + check(sawNeg and sawPos, "HPF centers noise / drums around analog 0") + check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count)) +end + +do + local song = pulseSong() + ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) + local a = ChipSynth.newEngine(data, song, { allowLoops = false }) + local base = a.channels[1]:sample() + ChipSynth.setChannelVolume(1, 0.25) + local b = ChipSynth.newEngine(data, song, { allowLoops = false }) + local quarter = b.channels[1]:sample() + ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) + check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9, + "channelVolume still quarters the unipolar DAC level") +end + +eq(type(ChipSynth.newEngine), "function", "engine factory still exported") + +T.finish("chip analog path") diff --git a/tests/engine/drum_envelope_ring.lua b/tests/engine/drum_envelope_ring.lua new file mode 100644 index 00000000..120f55f6 --- /dev/null +++ b/tests/engine/drum_envelope_ring.lua @@ -0,0 +1,82 @@ +-- ..(audio/engine_1.asm ln 197) +-- ..(audio/sfx/noise_instrument01_1.asm ln 1) +-- luajit tests/engine/drum_envelope_ring.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local snare = ChipAsm.song{ + channels = { + { hw = 4, program = { + { notetype = { speed = 12 } }, + { drum = 1, len = 2 }, + { rest = 16 }, + } }, + }, + drums = { + [1] = { + { len = 1, volume = 12, fade = 1, parameter = 0x33 }, + }, + }, +} + +local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false }) +local segs = engine:noiseInstrument(1) +local last = segs[#segs] +local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000 +check(ringMs > 150 and ringMs < 220, + ("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs)) + +local energyEarly, energyLate, energyEnd = 0, 0, 0 +local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25) +for i = 1, total do + local s = engine:sample() + local e = s * s + local t = i / ChipSynth.SAMPLE_RATE + if t < 0.02 then + energyEarly = energyEarly + e + elseif t > 0.05 and t < 0.12 then + energyLate = energyLate + e + elseif t > 0.20 then + energyEnd = energyEnd + e + end +end +check(energyEarly > 0, "snare attack is audible") +check(energyLate > energyEarly * 0.05, + ("snare body still sounds at 50-120ms (early=%.4f late=%.4f)") + :format(energyEarly, energyLate)) +check(energyEnd < energyLate * 0.1, + "snare has decayed by 200ms") + +local hats = ChipAsm.song{ + channels = { + { hw = 4, program = { + { notetype = { speed = 12 } }, + { drum = 1, len = 2 }, + { drum = 1, len = 2 }, + } }, + }, + drums = { + [1] = { + { len = 1, volume = 8, fade = 1, parameter = 0x10 }, + }, + }, +} +local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false }) +local hits = 0 +local prev = 0 +for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do + local s = math.abs(hatEngine:sample()) + if prev < 0.01 and s >= 0.01 then hits = hits + 1 end + prev = s +end +check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits)) + +T.finish("drum envelope ring") diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua new file mode 100644 index 00000000..57bcd86f --- /dev/null +++ b/tests/engine/game_speed_categories_test.lua @@ -0,0 +1,196 @@ +-- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack +-- walk, Game:logicSpeed()'s precedence (link lock / run-argument override / +-- the core.logic_speed hook), Game:_cycleSpeed's per-category cycling, and +-- the core.logic_speed hook itself exercised through the public mod API +-- (Hooks.new() + bus:wrap, the same idiom other hooks' tests use -- not a +-- private require). +-- luajit tests/engine/game_speed_categories_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Game = require("src.core.Game") +local GameSpeed = require("src.core.GameSpeed") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") + +-- ------- Game.speedCategoryInStack: the whole-stack walk + +local function stack(...) return { states = { ... } } end + +local battle = { isBattle = true } +local overworld = { isOverworld = true } +local overlay = {} -- a party menu/choice box/naming screen/text box: no marker + +eq(Game.speedCategoryInStack(nil), "menu", "a nil stack falls to menu") +eq(Game.speedCategoryInStack(stack()), "menu", "an empty stack falls to menu") +eq(Game.speedCategoryInStack(stack(overlay)), "menu", + "an unmarked state alone (title screen, credits, a standalone cutscene) is menu") + +eq(Game.speedCategoryInStack(stack(overworld)), "overworld", + "the overworld alone resolves to overworld") +eq(Game.speedCategoryInStack(stack(battle)), "battle", + "a battle alone resolves to battle") + +eq(Game.speedCategoryInStack(stack(overworld, overlay)), "overworld", + "a menu opened while walking inherits overworld") +eq(Game.speedCategoryInStack(stack(battle, overlay)), "battle", + "a menu opened mid-battle inherits battle, not menu") +eq(Game.speedCategoryInStack(stack(overworld, overlay, overlay)), "overworld", + "the inheritance walk sees through more than one stacked overlay") + +eq(Game.speedCategoryInStack(stack(overworld, battle)), "battle", + "a battle opened over the overworld reads as battle, not the overworld underneath it") +eq(Game.speedCategoryInStack(stack(overworld, battle, overlay)), "battle", + "and a menu on top of THAT still reads as battle") + +-- ------- Game:_resolveLogicSpeed: category -> save.options key -> clamp + +local unpack = table.unpack or unpack + +local function gameWith(states, options) + return setmetatable({ + save = { options = options }, + stack = stack(unpack(states or {})), + }, { __index = Game }) +end + +do + local g = gameWith({ overworld }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 4, "overworld reads speedOverworld") +end +do + local g = gameWith({ battle }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle") +end +do + local g = gameWith({ overlay }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 2, "menu reads speedMenu") +end +do + local g = gameWith({ overworld }, { speedOverworld = 7 }) + eq(g:_resolveLogicSpeed(), GameSpeed.clamp(7), + "an odd value clamps to the nearest LEVELS entry, like the old single field") +end +do + local g = gameWith({ overworld }, nil) + eq(g:_resolveLogicSpeed(), GameSpeed.DEFAULT, + "no save.options at all defaults rather than erroring") +end + +-- ------- Game:logicSpeed(): link and speedOverride win over every category +-- and over a hook override; the hook only ever sees the ordinary case + +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkSession = true + eq(g:logicSpeed(), 1, "an active link session forces 1X even at 50X battle") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkNet = { closed = false } + eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkNet = { closed = true } + eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X") +end +do + local g = gameWith({ overworld }, { speedOverworld = 4 }) + g.speedOverride = 20 + eq(g:logicSpeed(), 20, + "speedOverride (--speed/POKEPORT_SPEED) wins over the category option") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkSession = true + local bus = Hooks.new() + local savedHooks = Runtime.hooks + Runtime.hooks = bus + local hookRan = false + local unsub = bus:wrap("core.logic_speed", function(next, game) + hookRan = true + return 999 + end) + eq(g:logicSpeed(), 1, + "the link lock wins even over a mod's core.logic_speed override") + check(not hookRan, "...because the hook is never called during link play") + unsub() + Runtime.hooks = savedHooks +end + +-- ------- core.logic_speed: the mod-API seam, driven through Runtime.call/ +-- Hooks.new + bus:wrap like every other hook's public-API test + +local function callLogicSpeed(g) + return Runtime.call("core.logic_speed", + function(gg) return gg:_resolveLogicSpeed() end, g) +end + +do + local g = gameWith({ battle }, { speedBattle = 4 }) + eq(callLogicSpeed(g), 4, + "with no subscriber, the hook returns the vanilla category resolution") +end + +do + local g = gameWith({ overworld }, { speedOverworld = 4 }) + local bus = Hooks.new() + local savedHooks = Runtime.hooks + Runtime.hooks = bus + + local nextArg = nil + local unsub = bus:wrap("core.logic_speed", function(next, game) + nextArg = next(game) + return nextArg + end) + eq(callLogicSpeed(g), 4, + "a subscriber calling next(game) passes the vanilla value through") + eq(nextArg, 4, "...and next(game) itself returned the vanilla resolution") + unsub() + + -- a bot mod forcing 1X for one route segment regardless of the category + unsub = bus:wrap("core.logic_speed", function(next, game) return 1 end) + eq(callLogicSpeed(g), 1, + "a subscriber may override the resolved multiplier outright") + unsub() + + Runtime.hooks = savedHooks +end + +-- ------- Game:_cycleSpeed: cycles whichever category is active, and only it + +do + local writeOptions = { calls = 0 } + local g = gameWith({ battle }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end + g:_cycleSpeed(1) + eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle") + eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone") + eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone") + eq(writeOptions.calls, 1, "a successful cycle persists the option") +end +do + local g = gameWith({ overworld }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(1) + eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld") + eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone") +end +do + local g = gameWith({ overlay }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(1) + eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu") +end + +T.finish("game_speed_categories") diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua new file mode 100644 index 00000000..19f80fcf --- /dev/null +++ b/tests/engine/link_session.lua @@ -0,0 +1,345 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Net = require("src.link.Net") +local Json = require("src.link.Json") +local Session = require("src.link.Session") + +local function sessionPair() + local hostNet, guestNet = Net.loopbackPair() + return Session.new(hostNet, { role = "host", kind = "link" }), + Session.new(guestNet, { role = "guest", kind = "link" }) +end + +local function fakeTransport(options) + options = options or {} + local transport = { + paired = options.paired ~= false, + closed = false, + error = nil, + inbox = options.inbox or {}, + closeCount = 0, + } + function transport:update() + if options.onUpdate then options.onUpdate(self) end + if options.updateError then error(options.updateError) end + end + function transport:poll() + if options.pollError then error(options.pollError) end + local messages = self.inbox + self.inbox = {} + return messages + end + function transport:send(message) + self.sent = message + return true + end + function transport:close() + self.closeCount = self.closeCount + 1 + self.closed = true + if options.closeError then error(options.closeError) end + end + return transport +end + +local function readFile(path) + local handle = assert(io.open(path, "rb")) + local body = handle:read("*a") + handle:close() + return body +end + +do + local host, guest = sessionPair() + T.eq(host:getRole(), "host", "host role is assigned locally") + T.eq(guest:getRole(), "guest", "guest role is assigned locally") + T.eq(host:getKind(), "link", "session kind is retained") + T.eq(host:getStatus(), "paired", "wrapped loopback starts paired") + + guest:send({ + type = "hello", name = "BLUE", role = "host", kind = "tournament", + }) + host:update() + local hello = host:take("hello") + T.eq(hello.name, "BLUE", "send forwards the original payload") + T.eq(hello.session, nil, "send adds no session envelope") + T.eq(host:getRole(), "host", "peer payload cannot replace local role") + T.eq(host:getKind(), "link", "peer payload cannot replace local kind") +end + +do + local host, guest = sessionPair() + guest:send({ type = "before", sequence = 1 }) + guest:send({ type = "hello", sequence = 2 }) + guest:send({ type = "after", sequence = 3 }) + guest:send({ type = "hello", sequence = 4 }) + host:update() + + local hello = host:take("hello") + T.eq(hello.sequence, 2, "take removes the first matching packet") + T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head") + + local rest = host:poll() + T.eq(#rest, 2, "poll returns every remaining packet once") + T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order") + T.eq(rest[2].sequence, 4, "take preserves repeated-type order") + T.eq(#host:poll(), 0, "poll clears the private FIFO") +end + +do + local sent + local transport = { + paired = false, + code = nil, + address = "192.0.2.5:7777", + target = "ROOM01", + update = function(self) + self.paired = true + self.code = "ROOM02" + end, + poll = function() return {} end, + send = function(_, message) + sent = message + return "queued", 7 + end, + close = function(self) self.closed = true end, + } + local session = Session.new(transport, { role = "guest", kind = "tournament" }) + T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting") + T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored") + T.eq(session.target, "ROOM01", "target metadata is mirrored") + + local outbound = { type = "ping" } + local result, count = session:send(outbound) + T.eq(result, "queued", "send preserves the transport's first return") + T.eq(count, 7, "send preserves the transport's second return") + T.eq(sent, outbound, "send forwards the original table unchanged") + + session:update() + T.eq(session:getStatus(), "paired", "update observes transport pairing") + T.eq(session.code, "ROOM02", "update refreshes relay metadata") +end + +do + local transport = fakeTransport({ onUpdate = function(self) + self.inbox[#self.inbox + 1] = { type = "bye", final = true } + self.closed = true + end }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + T.eq(session:getStatus(), "draining", "normal close drains its final packet") + T.eq(session.closed, false, "compatibility closed waits for the FIFO") + T.eq(session:take("bye").final, true, "final close packet remains observable") + T.eq(session:getStatus(), "closed", "normal drain reaches closed") + T.eq(transport.closeCount, 1, "transport cleanup runs once") +end + +do + local transport = fakeTransport({ + onUpdate = function(self) self.closed = true end, + closeError = "normal cleanup exploded", + }) + local session = Session.new(transport, { role = "host", kind = "link" }) + T.check(pcall(session.update, session), + "normal-close cleanup exception does not escape the game loop") + local reason, detail = session:getFailure() + T.eq(reason, "transport_error", + "normal-close cleanup exception becomes a transport failure") + T.check(detail:find("normal cleanup exploded", 1, true) ~= nil, + "normal-close cleanup failure keeps its diagnostic detail") + T.eq(session:getStatus(), "failed", + "normal-close cleanup exception cannot report a clean close") +end + +do + local transport = fakeTransport({ onUpdate = function(self) + self.inbox = { { type = "before", sequence = 1 } } + self.error = "socket failed" + self.closed = true + end }) + local session = Session.new(transport, { role = "guest", kind = "link" }) + session:update() + local reason, detail = session:getFailure() + T.eq(reason, "transport_error", "transport failure has a stable reason") + T.eq(detail, "socket failed", "transport failure retains original detail") + T.eq(session:getStatus(), "draining", "transport failure drains valid prefix") + T.eq(session.error, nil, "legacy error stays hidden during drain") + T.eq(session.closed, false, "legacy closed stays false during failed drain") + T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix") + T.eq(session:getStatus(), "failed", "failed drain reaches failed") + T.eq(session.error, "socket failed", "legacy error appears at terminal failure") + transport.error = "later error" + session:update() + local _, latchedDetail = session:getFailure() + T.eq(latchedDetail, "socket failed", "first terminal failure stays latched") +end + +do + local transport = fakeTransport({ inbox = { + { type = "before", sequence = 1 }, + false, + { type = "after", sequence = 3 }, + } }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + local reason = session:getFailure() + T.eq(reason, "protocol_error", "malformed packet fails as protocol_error") + T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix") + local messages = session:poll() + T.eq(#messages, 1, "malformed value and untrusted tail are not exposed") + T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet") + T.eq(session:getStatus(), "failed", "protocol drain reaches failed") +end + +do + local transport = fakeTransport({ inbox = { { type = 7 } } }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + T.eq(session:getFailure(), "protocol_error", + "table without string type is a protocol error") +end + +do + local transport = fakeTransport({ + inbox = { { type = "future_world_packet", value = 9 } }, + }) + local session = Session.new(transport, { role = "host", kind = "link" }) + session:update() + T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned") +end + +do + local transport = fakeTransport({ + inbox = { { type = "already_decoded", value = 4 } }, + updateError = "update exploded", + }) + local session = Session.new(transport, { role = "host", kind = "link" }) + local ok = pcall(session.update, session) + T.check(ok, "transport update exception does not escape the game loop") + T.eq(session:getStatus(), "draining", "update exception still drains prior inbox") + T.eq(session:pollOne().value, 4, "decoded packet survives update exception") + T.eq(session:getStatus(), "failed", "update exception becomes terminal failure") +end + +do + local transport = fakeTransport({ pollError = "poll exploded" }) + local session = Session.new(transport, { role = "host", kind = "link" }) + T.check(pcall(session.update, session), + "transport poll exception does not escape the game loop") + local reason, detail = session:getFailure() + T.eq(reason, "transport_error", "poll exception is a transport failure") + T.check(detail:find("poll exploded", 1, true) ~= nil, + "poll exception keeps its diagnostic detail") +end + +do + local transport = fakeTransport({ closeError = "close exploded" }) + local session = Session.new(transport, { role = "guest", kind = "link" }) + T.check(pcall(session.close, session), + "transport close exception does not escape cleanup") + T.eq(session:getFailure(), "transport_error", + "close exception is a transport failure") + session:close() + T.eq(transport.closeCount, 1, "failed close is still attempted only once") +end + +do + local transport = fakeTransport() + local session = Session.new(transport, { role = "guest", kind = "link" }) + session:close() + session:close() + session:update() + T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent") + T.eq(session:getStatus(), "closed", "explicit close reaches closed") +end + +do + T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }), + "constructor rejects missing transport") + local transport = fakeTransport() + T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }), + "constructor rejects unsupported role") + T.check(not pcall(Session.new, transport, { role = "host", kind = "" }), + "constructor rejects empty kind") +end + +do + local senderNet, receiverNet = Net.loopbackPair() + local receiver = Session.new(receiverNet, { role = "guest", kind = "link" }) + senderNet:send(false) + receiver:update() + T.eq(receiver:getFailure(), "protocol_error", + "loopback forwards decoded false to session validation") +end + +do + local delivered = false + local transport = Net.new() + transport.enetHost = { + service = function() + if delivered then return nil end + delivered = true + return { type = "receive", data = "false" } + end, + } + local session = Session.new(transport, { role = "guest", kind = "link" }) + session:update() + T.eq(session:getFailure(), "protocol_error", + "ENet forwards decoded false to session validation") +end + +do + local transport = Net.new() + local session = Session.new(transport, { role = "host", kind = "tournament" }) + T.check(pcall(transport.handleTCPLine, transport, "42"), + "TCP control handoff does not index a decoded scalar") + session:update() + T.eq(session:getFailure(), "protocol_error", + "decoded TCP scalar reaches session validation") +end + +do + local transport = Net.new() + transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" })) + T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned") + transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" })) + T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact") +end + +do + local source = readFile("src/link/LinkState.lua") + T.check(source:find('require("src.link.Session")', 1, true) ~= nil, + "LinkState depends on the session boundary") + T.check(source:find('kind = "link"', 1, true) ~= nil, + "LinkState assigns the link session kind locally") + T.check(source:find("self.net.inbox", 1, true) == nil, + "LinkState never mutates a transport inbox") + T.check(source:find("self.net = Net.new()", 1, true) == nil, + "LinkState stores only successful session wrappers") + T.check(source:find(':take("hello")', 1, true) ~= nil, + "LinkState retrieves hello without draining unrelated packets") + T.check(source:find(':take("party")', 1, true) ~= nil, + "LinkState leaves battle handoff packets in session order") + T.check(source:find("getStatus()", 1, true) ~= nil, + "LinkState uses the session lifecycle instead of raw terminal flags") +end + +do + local source = readFile("src/link/Tournament.lua") + T.check(source:find('require("src.link.Session")', 1, true) ~= nil, + "Tournament depends on the session boundary") + T.check(source:find('kind = "tournament"', 1, true) ~= nil, + "Tournament assigns its connection role and kind locally") + T.check(source:find("self.net.inbox", 1, true) == nil, + "Tournament never mutates a transport inbox") + T.check(source:find("self.net = Net.new()", 1, true) == nil, + "Tournament stores only a successful session wrapper") + T.check(source:find(':take("hello")', 1, true) ~= nil, + "Tournament retrieves match hello without draining its tail") + T.check(source:find(":pollOne()", 1, true) ~= nil, + "Tournament processes handoff prefixes one packet at a time") + T.check(source:find("getStatus()", 1, true) ~= nil, + "Tournament uses the session lifecycle instead of raw terminal flags") +end + +T.finish("link_session") diff --git a/tests/engine/map_music_fade.lua b/tests/engine/map_music_fade.lua new file mode 100644 index 00000000..0ab6bab2 --- /dev/null +++ b/tests/engine/map_music_fade.lua @@ -0,0 +1,94 @@ +-- ..(home/audio.asm ln 9) +-- ..(home/fade_audio.asm ln 36) +-- luajit tests/engine/map_music_fade.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +local made = {} -- file -> the last source built for it +love.audio = { + newSource = function(file, mode) + made[file] = setmetatable({ file = file, mode = mode }, Source) + return made[file] + end, +} + +local Music = require("src.core.Music") + +local data = { audio = { + songs = { + Music_Pallet = { file = "pallet.wav" }, + Music_Routes1 = { file = "routes1.wav" }, + Music_Pewter = { file = "pewter.wav" }, + }, + mapSongs = { + PALLET_TOWN = "Music_Pallet", + ROUTE_1 = "Music_Routes1", + PEWTER_CITY = "Music_Pewter", + }, +} } + +local function frames(n) + for _ = 1, n do Music.update(data) end +end + +local function playing() + for file, src in pairs(made) do + if src.playing then return file end + end + return "(silence)" +end + +local FADE = 7 * Music.MAP_FADE -- 7 volume levels x 10 frames + +Music.stop() +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +eq(playing(), "pallet.wav", "the first map after boot starts at once") + +local fullVolume = made["pallet.wav"].volume + +Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE) +eq(playing(), "pallet.wav", "the new theme waits while the old one fades") +frames(FADE - 1) +eq(playing(), "pallet.wav", "still fading one frame short of silence") +check(made["pallet.wav"].volume < fullVolume, + "the old theme has been ramped down by then") +frames(1) +eq(playing(), "routes1.wav", "the queued theme takes over after 7 * 10 frames") + +eq(made["routes1.wav"].volume, fullVolume, + "the new theme starts at full volume, not where the ramp ended") + +Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE) +eq(playing(), "routes1.wav", "the same theme keeps playing") +frames(FADE) +eq(playing(), "routes1.wav", "and no fade was armed for it") + +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +frames(3 * Music.MAP_FADE) +Music.playMap(data, "PEWTER_CITY", false, false, Music.MAP_FADE) +eq(playing(), "routes1.wav", "the retargeted fade keeps ramping the old theme") +frames(4 * Music.MAP_FADE) +eq(playing(), "pewter.wav", "the ramp lands on the newest map's theme") + +Music.playMap(data, "PALLET_TOWN", false, false) +eq(playing(), "pallet.wav", "a fadeless map cue swaps immediately") + +T.finish("map_music_fade") diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index 82076453..87e96222 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -142,7 +142,11 @@ do index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } })) check(index == nil and err ~= nil, "a feed with no schema_version is refused") index, err = ModIndex.parse("404") - check(index == nil and err ~= nil, "an HTML error page soft-fails") + check(index == nil and tostring(err):find("HTML", 1, true) ~= nil, + "an HTML error page is named, not blamed on the parser") + index, err = ModIndex.parse("Error: upstream unavailable") + check(index == nil and tostring(err):find("not JSON", 1, true) ~= nil, + "a plain-text error names the response") index, err = ModIndex.parse('{"schema_version":1}') check(index == nil and err ~= nil, "a feed with no mods array soft-fails") end diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua index d6ff3239..a0b3306e 100644 --- a/tests/engine/mod_update_tests.lua +++ b/tests/engine/mod_update_tests.lua @@ -76,6 +76,31 @@ do check(path == nil and dlErr ~= nil, "empty url soft-fails") end +-- the reported bug: a non-JSON answer (plain-text error, proxy/captive +-- prompt, outage message) used to leak the decoder's "unexpected character" +-- assert at the first byte of the body. The guard must name what the server +-- actually sent and never let that assert surface. +do + local list, err = ModUpdate.parseReleases("Error: API rate limit exceeded", "demo") + check(list == nil and err ~= nil, "plain-text error soft-fails") + check(tostring(err):find("not JSON", 1, true) ~= nil + and tostring(err):find("Error: API", 1, true) ~= nil, + "plain-text error names the response and previews what it said") + list, err = ModUpdate.parseReleases("502 Bad Gateway", "demo") + check(list == nil and tostring(err):find("HTML", 1, true) ~= nil, + "an HTML error page is named as such") + list, err = ModUpdate.parseReleases("", "demo") + check(list == nil and tostring(err):find("empty", 1, true) ~= nil, + "an empty response is named") + check(tostring(err):find("unexpected character", 1, true) == nil, + "the decoder's assert never leaks into the message") + list = ModUpdate.parseReleases(Json.encode({ + { tag_name = "v1.0.0", assets = { + { name = "demo-1.0.0.zip", browser_download_url = "https://x/d.zip" } } }, + }), "demo") + eq(#list, 1, "the guard lets real JSON through") +end + do local body = Json.encode({ tag_name = "v2.0.0", diff --git a/tests/engine/nx_yellow_boot_test.lua b/tests/engine/nx_yellow_boot_test.lua index 764198ec..a0ac0bf5 100644 --- a/tests/engine/nx_yellow_boot_test.lua +++ b/tests/engine/nx_yellow_boot_test.lua @@ -37,7 +37,8 @@ end local Y_TITLE = { "pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png", - "player.png", "copyright.png", "yellow_version.png", + "player.png", "copyright.png", "gamefreak_inc.png", "nine.png", + "yellow_version.png", } for _, name in ipairs(Y_TITLE) do seed("yellow/assets/generated/title/" .. name) @@ -207,6 +208,11 @@ check(pre ~= nil, "the IntroMovie pre-roll was constructed") eq(pre and pre.copyright and pre.copyright.path, "yellow/assets/generated/title/copyright.png", "copyright card resolves to the yellow/ copy") +eq(pre and pre.nineImg and pre.nineImg.path, + "yellow/assets/generated/title/nine.png", + "copyright NineTile resolves to the yellow/ copy") +check(pre and pre.yellowCopy == true, + "IntroMovie selects the Yellow (c)1995-1999 tile sequence") eq(pre and pre.studioLogo and pre.studioLogo.path, "yellow/assets/generated/intro/studio_logo.png", "data-driven studio logo resolves to the yellow/ copy") @@ -266,6 +272,7 @@ GameVersion.set("blue") seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes") seed("blue/assets/generated/title/player.png") seed("blue/assets/generated/title/copyright.png") +seed("blue/assets/generated/title/gamefreak_inc.png") local B_INTRO = { "gf_logo.png", "gf_text.png", "big_star.png", "falling_star.png", "falling_star_blink.png", "studio_logo.png", diff --git a/tests/engine/options_partial_write_bug932.lua b/tests/engine/options_partial_write_bug932.lua new file mode 100644 index 00000000..9f7802a9 --- /dev/null +++ b/tests/engine/options_partial_write_bug932.lua @@ -0,0 +1,100 @@ +-- #932 "Bugs reset settings": a caller that hands saveOptions a PARTIAL +-- table (only the keys it changed) used to drop every key it did not +-- mention -- launcher-only keys like lastVersion, and keys the launcher set +-- (battleBg, tilt) all fell back to defaults. saveOptions now reads the +-- on-disk file first and folds caller-absent values underneath, so a delta +-- write changes only what it names. +-- +-- This suite pins the three-way merge against injected filesystem stubs +-- (the same { getInfo, read, write, remove } shape the other engine suites +-- use). It is ROM-free (T2 engine tier). +-- luajit tests/engine/options_partial_write_bug932.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") + +local OPTIONS = "options.lua" + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +-- Seed a save dir with the full snapshot a launcher would write: defaults, +-- plus the keys the issue cares about. lastVersion is launcher-only (not a +-- defaultOptions member) and must survive ANY write that does not name it. +local function seed(fs) + local seed = SaveData.defaultOptions() + seed.battleBg = "world" + seed.lastVersion = "blue" + seed.tilt = 1 + seed.mods = { foo = true } + seed.modOptions = { alpha = { keep = true, x = 1 } } + check(SaveData.saveOptions(seed, fs) ~= nil, "seeding lands") +end + +-- ---- launcher-only keys survive a delta write + +local fs = memfs() +seed(fs) + +-- loader-style partial write: only the mods bucket it manages. +SaveData.saveOptions({ mods = { foo = true } }, fs) +local opts = SaveData.loadOptions(fs) +eq(opts.battleBg, "world", "a partial write keeps battleBg the launcher set") +eq(opts.lastVersion, "blue", "a partial write keeps lastVersion (#932)") +eq(opts.tilt, 1, "a partial write keeps tilt the launcher set") + +-- ---- caller-present keys still win + +SaveData.saveOptions({ battleBg = "black" }, fs) +eq(SaveData.loadOptions(fs).battleBg, "black", + "a key the caller DOES provide wins over the on-disk value") +eq(SaveData.loadOptions(fs).lastVersion, "blue", + "...while the launcher-only key is still carried") + +-- ---- modOptions per-mod deep merge stays intact + +SaveData.saveOptions({ modOptions = { alpha = { x = 5 } } }, fs) +local after = SaveData.loadOptions(fs) +eq(after.modOptions.alpha.x, 5, "newest alpha value wins the per-mod merge") +eq(after.modOptions.alpha.keep, true, "alpha's untouched keys survive") +eq(after.modOptions.beta, nil, "no beta was invented by the merge") + +-- ---- full-table writes stay authoritative (bindings/activeProfile drops) + +-- The fold must NOT resurrect a key a full snapshot deliberately deletes: +-- the RESET REBINDS path nils bindings and the mod manager nils +-- activeProfile, always on full loadOptions tables. +fs = memfs() +seed(fs) +SaveData.saveOptions({ bindings = { a = 1 } }, fs) +eq(SaveData.loadOptions(fs).bindings.a, 1, "bindings is not a default member") + +local full = SaveData.loadOptions(fs) +full.bindings = nil +full.activeProfile = nil +SaveData.saveOptions(full, fs) +local reopened = SaveData.loadOptions(fs) +eq(reopened.bindings, nil, + "a full-snapshot deletion of bindings is NOT resurrected by the fold") +eq(reopened.activeProfile, nil, + "a full-snapshot deletion of activeProfile is NOT resurrected") +eq(reopened.battleBg, "world", + "the rest of the full snapshot is still what it was") + +T.finish("options_partial_write_bug932") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua index 59b9b0b7..b0ccbabc 100644 --- a/tests/engine/options_write_readback_bug828.lua +++ b/tests/engine/options_write_readback_bug828.lua @@ -204,23 +204,26 @@ eq(reopened.lastVersion, "blue", "launcher-only keys the game never reads are carried through its write") -- The corollary, and the reason the copy has to come from loadOptions: a --- caller that writes a partial literal instead of a loaded table drops every --- key it does not mention, because mergeOptions only fills DEFAULTS in around --- what it is handed (SaveData.mergeOptions). Nothing on the boot path does --- this today; the assertion is the guard rail if someone shortcuts it. +-- caller that writes a partial literal instead of a loaded table would drop +-- every key it does not mention. Since #932 that drop is closed by a +-- three-way merge -- saveOptions folds on-disk values the caller's table +-- does not carry (lastVersion here), defaults-filling only what neither side +-- has -- so even a delta write keeps the launcher's key alive. Nothing on +-- the boot path writes partials today; the assertion is the guard rail if +-- someone shortcuts it. SaveData.saveOptions({ battleLayout = "og" }, hop) -eq(SaveData.loadOptions(hop).lastVersion, nil, - "a partial write drops launcher-only keys, so the game must write the " - .. "table loadOptions handed it") +eq(SaveData.loadOptions(hop).lastVersion, "blue", + "a partial write no longer drops launcher-only keys (#932)") --- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's --- write and flushed after it still wins, because saveOptions merges only --- modOptions from disk and every other key is last-writer-wins. Measured, --- not guessed (og beats a newer wide). No shipping path holds an options --- table across a launcher write -- HostShell.restart replaces the process on --- the way back to the launcher (#785, #575) and LauncherSettings.open notes --- its own cached table is only true while its modal covers the launcher -- --- so closing that gap needs a three-way merge (baseline vs caller vs disk), --- not a straight "disk wins", which would throw away real in-game changes. +-- Known gap, deliberately not asserted: a FULL copy taken BEFORE the +-- launcher's write and flushed after it still wins -- a table holding every +-- defaultOptions key is authoritative, so its og is never folded against a +-- newer wide on disk (#932 closes the PARTIAL-write drop, not this). +-- Measured, not guessed. No shipping path holds an options table across a +-- launcher write -- HostShell.restart replaces the process on the way back +-- to the launcher (#785, #575) and LauncherSettings.open notes its own +-- cached table is only true while its modal covers the launcher -- so +-- closing that gap needs a real three-way baseline (vs caller vs disk), not +-- a straight "disk wins", which would throw away real in-game changes. T.finish("options_write_readback_bug828") diff --git a/tests/engine/pc_list_kinds.lua b/tests/engine/pc_list_kinds.lua new file mode 100644 index 00000000..ad0238c3 --- /dev/null +++ b/tests/engine/pc_list_kinds.lua @@ -0,0 +1,48 @@ +-- Stable ListMenu identities for screen.render_visible and companion UIs. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +local SaveData = require("src.core.SaveData") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local PlayerPC = require("src.ui.PlayerPC") +local Boxes = require("src.pokemon.Boxes") + +local pushed +local game = { + data = Data, + save = SaveData.newGame(), + stack = { push = function(_, state) pushed = state end }, +} + +local species = T.fixtures.ids.species[1] +Boxes.ensure(game.save)[1][1] = { species = species, level = 5 } +game.save.party[1] = { species = species, level = 5 } +game.save.party[2] = { species = species, level = 6 } +game.save.pcItems = { FIX_POTION = 2 } +game.save.inventory.FIX_POTION = 2 + +local generic = ListMenu.new(game, "VISIBLE TITLE", {}, {}) +T.eq(generic.kind, "VISIBLE TITLE", "generic lists fall back to their title") +local explicit = ListMenu.new(game, "Localized title", {}, { kind = "stable_id" }) +T.eq(explicit.kind, "stable_id", "explicit list kind is preserved") + +local box = BoxMenu.new(game) +for i, kind in ipairs({ "pc_box_withdraw", "pc_box_deposit", + "pc_box_release", "pc_box_change" }) do + pushed = nil + box.items[i].onSelect() + T.eq(pushed and pushed.kind, kind, kind .. " is stable") +end + +local items = PlayerPC.new(game) +for i, kind in ipairs({ "pc_item_withdraw", "pc_item_deposit", + "pc_item_toss" }) do + pushed = nil + items.items[i].onSelect() + T.eq(pushed and pushed.kind, kind, kind .. " is stable") +end + +T.finish("pc_list_kinds") diff --git a/tests/engine/playthrough_identity.lua b/tests/engine/playthrough_identity.lua new file mode 100644 index 00000000..cb584d51 --- /dev/null +++ b/tests/engine/playthrough_identity.lua @@ -0,0 +1,154 @@ +-- Opaque playthrough identity: New Game uniqueness, save/load persistence, +-- stable legacy backfill, and version/slot isolation. No real save directory. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") + +local realFS = love.filesystem + +local function memfs(files) + return { + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + getDirectoryItems = function(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local function legacy(version, name) + return { + version = version, + meta = { format = 4, mods = {} }, + player = { name = name, map = "PALLET_TOWN", x = 5, y = 6 }, + flags = {}, inventory = {}, pcItems = {}, party = {}, box = {}, boxes = {}, + money = 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} }, + } +end + +-- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope. +do + fresh() + local first = SaveData.newGame({ version = "red" }) + local second = SaveData.newGame({ version = "red" }) + T.eq(first.meta.playthroughId, nil, + "New Game allocates no playthrough id before a public tool requests it") + T.check(SaveData.save(first), "unused identity fixture saves") + local untouched = SaveData.load("red") + T.eq(untouched.meta.playthroughId, nil, + "normal save/load stays identity-free when no tool uses the capability") + + local firstId = SaveData.ensurePlaythroughId(first) + local secondId = SaveData.ensurePlaythroughId(second) + T.check(type(firstId) == "string" and firstId ~= "", + "the first tool request allocates an opaque playthrough id") + T.neq(secondId, firstId, + "separate New Games receive separate requested playthrough ids") +end + +-- Dropping the id from buildMeta or save encoding must fail the roundtrip. +do + fresh() + local save = SaveData.newGame({ version = "red" }) + local expected = SaveData.ensurePlaythroughId(save) + T.check(SaveData.save(save), "identity fixture saves") + local loaded = SaveData.load("red") + T.eq(loaded and loaded.meta.playthroughId, expected, + "normal save/load preserves the playthrough id") +end + +-- Legacy identity is persisted independently: the legacy progress bytes remain +-- unchanged, yet two loads resolve the same id before a normal SAVE occurs. +do + local files = fresh() + local raw = legacy("red", "LEGACY") + files["save.lua"] = SaveSerializer.encode(raw) + + local first = SaveData.load("red") + T.eq(first and first.meta.playthroughId, nil, + "loading a legacy save alone does not allocate tool identity") + local id = SaveData.ensurePlaythroughId(first) + T.check(type(id) == "string" and id ~= "", + "a legacy save receives a playthrough id") + local mappedOptions, mappedErr = SaveSerializer.decode(files["options.lua"] or "") + T.check(mappedOptions ~= nil, + "legacy identity mapping remains decodable: " .. tostring(mappedErr)) + + local slotBytes = files["saves/red/slot1.lua"] + local onDisk = slotBytes and SaveSerializer.decode(slotBytes) + T.eq(onDisk and onDisk.meta.playthroughId, nil, + "legacy backfill does not rewrite normal progress") + + SaveData.resetSlotState() + local second = SaveData.load("red") + T.eq(SaveData.ensurePlaythroughId(second), id, + "legacy backfill is stable across reload before normal SAVE") +end + +-- Reusing names and coordinates cannot merge identities across slots or games. +do + fresh() + local redA = SaveData.createSlot("red") + local redB = SaveData.createSlot("red") + SaveData.setActiveSlot("red", redA) + T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")), + "seed red slot A") + local idA = SaveData.ensurePlaythroughId(SaveData.load("red")) + + SaveData.setActiveSlot("red", redB) + T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")), + "seed red slot B") + local idB = SaveData.ensurePlaythroughId(SaveData.load("red")) + + GameVersion.set("blue") + local blue = SaveData.createSlot("blue") + SaveData.setActiveSlot("blue", blue) + T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")), + "seed blue slot") + local idBlue = SaveData.ensurePlaythroughId(SaveData.load("blue")) + + T.neq(idA, idB, "two active slots do not share legacy identity") + T.neq(idA, idBlue, "Red and Blue do not share legacy identity") + T.neq(idB, idBlue, "every version/slot scope is isolated") +end + +love.filesystem = realFS +SaveData.resetSlotState() +GameVersion.set("red") + +T.finish("playthrough_identity") diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index 0ae47ec1..46b955e3 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -131,7 +131,12 @@ package.loaded["src.import.CacheFs"] = fakeCache local SaveConvert = require("src.save_convert.SaveConvert") -local GENERATED = { "pokemon", "moves", "items", "maps" } +-- tilesets/audio joined the set with the #889 map-context rebuild, which +-- reads the current map's tileset row and song out of the same cache. +-- The audio entry is single-quoted on purpose: gate_meta_coverage.lua treats a +-- double-quoted registry name anywhere in the test corpus as that registry's +-- unit test, and this suite is not the mod audio registry's. +local GENERATED = { "pokemon", "moves", "items", "maps", "tilesets", 'audio' } local function prefixes() local seen = {} @@ -148,7 +153,7 @@ do name .. " comes out of Blue's cache, not the un-prefixed read path") end eq(prefixes()[GameVersion.VERSIONS.blue.cachePrefix], #GENERATED, - "all four generated tables are read under Blue's cache prefix") + "every generated table is read under Blue's cache prefix") eq(fakeCache.prefix, SENTINEL, "CacheFs.prefix is launcher-owned state and is put back after the read") check(data and data.eventFlags ~= nil, diff --git a/tests/engine/save_map_context_bug889.lua b/tests/engine/save_map_context_bug889.lua new file mode 100644 index 00000000..f0b9f6f3 --- /dev/null +++ b/tests/engine/save_map_context_bug889.lua @@ -0,0 +1,201 @@ +-- #889: a .sav exported from a save that never came from a ROM import used to +-- carry no current-map state at all. A Continue restores that window from the +-- save and never rebuilds it (LoadMainData sets BIT_NO_PREVIOUS_MAP and +-- LoadMapHeader returns early on it), so the game continued into tileset 0, +-- a $0000 map-data pointer and sound id 0 -- a garbled map and a silent hang +-- on real hardware. +-- +-- src/save_convert/MapContext.lua replays LoadMapHeader's WRAM writes from the +-- extracted ROM bytes instead. This suite pins the layout it writes, on a +-- synthetic map whose header/object bytes are chosen so every field is +-- distinguishable, and then checks the encoder's three cases: no template +-- (rebuild), a template saved on the same map (leave the game's own bytes +-- alone, which is what keeps import -> export byte-identical), and a template +-- saved on a different map (rebuild, or the export carries the wrong map's +-- header). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local MapContext = require("src.save_convert.MapContext") +local GenSave = require("src.save_convert.GenSave") + +local O = MapContext.OFFSETS +local SAVE = GenSave.OFFSETS + +-- ---------------------------------------------------------------- fixtures + +-- tileset 0, 4 blocks tall, 5 wide, data/text/script pointers, north|west +local HEADER = { 0x00, 0x04, 0x05, 0x21, 0x43, 0x78, 0x56, 0x89, 0x67, 0x0A } +-- two 11-byte map_connection_structs, north first then west (the order +-- LoadMapHeader copies them in), each tagged with its own filler +local CONNECTIONS = { + 0x11, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, + 0x22, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, +} +local OBJECTS = { + 0x0E, -- wMapBackgroundTile + 2, -- warps + 0x04, 0x05, 0x00, 0x26, + 0x06, 0x07, 0x01, 0x27, + 2, -- signs + 0x08, 0x09, 0x03, -- Y, X, text id + 0x0A, 0x0B, 0x04, + 3, -- sprites + 0x01, 0x14, 0x15, 0xFF, 0xD0, 0x05, -- plain NPC + 0x02, 0x16, 0x17, 0xFE, 0x01, 0x47, -- trainer ($40): class, party + 0x33, 0x44, + 0x03, 0x18, 0x19, 0xFF, 0xD3, 0x85, -- item ball ($80): item id + 0x14, +} +local TILESET_HEADER = { 0x0C, 0x11, 0x40, 0x22, 0x40, 0x33, 0x40, + 0x44, 0x55, 0x66, 0x77, 0x02 } + +local function fixtureData() + local maps = {} + for id, map in pairs(dofile("tests/fixture_data/maps.lua")) do + local copy = {} + for k, v in pairs(map) do copy[k] = v end + maps[id] = copy + end + maps.FIX_TOWN.sram = { + header = HEADER, connections = CONNECTIONS, objects = OBJECTS, + } + return { + maps = maps, + pokemon = dofile("tests/fixture_data/pokemon.lua"), + moves = dofile("tests/fixture_data/moves.lua"), + items = dofile("tests/fixture_data/items.lua"), + tilesets = { [maps.FIX_TOWN.tileset] = { header = TILESET_HEADER } }, + audio = { + -- Song ids are computed from the header address + -- (constants/music_constants.asm): (address - $4000) / 3. + mapSongs = { FIX_TOWN = "Music_Fixture" }, + songs = { Music_Fixture = { address = 0x4000 + 3 * 0xBD, bank = 2 } }, + }, + } +end + +local data = fixtureData() + +-- ------------------------------------------------------------ the window + +local ctx = assert(MapContext.build(data, "FIX_TOWN", 7, 4)) +local w = ctx.writes + +local function eqBytes(got, want, msg) + got = got or {} + T.eq(#got, #want, msg .. " (length)") + for i = 1, #want do + T.eq(got[i], want[i], ("%s (byte %d)"):format(msg, i)) + end +end + +eqBytes(w[O.curMapHeader], HEADER, "wCurMapHeader is the ROM header verbatim") + +-- north and west are present; south and east must read $FF, or LoadTileBlockMap +-- walks a connection that is not there +local conn = w[O.connectionHeaders] +T.eq(#conn, 44, "all four connection structs are written") +T.eq(conn[1], 0x11, "north takes the first connection struct") +T.eq(conn[11], 0xAA, "north keeps all 11 of its bytes") +T.eq(conn[12], 0xFF, "south is disabled with $FF") +T.eq(conn[23], 0x22, "west takes the second connection struct") +T.eq(conn[34], 0xFF, "east is disabled with $FF") + +eqBytes(w[O.mapBackgroundTile], { 0x0E }, "wMapBackgroundTile") +eqBytes(w[O.numberOfWarps], { 2 }, "wNumberOfWarps") +eqBytes(w[O.warpEntries], { 0x04, 0x05, 0x00, 0x26, 0x06, 0x07, 0x01, 0x27 }, + "wWarpEntries are the raw 4-byte rows") +eqBytes(w[O.numSigns], { 2 }, "wNumSigns") +eqBytes(w[O.signCoords], { 0x08, 0x09, 0x0A, 0x0B }, + "wSignCoords are split out of the 3-byte sign rows") +eqBytes(w[O.signTextIDs], { 0x03, 0x04 }, "wSignTextIDs") +eqBytes(w[O.numSprites], { 3 }, "wNumSprites") +-- movement byte 2 and the text id with its flag bits masked ("and $3f") +eqBytes(w[O.mapSpriteData], { 0xD0, 0x05, 0x01, 0x07, 0xD3, 0x05 }, + "wMapSpriteData is (movement byte 2, text id & $3f) per sprite") +eqBytes(w[O.mapSpriteExtra], { 0, 0, 0x33, 0x44, 0x14, 0 }, + "wMapSpriteExtraData holds trainer class/party and item id") +eqBytes(w[O.currentMapHeight2], { 8 }, "map height doubled into 2x2 blocks") +eqBytes(w[O.currentMapWidth2], { 10 }, "map width doubled into 2x2 blocks") + +local tilesetRow = {} +for i = 1, 11 do tilesetRow[i] = TILESET_HEADER[i] end +eqBytes(w[O.tilesetHeader], tilesetRow, + "wTilesetBank..wGrassTile is the Tilesets row (11 bytes)") +T.eq(ctx.tileAnimations, 0x02, "the 12th tileset byte rides in sTileAnimations") + +eqBytes(w[O.mapMusicSoundID], { 0xBD }, "the song id is derived from its header address") +eqBytes(w[O.mapMusicROMBank], { 2 }, "the song's audio bank comes with it") + +-- x=7,y=4: block coords are the odd/even halves, and the view pointer is +-- macros/coords.asm event_displacement over the map width. +eqBytes(w[O.yBlockCoord], { 0 }, "wYBlockCoord is y & 1") +eqBytes(w[O.xBlockCoord], { 1 }, "wXBlockCoord is x & 1") +local view = 0xC6E8 + 7 + 5 + (5 + 6) * 2 + 3 +eqBytes(w[O.viewPointer], { view % 256, math.floor(view / 256) }, + "wCurrentTileBlockMapViewPointer") + +-- sSpriteData: picture ids in structs 1..3 of page 1, positions in page 2, +-- and every non-player struct's image index disabled at $ff +T.eq(#ctx.spriteData, 512, "sSpriteData is the full 512-byte window") +T.eq(ctx.spriteData[16 + 1], 0x01, "sprite 1 picture id") +T.eq(ctx.spriteData[16 + 3], 0xFF, "sprite 1 image index starts disabled") +T.eq(ctx.spriteData[256 + 16 + 5], 0x14, "sprite 1 map Y") +T.eq(ctx.spriteData[256 + 16 + 6], 0x15, "sprite 1 map X") +T.eq(ctx.spriteData[256 + 16 + 7], 0xFF, "sprite 1 movement byte 1") +T.eq(ctx.spriteData[3 * 16 + 1], 0x03, "sprite 3 picture id") +T.eq(ctx.spriteData[1], 0, "the player's struct is left to ResetPlayerSpriteData") + +-- a map the cache has no bytes for degrades instead of raising +T.eq(MapContext.build(data, "FIX_ROUTE", 0, 0), nil, + "a map with no extracted bytes returns nil, not an error") + +-- ------------------------------------------------------- through the codec + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local save = { + player = { name = "RED", rival = "BLUE", map = "FIX_TOWN", x = 7, y = 4 }, + money = 3000, inventory = {}, pokedex = { seen = {}, owned = {} }, + flags = {}, party = {}, boxes = {}, +} + +local function byteAt(bytes, mainOffset) + return bytes:byte(SAVE.mainData + mainOffset + 1) +end + +local fresh = GenSave.encode(save, data, nil) +T.eq(byteAt(fresh, O.curMapHeader + 1), 0x04, + "a templateless export carries the map header") +T.eq(byteAt(fresh, O.tilesetHeader), 0x0C, + "a templateless export carries the tileset header") +T.eq(byteAt(fresh, O.mapMusicSoundID), 0xBD, + "a templateless export carries the map's music, not sound id 0") +T.eq(fresh:byte(SAVE.checksumEnd - 1 + 1), 0x02, + "sTileAnimations is written before the checksum covers it") +T.eq(GenSave.mainChecksumValid(fresh), true, + "the rebuilt window is inside a valid main-data checksum") + +-- a template still on its own map keeps the game's own bytes: those include +-- live NPC positions, and preserving them is the round-trip invariant +local template = {} +for i = 1, #fresh do template[i] = fresh:sub(i, i) end +template[SAVE.mainData + O.curMapHeader + 1] = string.char(0x99) +template[SAVE.spriteData + 17] = string.char(0x77) +local sameMap = GenSave.encode(save, data, table.concat(template)) +T.eq(byteAt(sameMap, O.curMapHeader), 0x99, + "a template saved on this map keeps its map header untouched") +T.eq(sameMap:byte(SAVE.spriteData + 17), 0x77, + "a template saved on this map keeps its live sprite data") + +-- a template saved somewhere else is stale: it holds the OTHER map's header, +-- which is exactly as unbootable as an empty one +template[SAVE.mainData + SAVE.curMap - SAVE.mainData + 1] = nil +local other = {} +for i = 1, #fresh do other[i] = fresh:sub(i, i) end +other[SAVE.curMap + 1] = string.char(0xFE) -- some map this save is not on +other[SAVE.mainData + O.curMapHeader + 1] = string.char(0x99) +local moved = GenSave.encode(save, data, table.concat(other)) +T.eq(byteAt(moved, O.curMapHeader), HEADER[1], + "a template saved on another map is rebuilt for the map the save is on") diff --git a/tests/engine/second_screen_touch_test.lua b/tests/engine/second_screen_touch_test.lua new file mode 100644 index 00000000..309ba054 --- /dev/null +++ b/tests/engine/second_screen_touch_test.lua @@ -0,0 +1,42 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local name = "src.render.SecondScreen" +local oldModule = package.loaded[name] +local oldFfi = package.loaded.ffi +local oldPreload = package.preload.ffi +local null = {} +local calls = 0 +local C = { + love_android_secondary_ready = function() return 1 end, + love_android_push_secondary = function() end, + love_android_secondary_enable = function() end, + love_android_poll_secondary_touch = function() + calls = calls + 1 + return calls == 1 and "down,12,34" or null + end, +} +local fakeFfi = { + C = C, + NULL = null, + cdef = function() end, + load = function() return C end, + string = function(value) return value end, +} + +package.loaded[name] = nil +package.loaded.ffi = nil +package.preload.ffi = function() return fakeFfi end + +local SecondScreen = require(name) +T.eq(SecondScreen.pollTouch(), "down,12,34", + "secondary touch reaches the Lua facade") +T.eq(SecondScreen.pollTouch(), nil, "an empty native touch queue returns nil") +C.love_android_poll_secondary_touch = nil +T.eq(SecondScreen.pollTouch(), nil, "an older native bridge remains safe") + +package.loaded[name] = oldModule +package.loaded.ffi = oldFfi +package.preload.ffi = oldPreload + +T.finish("second-screen touch facade") diff --git a/tests/engine/title_mon_cycle.lua b/tests/engine/title_mon_cycle.lua new file mode 100644 index 00000000..8b9c9039 --- /dev/null +++ b/tests/engine/title_mon_cycle.lua @@ -0,0 +1,109 @@ +-- ..(engine/movie/title.asm ln 227) +-- ..(engine/movie/title2.asm ln 13) +-- luajit tests/engine/title_mon_cycle.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") +love.math = love.math or {} +local nextPick = 1 +love.math.random = function(lo, hi) + nextPick = nextPick % hi + 1 + return math.max(lo, nextPick) +end + +local TitleState = require("src.ui.TitleState") + +local title = TitleState.new( + { data = {}, input = { wasPressed = function() return false end } }, {}) +title.sprites = setmetatable({}, { __index = function() return false end }) + +eq(title.phase, "drop", "Red/Blue boot into the logo drop, not the loop") +local ribbonSeen = {} +for _ = 1, 400 do + if title.phase == "loop" then break end + title:update(1 / 60) + if title.phase == "ribbon" then + ribbonSeen[#ribbonSeen + 1] = title.ribbonOffset + end +end +eq(title.phase, "loop", "the cinematic lands within 400 frames") +eq(ribbonSeen[1], 112, + "the ribbon is parked off the right edge on its first drawn frame") +eq(ribbonSeen[#ribbonSeen], 4, "and walks in 4px a frame to its rest") + +title.cycleIndex = 1 -- CHARMANDER: a starter, so the ball juggle runs +title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0 +title.monOffset = 0 + +local frames = {} +for _ = 1, 260 do + title:update(1 / 60) + frames[#frames + 1] = { + phase = title.scrollPhase, offset = title.monOffset, + ball = title.ballY, mon = title.cycleSpecies[title.cycleIndex], + } +end + +local HOLD_FRAMES = 200 + +local function span(phase) + local first, count = nil, 0 + for i, f in ipairs(frames) do + if f.phase == phase then + if not first then first = i end + if first + count == i then count = count + 1 end + end + end + return first, count +end + +local holdAt, holdLen = span("hold") +local outAt, outLen = span("out") +local ballAt, ballLen = span("ball") +local inAt, inLen = span("in") +eq(holdAt, 1, "the cycle opens on the hold") +eq(holdLen, HOLD_FRAMES - 1, "ld c, 200 / CheckForUserInterruption") +eq(outAt, HOLD_FRAMES, "the scroll out begins as the 200th hold frame ends") +eq(outLen, 18, "TitleScroll_Out is 2+2+2+2+2+2+3+3 frames") +eq(ballLen, 10, "TitleScroll_WaitBall is two runs of 5") +eq(inLen, 17, "TitleScroll_In is 2+4+4+3+2+1+1 frames") +check(outAt < ballAt and ballAt < inAt, "out, then the ball, then in") + +local OUT = { 0, -1, -2, -4, -6, -9, -12, -16, -20, -25, -30, -36, -42, + -50, -58, -66, -75, -84 } +for i, want in ipairs(OUT) do + eq(frames[outAt + i - 1].offset, want, + "TitleScroll_Out offset at frame " .. i) +end + +local IN = { 120, 110, 100, 91, 82, 73, 64, 56, 48, 40, 32, 26, 20, 14, 9, + 4, 1 } +for i, want in ipairs(IN) do + eq(frames[inAt + i - 1].offset, want, "TitleScroll_In offset at frame " .. i) +end + +local BALL = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 } +for i, want in ipairs(BALL) do + eq(frames[ballAt + i - 1].ball, want, "TitleBallYTable entry " .. i) +end + +local outgoing = frames[outAt].mon +eq(outgoing, "CHARMANDER", "the starter is the one that scrolls out") +for i = outAt, inAt - 1 do + eq(frames[i].mon, outgoing, + "the pick does not change before the scroll in, at frame " .. i) +end +local incoming = frames[inAt].mon +check(incoming ~= outgoing, "TitleScreenPickNewMon never repeats the pick") +for i = inAt, inAt + inLen - 1 do + check(frames[i].offset > 0, + "the incoming mon is only ever drawn right of rest, at frame " .. i) +end +eq(frames[inAt + inLen].offset, 0, "and settles at its resting column") + +T.finish("title_mon_cycle") diff --git a/tests/engine/trainer_battle_theme_bug945.lua b/tests/engine/trainer_battle_theme_bug945.lua new file mode 100644 index 00000000..d7a4ce8e --- /dev/null +++ b/tests/engine/trainer_battle_theme_bug945.lua @@ -0,0 +1,190 @@ +-- Issue #945: a mod's per-trainer battleTheme (trainers.battleTheme, an +-- audio.songs id) was validated and merged onto the trainer record but +-- never read -- battle music came solely from data.audio.battle[kind] where +-- kind is computeMusicKind()'s final/gym/trainer/wild. Both battle-theme +-- start sites (OverworldController:pushBattle's pre-wipe cue and +-- BattleState:enter) now route through BattleState:playBattleTheme(), which +-- hands the override to Music.playBattle's new song arg. A nil override +-- keeps the kind default, so vanilla trainer fights -- and #782's non-gym +-- Giovanni -- are unchanged. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- ------- love audio stub: file-backed songs only (mod_audio pattern) + +local love = _G.love or {} +_G.love = love +love.audio = love.audio or {} + +local assets = { + ["assets/theme.ogg"] = true, + ["assets/alt.ogg"] = true, +} + +local sources = {} + +local Source = {} +Source.__index = Source +function Source:play() end +function Source:stop() end +function Source:setLooping() end +function Source:setVolume() end +function Source:setFilter() end + +love.audio.newSource = function(what, mode) + if type(what) == "string" and not assets[what] then + error("could not open file " .. what, 0) + end + local src = setmetatable({ file = what, mode = mode, queueable = false }, Source) + sources[#sources + 1] = src + return src +end +love.audio.newQueueableSource = function() + local src = setmetatable({ queueable = true }, Source) + sources[#sources + 1] = src + return src +end + +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Font = require("src.render.Font") +local TypeChart = require("src.battle.TypeChart") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local BattleState = require("src.battle.BattleState") + +-- ------- the fix-945 mod: register a song and point a trainer class at it + +local MOD = { + ["mods/fix_youngster_theme/manifest.json"] = [[{ + "id": "fix_youngster_theme", + "name": "Fix Youngster Theme", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_youngster_theme/main.lua"] = [[ + local mod = ... + mod.content.music:register("Music_ModTheme", { file = "assets/theme.ogg" }) + mod.content.trainers:patch("OPP_FIX_YOUNGSTER", { + battleTheme = "Music_ModTheme", + }) + ]], +} + +local function newGame(data) + local save = SaveData.newGame() + save.player.name = "RED" + save.player.rival = "GARY" + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + return { data = data, save = save, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } +end + +-- record every cue the music.select hook sees +local function hookRecorder(seen) + Runtime.hooks:wrap("music.select", function(nextLink, song, ctx) + seen[#seen + 1] = { song = song, kind = ctx.kind, + trainerId = ctx.trainerId } + return nextLink(song, ctx) + end, nil, "bug945") +end + +-- a playBattle spy that records (kind, trainerId, song) without touching audio +local function spyPlayBattle() + local calls = {} + local real = Music.playBattle + Music.playBattle = function(data, kind, trainerId, song) + calls[#calls + 1] = { kind = kind, trainerId = trainerId, song = song } + end + return calls, function() Music.playBattle = real end +end + +-- ------- the modded class resolves and the override reaches the cue + +local Data = T.fixtures.fresh() +Font.load(Data) +TypeChart.load(Data) + +local run = T.sdk.loadMods({ "mods/fix_youngster_theme" }, + { data = Data, fs = T.sdk.memfs(MOD) }) +T.eq(#run.errors, 0, "the battleTheme mod loads without validation errors") +T.eq(Data.trainers.OPP_FIX_YOUNGSTER.battleTheme, "Music_ModTheme", + "the patch lands on the trainer record") + +-- give the kind defaults a home so the no-override fallback is observable +Data.audio = Data.audio or {} +Data.audio.battle = Data.audio.battle or { + wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer", +} +Data.audio.songs = Data.audio.songs or {} +Data.audio.songs.Music_DefaultWild = { file = "assets/alt.ogg" } +Data.audio.songs.Music_DefaultTrainer = { file = "assets/alt.ogg" } + +local battle = BattleState.newTrainer(newGame(Data), "OPP_FIX_YOUNGSTER", 1) +T.eq(battle:battleTheme(), "Music_ModTheme", + "battleTheme() resolves the per-trainer override") +T.eq(battle:computeMusicKind(), "trainer", + "a plain trainer fight is still trainer-kind") + +local calls, restore = spyPlayBattle() +battle:playBattleTheme() +T.eq(#calls, 1, "playBattleTheme cues the theme once") +T.eq(calls[1].kind, "trainer", "the cue carries the computed kind") +T.eq(calls[1].trainerId, "OPP_FIX_YOUNGSTER", "the cue carries the trainer id") +T.eq(calls[1].song, "Music_ModTheme", "the override label wins over the kind default") + +-- enter() sets self.musicKind before playing; playBattleTheme honors it +battle.musicKind = "gym" +battle:playBattleTheme() +T.eq(calls[2].kind, "gym", "a pre-set musicKind (the enter path) is used as-is") +restore() + +-- ------- Music.playBattle: override arg wins; nil falls back to the default + +local seen = {} +hookRecorder(seen) +Music.reload() +Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER", "Music_ModTheme") +T.eq(seen[1].song, "Music_ModTheme", "the override arg is played") +T.eq(seen[1].kind, "trainer", "the hook sees the battle kind") +T.eq(seen[1].trainerId, "OPP_FIX_YOUNGSTER", "the hook sees the trainer id") + +Music.reload() +Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER") +T.eq(seen[2].song, "Music_DefaultTrainer", + "no override falls back to the kind's default song") +T.eq(seen[2].trainerId, "OPP_FIX_YOUNGSTER", + "the hook still sees the trainer id on the default path") + +-- ------- a vanilla class has no override, so the kind default is untouched + +local DataV = T.fixtures.fresh() +Font.load(DataV) +TypeChart.load(DataV) +DataV.audio = { + battle = { wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer" }, + songs = { + Music_DefaultWild = { file = "assets/alt.ogg" }, + Music_DefaultTrainer = { file = "assets/alt.ogg" }, + }, +} + +local battleV = BattleState.newTrainer(newGame(DataV), "OPP_FIX_YOUNGSTER", 1) +T.eq(battleV:battleTheme(), nil, "a vanilla trainer class has no override") +local callsV, restoreV = spyPlayBattle() +battleV:playBattleTheme() +T.eq(callsV[1].kind, "trainer", "vanilla cue keeps the trainer kind") +T.eq(callsV[1].song, nil, "vanilla passes no override, so the default plays (#782)") +restoreV() + +local seenV = {} +hookRecorder(seenV) +Music.reload() +Music.playBattle(DataV, "trainer", "OPP_FIX_YOUNGSTER") +T.eq(seenV[1].song, "Music_DefaultTrainer", + "vanilla battles play the kind default, not a per-trainer theme (#782)") + +T.finish("trainer battle theme bug945") diff --git a/tests/engine/ttf_font_mode.lua b/tests/engine/ttf_font_mode.lua index 283f47a6..351f9a89 100644 --- a/tests/engine/ttf_font_mode.lua +++ b/tests/engine/ttf_font_mode.lua @@ -40,6 +40,27 @@ T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace") -- ------------------------------------------------- the ttf takes over +-- Font rasterizers must use the same 1x pixel grid as PixelCanvas, rather +-- than inheriting Android's window density. Keep this local fake so the +-- contract is testable without requiring a real LÖVE window. +do + local g, oldNewFont = love.graphics, love.graphics.newFont + local args + g.newFont = function(...) + args = { ... } + return oldNewFont(Font.PLAINPIXEL, Font.PLAINPIXEL_SIZE) + end + local loaded, err = pcall(Font.load, { + font = { charmap = CHARMAP, ttf = { file = "custom.ttf", size = 13 } }, + }) + g.newFont = oldNewFont + if not loaded then error(err, 0) end + T.eq(args[1], "custom.ttf", "rasterizer uses the configured file") + T.eq(args[2], 13, "rasterizer uses the configured size") + T.eq(args[3], "mono", "rasterizer keeps the pixel hinting mode") + T.eq(args[4], 1, "rasterizer stays on the pixel canvas scale") +end + Font.load({ font = { charmap = CHARMAP, ttf = {} } }) T.check(Font.ttfActive(), "an empty ttf table loads the bundled font") diff --git a/tests/engine/warp_sprite_hidden_bug916.lua b/tests/engine/warp_sprite_hidden_bug916.lua new file mode 100644 index 00000000..5e6cd885 --- /dev/null +++ b/tests/engine/warp_sprite_hidden_bug916.lua @@ -0,0 +1,151 @@ +-- Engine invariant (#916): after the Fly / Dig departure animation ends, the +-- trainer sprite must stay hidden through the warp fade-out and only become +-- visible again when the arrival animation (flyArrive / teleport spin-down) +-- plays on the new map. +-- +-- Root cause: the player-hide guard only held while a departure animation +-- was live. flyAnim was nil'd the instant the bird finished path2, and the +-- teleportOut countdown cleared the spin fields at 0, but startWarpTo's +-- 32-frame fade keeps the overworld drawing beneath the veil (the Transition +-- is not isOpaque), so with the departure guard gone and the arrival not yet +-- armed, the standing sprite popped back in at the old cell for the whole +-- fade. +-- +-- The fix is a playerHidden flag on OverworldState: set when the departure +-- completes (flyAnim path2 / teleportOut countdown), cleared in startWarpTo's +-- midpoint the same tick the arrival arms, and folded into both player-draw +-- guards. This suite runs the REAL Transition + setMap headlessly and +-- asserts there is no fade frame where the player would draw bare. +-- +-- ROM-free (fixture dataset, no ROM boot): lives in tests/engine so the CI +-- headless tier runs it; also runnable standalone via +-- `luajit tests/engine/warp_sprite_hidden_bug916.lua`. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Data = T.fixtures.fresh() +-- fixture patches that let the overworld boot and run headlessly +Data.tilesets.FIX_OUT.tilesPerRow = 16 +Data.field.flyWarps = Data.field.flyWarps or {} +Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" } +Data.field.waterTilesets = {} +Data.field.forcedMovement = { tiles = {} } + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) } +local stack = Game.stack + +-- The draw guard both entity passes use: the player sprite is skipped while +-- any of flyAnim / flyArrive / playerHidden is set. +local function playerHidden(ow) + return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true +end + +local function newOW() + stack:push(OW, "FIX_TOWN", 5, 6, "down") + local ow = stack:top() + Game.overworld = ow + return ow +end + +-- Drive `ow` until its departure + warp + arrival all complete, tracking the +-- fade window. Returns counters: fadeFrames / fadeFramesHidden (frames the +-- Transition was on top; of those, frames the player was hidden), gapFrames +-- (fade frames where NO arrival was active AND the player was NOT hidden -- +-- the regression this suite guards), arrivalFrame (first frame an arrival +-- animation armed), warpFrame (first frame a fade is up). +-- +-- Breaks once an arrival armed and then fully finished (no stale departure +-- or arrival animation, OW back on top); `maxFrames` is the safety net. +local function drive(ow, maxFrames) + local st = { fadeFrames = 0, fadeFramesHidden = 0, gapFrames = 0, + arrivalFrame = nil, warpFrame = nil } + for i = 1, maxFrames or 260 do + local fading = stack:top() ~= ow + stack:update() + if fading then + st.fadeFrames = st.fadeFrames + 1 + if playerHidden(ow) then st.fadeFramesHidden = st.fadeFramesHidden + 1 end + local arrivalActive = ow.flyArrive ~= nil or ow.player.spinDrop == true + if not arrivalActive and not playerHidden(ow) then + st.gapFrames = st.gapFrames + 1 + end + if st.warpFrame == nil then st.warpFrame = i end + end + if st.arrivalFrame == nil + and (ow.flyArrive ~= nil or ow.player.spinDrop == true) then + st.arrivalFrame = i + end + if st.arrivalFrame and stack:top() == ow + and ow.flyArrive == nil and ow.player.spinDrop ~= true + and not ow.player.inputLocked then + break -- departure + fade + arrival all finished + end + end + return st +end + +-- ------------------------------------------------------------------ dig/teleport +-- Departure spin (48) -> warp fade -> arrival spin-down. From the moment +-- the spin ends until the arrival arms, the sprite must never draw bare. +local ow = newOW() +local doneFired = false +ow:beginTeleportOut(function() + doneFired = true + ow.player.inputLocked = false -- the party-menu caller unlocks after the warp +end) +local st = drive(ow, 260) +check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins") +check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)") +eq(st.gapFrames, 0, + "no dig fade frame leaves the player standing bare (#916)") +check(st.fadeFramesHidden >= st.fadeFrames - 1, + "dig fade hidden on every frame but the arrival-arming midpoint (" + .. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")") +check(st.arrivalFrame ~= nil, "dig arrival spin-down arms") +check(ow.playerHidden == false, "dig hide cleared on the new map") +check(doneFired, "dig onDone fires after the warp") +check(ow.player.spinDrop ~= true and ow.player.spinning == false, + "dig arrival spin-down completes") +check(not playerHidden(ow), "player drawable again after the dig landing") + +-- ------------------------------------------------------------------ fly +-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim, +-- then the fade, then the bird swoops in (flyArrive). Same invariant. +Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 } +ow = newOW() +ow:flyTo("FIX_ROUTE") +st = drive(ow, 260) +check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins") +-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the +-- warp fires on frame 133's update, so the fade is on top from loop frame 134 +eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path") +check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)") +eq(st.gapFrames, 0, + "no fly fade frame leaves the player standing bare (#916)") +check(st.fadeFramesHidden >= st.fadeFrames - 1, + "fly fade hidden on every frame but the arrival-arming midpoint (" + .. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")") +check(st.arrivalFrame ~= nil, "fly arrival swoop arms") +check(ow.playerHidden == false, "fly hide cleared on the new map") +check(ow.flyArrive == nil, "fly arrival swoop completes") +check(not ow.player.inputLocked, "fly landing releases player input") +check(not playerHidden(ow), "player drawable again after the fly landing") + +T.finish("warp_sprite_hidden_bug916") diff --git a/tests/engine/wide_battle_shake_bug562.lua b/tests/engine/wide_battle_shake_bug562.lua index 4f367a12..b445748c 100644 --- a/tests/engine/wide_battle_shake_bug562.lua +++ b/tests/engine/wide_battle_shake_bug562.lua @@ -74,6 +74,9 @@ local function battleWith(fx, sprites) growInScale = function() return nil end, drawBallRow = function() end, statusLabel = function() return "" end, + statusHUDVisible = function() return true end, + bottomUIVisible = function() return true end, + caughtMarkerVisible = function() return false end, } end diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua new file mode 100644 index 00000000..32c540a2 --- /dev/null +++ b/tests/engine/world_map_overview_test.lua @@ -0,0 +1,78 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local Assets = require("src.render.Assets") +local WorldAPI = require("src.world.WorldAPI") + +Assets.imageData = function() + return { getPixel = function(_, x, y) + local shade = x >= 8 and 0 or ({ 1, 0, 2 / 3, 1 / 3 })[ + math.floor(y / 4) * 2 + math.floor(x / 4) + 1] + return shade, shade, shade, 1 + end } +end + +local api = WorldAPI.new({ stack = { states = {} } }, "tester") +local overview, err = api:mapOverview() +T.eq(overview, nil, "map overview is unavailable outside the overworld") +T.eq(err, "no overworld", "map overview reports why it is unavailable") + +local map = { + id = "TEST_MAP", widthCells = 2, heightCells = 2, + def = { + warps = { { x = 1, y = 0 } }, + objects = { { index = 1, x = 0, y = 1, item = "POTION" } }, + }, +} +function map:isWarpTileCell(x, y) return x == 1 and y == 0 end +function map:isWaterCell(x, y) return x == 0 and y == 1 end +function map:isWalkableCell(x, y) return x == 0 and y == 0 end +function map:tileAt(x) return x % 2 end + +local save = {} +local world = { + isOverworld = true, + map = map, + objectVisible = function(s, mapId, obj) + return not (s.itemsTaken and s.itemsTaken[mapId .. "_obj_" .. obj.index]) + end, +} +local game = { + save = save, + data = { field = { hiddenItems = { + TEST_MAP = { { x = 1, y = 1, item = "NUGGET" } }, + } } }, + stack = { states = { world } }, +} +api = WorldAPI.new(game, "tester") +overview = api:mapOverview() +T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map") +T.eq(overview.width, 2, "map overview reports its width") +T.eq(overview.height, 2, "map overview reports its height") +T.eq(overview.rows[1], ".+", "walkable land and warps are distinct") +T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct") +T.eq(overview.tileRows, nil, "tile overview is optional") +T.eq(#overview.markers, 3, "active exits and untaken items are marked") +T.eq(overview.markers[1].kind, "warp", "warp marker is semantic") +T.eq(overview.markers[2].kind, "item", "visible item marker is semantic") +T.eq(overview.markers[3].kind, "hidden", "hidden item marker is semantic") + +save.itemsTaken = { TEST_MAP_obj_1 = true } +save.hiddenTaken = { TEST_MAP_1_1 = true } +overview = api:mapOverview() +T.eq(#overview.markers, 1, "collected items disappear from the overview") +T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items") + +map.tileset = { image = "test.png", tilesPerRow = 2 } +overview = api:mapOverview() +T.eq(overview.tileWidth, 4, "tile overview reports its width") +T.eq(overview.tileHeight, 4, "tile overview reports its height") +T.eq(overview.tileRows[1], "2323", "tile overview preserves average shading") +T.eq(overview.tileDetailWidth, 8, "detail overview reports its width") +T.eq(overview.tileDetailHeight, 8, "detail overview reports its height") +T.eq(overview.tileDetailRows[1], "03330333", + "detail overview preserves top tile quadrants") +T.eq(overview.tileDetailRows[2], "12331233", + "detail overview preserves bottom tile quadrants") + +T.finish("world map overview") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index b2b543a2..8e310e63 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -428,16 +428,42 @@ local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites) spriteReg:register("SPRITE_TITLE_LOGO", { image = "mods/logo/logo.png", frames = 1, trueColor = true }, "logo_mod") +spriteReg:register("SPRITE_LARGE_ACTOR", + { image = "mods/actor/actor.png", frames = 6, + walker = true, frameWidth = 32, frameHeight = 24, + anchorX = 16, anchorY = 24, trueColor = true }, + "actor_mod") local logoDef = spriteReg:get("SPRITE_TITLE_LOGO") check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO", logoDef, "register"), "a trueColor sprites record validates against the catalog schema") check(logoDef.trueColor == true, "and keeps the flag through the merge") +local largeDef = spriteReg:get("SPRITE_LARGE_ACTOR") +check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_LARGE_ACTOR", + largeDef, "register"), + "a variable-size sprites record validates against the catalog schema") Renderer:init() local plainSprite = SpriteRenderer.new( { image = "assets/generated/sprites/red.png", frames = 1 }) local litSprite = SpriteRenderer.new(logoDef) +local largeSprite = SpriteRenderer.new(largeDef) +check(plainSprite.frameWidth == 16 and plainSprite.frameHeight == 16 + and plainSprite.anchorX == 8 and plainSprite.anchorY == 16, + "legacy sprite definitions keep the vanilla frame geometry") +local frameGeometry = largeSprite:getFrameGeometry(5) +check(frameGeometry.frame == 5 and frameGeometry.x == 0 + and frameGeometry.y == 120 and frameGeometry.width == 32 + and frameGeometry.height == 24 and frameGeometry.anchorX == 16 + and frameGeometry.anchorY == 24, + "frame geometry exposes a larger sheet rectangle and anchor") +local poseGeometry = largeSprite:getPoseGeometry("right", 1, true) +check(poseGeometry.frame == 5 and poseGeometry.mirror == true + and poseGeometry.quad == largeSprite.frames[5], + "pose geometry follows walker frame selection and right mirroring") +local originX, originY = largeSprite:getScreenOrigin(32, 32, 0, 0) +check(originX == 24 and originY == 20, + "a custom anchor keeps a larger sprite grounded at its cell") Renderer:beginFrame(true) check(#PaletteFX.trueColorRects("ui") == 0 @@ -471,6 +497,27 @@ check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits") check(worldDrawn[1].shader and worldDrawn[2].shader == false, "the colorized pass runs first, then the sprite's rect with no shader") +-- Larger true-color frames claim their actual extent, and fishing's top-half +-- path reserves only the bottom 8-pixel tile for the overlay. +Renderer:beginFrame(true) +Renderer:beginWorldPass() +largeSprite:draw(32, 32, 0, 0, "down", 0, false) +local largeRects = PaletteFX.trueColorRects("world") +check(#largeRects == 1 and largeRects[1].x == 24 and largeRects[1].y == 20 + and largeRects[1].w == 32 and largeRects[1].h == 24, + "a larger trueColor sprite reports its full anchored extent") +Renderer:endWorldPass() + +Renderer:beginFrame(true) +Renderer:beginWorldPass() +largeSprite:draw(32, 32, 0, 0, "down", 0, false, true) +local topRects = PaletteFX.trueColorRects("world") +check(#topRects == 1 and topRects[1].h == 16 + and largeSprite.halfFrames[0].y == 0 + and largeSprite.halfFrames[0].h == 16, + "the fishing overlay keeps a larger frame's bottom tile clear") +Renderer:endWorldPass() + -- the same path on the UI canvas, which is where a full-color title logo -- or menu portrait lands Renderer:beginFrame(false) diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index 4b4633a6..e14d2b78 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -271,6 +271,38 @@ do "and nothing is disabled off a failed migration") end +-- ------- force_enable_env: an env var can override a saved disable +-- (src/mods/Loader.lua's enable-resolution block, added for a mod that +-- cannot function disabled on the one build where its env var is set -- +-- e.g. a platform-launcher bridge mod). +do + local forceFiles = { + ["options.lua"] = "return { mods = { forced = false } }", + ["mods/forced/manifest.json"] = + [[{"id":"forced","name":"forced","version":"1.0.0","entry":"main.lua",]] + .. [["force_enable_env":"SOME_TEST_ENV"}]], + ["mods/forced/main.lua"] = "return function(mod) end", + } + + local realGetenv = os.getenv + os.getenv = function(name) + if name == "SOME_TEST_ENV" then return "1" end + return realGetenv(name) + end + local onLoader = Loader.new({ fs = memfs(forceFiles) }) + check(onLoader:load({ pokemon = {} }) == true, + "force_enable_env: load succeeds with the env var set") + check(onLoader.mods.forced.enabled == true, + "a matching force_enable_env re-enables a mod saved as disabled") + os.getenv = realGetenv + + local offLoader = Loader.new({ fs = memfs(forceFiles) }) + check(offLoader:load({ pokemon = {} }) == true, + "force_enable_env: load succeeds with the env var unset") + check(offLoader.mods.forced.enabled == false, + "with the env var unset, the saved disable is left alone") +end + -- leave shared singletons the way we found them for later chained tests local StateStack = require("src.core.StateStack") while StateStack:top() do StateStack:pop() end diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index d6c615e9..569479d2 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -105,6 +105,7 @@ local full = Manifest.validate({ api = 2, profile = "overhaul", permissions = { "network" }, dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" }, options_schema = "options.lua", assets_transforms = "transforms.lua", + force_enable_env = "SOME_ENV", }, "mods/full") check(full.api == 2 and full.profile == "overhaul", "api and profile parse") check(full.affects_link == true, "overhaul defaults to affecting link play") @@ -115,6 +116,7 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range "a bare conflict entry has no range") check(full.options_schema == "options.lua" and full.assets_transforms == "transforms.lua", "declared files are kept") +check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept") -- ------- github / experimental / incompatible local gh = Manifest.validate({ diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index 25a8b9ff..6fd63347 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -11,6 +11,7 @@ local Stats = require("src.pokemon.Stats") local Zoom = require("src.render.Zoom") local ListMenu = require("src.ui.ListMenu") local NamingScreen = require("src.ui.NamingScreen") +local TextBox = require("src.render.TextBox") local Player = require("src.world.Player") local Music = require("src.core.Music") @@ -161,6 +162,36 @@ do unsub() end +-- ------- battle UI visibility (companion / alternate renderers) + +do + local BattleState = require("src.battle.BattleState") + check(BattleState.bottomUIVisible({ phase = "menu" }), + "battle bottom UI is visible without a mod") + local seen + local unsub = wrap("battle.bottom_ui_visible", function(_, state) + seen = state + return false + end) + check(not BattleState.bottomUIVisible({ phase = "messages" }), + "a mod can hide the battle text and menu layer") + local text = setmetatable({}, TextBox) + text:draw() + check(seen == text, "pushed text boxes use the same visibility hook") + unsub() + check(BattleState.bottomUIVisible({ phase = "moveSelect" }), + "battle bottom UI returns when the hook is removed") + + check(BattleState.statusHUDVisible({}), + "battle status HUD is visible without a mod") + unsub = wrap("battle.status_hud_visible", function() return false end) + check(not BattleState.statusHUDVisible({}), + "a mod can hide the battle status HUD") + unsub() + check(BattleState.statusHUDVisible({}), + "battle status HUD returns when the hook is removed") +end + -- ------- music.volume (distance / indoor muffling) do diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 1ef1c640..51bde011 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -287,7 +287,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "performance", "colors", "tilt", "gbcfx", "zoom", "voidFill", "videoMode", "faithfulRes", "fpsCap", - "speed", "mods", "controls" } + "speedOverworld", "speedBattle", "speedMenu", + "mods", "controls" } check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") for i, id in ipairs(WANT_IDS) do check(om.rows[i].id == id, "options row order: " .. id) @@ -383,7 +384,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6 -- the MODS row is the manager's discoverable home local mgGame = optGame() om = OptionsMenu.new(mgGame) -om.rows[22].activate(mgGame) +om.rows[24].activate(mgGame) check(getmetatable(mgGame.stack:top()) == ManagerState, "the MODS row opens the manager") check(mgGame.stack:top().screenId == "ManagerState", @@ -393,7 +394,7 @@ check(mgGame.stack:top().screenId == "ManagerState", local BindingsMenu = require("src.ui.BindingsMenu") local cbGame = optGame() om = OptionsMenu.new(cbGame) -om.rows[23].activate(cbGame) +om.rows[25].activate(cbGame) local bm = cbGame.stack:top() check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 60aefb97..4deb2640 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -823,6 +823,29 @@ do and caught.enemy.mon.species == "TANGELA", "and it is the species the authored encounter table names") + -- Trainer battles do not arm the wild-encounter cooldown. + walker.wildEncounterGraceSteps = 0 + walker:afterBattle("win", { kind = "trainer" }) + check(walker.wildEncounterGraceSteps == 0, + "trainer battles do not start the wild encounter grace period") + + -- pokered grants three completed steps after a wild battle before the + -- next random battle can start (end_of_battle.asm + home/overworld.asm). + local finishedWild = caught + walker:afterBattle("run", finishedWild) + caught = nil + withBuses(function(_, hooks) + hooks:wrap("encounter.roll", function() + return { species = "TANGELA", level = 5 } + end, 0, "grace-period") + for step = 1, 3 do + pcall(walker.onStepComplete, walker) + check(caught == nil, "wild encounter grace period blocks step " .. step) + end + pcall(walker.onStepComplete, walker) + check(caught ~= nil, "wild encounter is eligible on step 4") + end) + -- the same walk with an encounter.roll wrapper never starts a battle withBuses(function(_, hooks) hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke") @@ -893,6 +916,76 @@ do check(value == nil and err == "no overworld", "npc() off the world") value, err = api:queueScript({}) check(value == nil and err == "no overworld", "queueScript() off the world") + value, err = api:startWildBattle("PIDGEY", 5) + check(value == nil and err == "no overworld", "startWildBattle() off the world") +end + +-- startWildBattle: what regresses is the handoff, not the battle. A mod that +-- builds a BattleState and pushes it itself still fights and still levels; it +-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and +-- pushBattle (entry wipe, battle theme). Shipped mods have hit exactly this. +do + -- the real dataset, not fixture(): a battle reaches for type_chart, items, + -- battle_anims and more, and this block only reads + local data = Data + local state, game = liveWorld(data) + -- the handoff runs through these three, so each needs the live game + for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do + check(bindGame(OW[fn], game), fn .. " binds Game") + end + state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" }) + + local Pokemon = require("src.pokemon.Pokemon") + local api = WorldAPI.new(game, "tester") + + local value, err = api:startWildBattle("NOT_A_MON", 5) + check(value == nil and err:find("unknown species", 1, true), + "an unknown species refuses and names it") + -- Pokemon.new writes the level through into the stat calc and the exp curve + -- verbatim, so a fraction has to be refused rather than rounded downstream + for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do + check(api:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") + end + + local caterpie = Pokemon.new(data, "CATERPIE", 6) + game.save.party = { caterpie } + check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + + -- pushBattle pushes the entry transition, which pushes the battle from its + -- own callback; awardExp is the BattleState marker (screenId would not work, + -- only Screens.push stamps that and pushBattle pushes the battle directly) + check(game.stack:top() ~= nil and game.stack:top().awardExp == nil, + "the entry transition goes on first") + -- overworld() resolves the world from UNDER the battle, so a second call + -- while one is up has to refuse rather than stack another + check(api:startWildBattle("PIDGEY", 5) == nil, + "a battle already running refuses") + + local battle + for _ = 1, 400 do + local t = game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end + end + check(battle ~= nil, "the transition hands off to the battle") + + battle.participants = { [caterpie] = true } + battle:awardExp() + check(caterpie.level >= 7, "the mon levels past its evolution threshold") + check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") + + game.stack:pop() + battle.onFinish("win") + for _ = 1, 12 do + local t = game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + game.stack:pop() + if t.onDone then t.onDone() end + end + check(game.stack:top() and game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") end do diff --git a/tests/modkit/cases/checkpoint_cross_mod.lua b/tests/modkit/cases/checkpoint_cross_mod.lua new file mode 100644 index 00000000..91e1a504 --- /dev/null +++ b/tests/modkit/cases/checkpoint_cross_mod.lua @@ -0,0 +1,342 @@ +-- Cross-mod checkpoint ownership and lifecycle contract through public API only. +-- The Pokemon metadata case models masterwebx/SHINY_POKEMON 1.0.8 at 2141b2e: +-- shiny identity is data on the plain Pokemon record (`dvs` plus `shiny`). + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("checkpoint cross-mod compatibility") +local BattleState = require("src.battle.BattleState") +local Fixtures = require("tests.modkit").fixtures +local GameMethods = require("src.core.Game") +local Loader = require("src.mods.Loader") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Stats = require("src.pokemon.Stats") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local oldGetRandomState = love.math.getRandomState +local oldSetRandomState = love.math.setRandomState +local rngState = "cross-mod-rng-A" +love.math.getRandomState = function() return rngState end +love.math.setRandomState = function(state) rngState = state end + +local function memfs(files) + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } +end + +local function shinyDvs() + return { attack = 2, defense = 10, speed = 10, special = 10, hp = 0 } +end + +local function ordinaryDvs() + return { attack = 1, defense = 1, speed = 1, special = 1, hp = 15 } +end + +local function setPokemonIdentity(data, mon, shiny) + mon.dvs = shiny and shinyDvs() or ordinaryDvs() + mon.shiny = shiny and true or false + mon.stats = Stats.calc(data.pokemon[mon.species], mon.level, mon.dvs, mon.statExp) + mon.hp = math.min(mon.hp or mon.stats.hp, mon.stats.hp) +end + +local function setBattlerIdentity(data, battler, shiny) + setPokemonIdentity(data, battler.mon, shiny) + battler.curStats = battler.mon.stats + battler.shownHP = battler.mon.hp + battler.shownStatus = battler.mon.status +end + +local function makeGame() + local data = Fixtures.fresh() + local save = SaveData.newGame() + save.meta.playthroughId = "cross-mod-playthrough" + save.party = { Pokemon.new(data, "FIXMON_A", 20) } + SaveData.validate(save, data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + save.options.modOptions = {} + + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game + local ow = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function ow:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function ow:enter(mapId, x, y, facing, opts) + if game.failNextEnter then + game.failNextEnter = false + error("injected cross-mod reconstruction failure") + end + self.map = { id = mapId } + self.player = { + cellX = x, cellY = y, facing = facing, + surfing = game.save.player.surfing and true or false, + } + self.runner = { isRunning = function() return false end } + self.parallelRunners, self.pendingScripts = {}, {} + self.parallelQueue, self.scriptMoves = {}, {} + game.lastCheckpointEnter = opts + end + function ow:restoreBattleContinuation(battle, origin) + if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then + return false + end + battle.onFinish = function() end + return true + end + + game = setmetatable({ + data = data, save = save, stack = stack, overworld = ow, + }, { __index = GameMethods }) + stack.states[1] = ow + return game, ow +end + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",') + :format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}' +end + +local files = { + ["mods/cooperator/manifest.json"] = manifest("cooperator"), + ["mods/cooperator/main.lua"] = [[ +return function(mod) + local cachedStage = mod.save:get("stage", "unset") + local restoreCount = 0 + mod.options:define({ + { key = "mode", type = "choice", default = "default", + choices = { { "A", "A" }, { "B", "B" }, { "C", "C" } } }, + }) + mod.exports.checkpoints = mod.checkpoints + mod.exports.storage = mod.storage + mod.exports.setStage = function(stage) + mod.save:set("stage", stage) + cachedStage = stage + end + mod.exports.stage = function() return mod.save:get("stage", "unset") end + mod.exports.cachedStage = function() return cachedStage end + mod.exports.restoreCount = function() return restoreCount end + mod.events:on("checkpoint.restored", function(ev) + restoreCount = restoreCount + 1 + cachedStage = mod.save:get("stage", "unset") + mod.exports.lastRestore = { + game = ev.game, + kind = ev.kind, + top = ev.game.stack:top(), + } + end) +end +]], + ["mods/passive/manifest.json"] = manifest("passive"), + ["mods/passive/main.lua"] = [[ +return function(mod) + local cachedStage = mod.save:get("stage", "unset") + mod.exports.setStage = function(stage) + mod.save:set("stage", stage) + cachedStage = stage + end + mod.exports.stage = function() return mod.save:get("stage", "unset") end + mod.exports.cachedStage = function() return cachedStage end +end +]], +} + +local game, ow = makeGame() +local loader = Loader.new({ fs = memfs(files) }) +loader.game, game.mods = game, loader +T.check(loader:load({}) == true, "cooperating fixture mods load") +local cooperator = loader.exports.cooperator +local passive = loader.exports.passive +T.check(type(cooperator) == "table" and type(passive) == "table", + "fixture exposes only public mod exports") +if type(cooperator) ~= "table" or type(passive) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + love.math.getRandomState = oldGetRandomState + love.math.setRandomState = oldSetRandomState + T.finish() +end + +cooperator.setStage("A") +passive.setStage("A") +game:adoptSave(game.save, true) +local optionBucket = { mode = "A" } +game.save.options.modOptions.cooperator = optionBucket +loader.modOptions.cooperator = optionBucket +setPokemonIdentity(game.data, game.save.party[1], true) +T.check(Stats.isShiny(game.save.party[1].dvs), + "condition A uses the real Gen 2 DV shiny predicate") +T.check(cooperator.storage:write(game, "history", { generation = "A" }), + "independent history condition A writes through mod.storage") + +local overworldA, captureCode = cooperator.checkpoints:capture(game) +T.check(overworldA ~= nil, "condition A overworld captures: " .. tostring(captureCode)) + +-- Mutate canonical progress, both mod.save buckets, independent storage, +-- options, and runtime caches to condition B. +game.save.money = 999999 +setPokemonIdentity(game.data, game.save.party[1], false) +cooperator.setStage("B") +passive.setStage("B") +optionBucket.mode = "B" +rngState = "cross-mod-rng-B" +T.check(cooperator.storage:write(game, "history", { generation = "B" }), + "independent history advances to condition B") + +local bad = cooperator.checkpoints:capture(game) +bad.format = 99 +local rejected, rejectCode = cooperator.checkpoints:restore(game, bad) +T.check(rejected == false and rejectCode == "unsupported_format", + "failed checkpoint validation is reported") +T.eq(cooperator.restoreCount(), 0, "failed restore emits no lifecycle event") +T.eq(cooperator.cachedStage(), "B", "failed restore leaves runtime cache at B") +T.eq(cooperator.stage(), "B", "failed restore leaves mod.save at B") + +local failedTarget = cooperator.checkpoints:capture(game) +game.failNextEnter = true +local failed, failedCode = cooperator.checkpoints:restore(game, failedTarget) +T.check(failed == false and failedCode == "restore_failed", + "failed reconstruction rolls back without committing") +T.eq(cooperator.restoreCount(), 0, + "failed reconstruction and successful rollback emit no lifecycle event") +T.eq(cooperator.cachedStage(), "B", + "failed reconstruction leaves cooperating runtime cache at B") +T.eq(cooperator.stage(), "B", + "failed reconstruction rollback leaves mod.save at B") + +local restored, restoreCode, restoreMessage = + cooperator.checkpoints:restore(game, overworldA) +T.check(restored == true, + "condition A overworld restores: " .. tostring(restoreCode or restoreMessage)) +T.eq(game.save.money, overworldA.save.money, "core game progress rewinds to A") +T.eq(game.save.party[1].shiny, true, + "shiny marker rewinds with its Pokemon record") +T.check(Stats.isShiny(game.save.party[1].dvs), + "authoritative shiny DVs rewind with the Pokemon record") +T.eq(cooperator.stage(), "A", "cooperating mod.save progress rewinds to A") +T.eq(passive.stage(), "A", "all mods' mod.save progress rewinds generically") +T.eq(passive.cachedStage(), "B", + "runtime-only state is not serialized for a non-cooperating mod") +T.eq(cooperator.cachedStage(), "A", + "checkpoint lifecycle lets a cooperating mod rebuild its runtime cache") +T.same(cooperator.storage:read(game, "history"), { generation = "B" }, + "independent mod.storage history does not rewind") +T.eq(cooperator.restoreCount(), 1, "successful overworld restore emits once") +local overworldEvent = cooperator.lastRestore or {} +T.eq(overworldEvent.game, game, "restore event carries the final live game") +T.eq(overworldEvent.kind, "overworld", "restore event identifies overworld") +T.eq(overworldEvent.top, ow, + "restore event runs after the reconstructed overworld is installed") +T.eq(loader.modOptions.cooperator.mode, "B", + "per-mod global options stay at condition B") +T.eq(game.save.options.modOptions.cooperator.mode, "B", + "checkpoint reattaches the current global options table") + +-- Repeat the same ownership rules at a supported ordinary wild battle safe point. +cooperator.setStage("battle-A") +passive.setStage("battle-A") +setPokemonIdentity(game.data, game.save.party[1], true) +local battle = BattleState.newWild(game, "FIXMON_B", 12) +battle.phase, battle.queue = "menu", {} +battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } +battle.musicKind = battle:computeMusicKind() +battle.onFinish = function() end +setBattlerIdentity(game.data, battle.enemy, true) +game.stack.states[2] = battle +rngState = "cross-mod-battle-rng-A" + +local battleA, battleCaptureCode = cooperator.checkpoints:capture(game) +T.check(battleA and battleA.kind == "battle", + "condition A battle captures: " .. tostring(battleCaptureCode)) +if battleA then + setBattlerIdentity(game.data, battle.player, false) + setBattlerIdentity(game.data, battle.enemy, false) + cooperator.setStage("battle-B") + passive.setStage("battle-B") + optionBucket.mode = "C" + rngState = "cross-mod-battle-rng-B" + T.check(cooperator.storage:write(game, "history", { generation = "battle-B" }), + "independent history advances during battle") + + local battleRestored, battleRestoreCode, battleRestoreMessage = + cooperator.checkpoints:restore(game, battleA) + T.check(battleRestored == true, + "condition A battle restores: " + .. tostring(battleRestoreCode or battleRestoreMessage)) + local restoredBattle = game.stack:top() + T.eq(restoredBattle.player.mon, game.save.party[1], + "restored player battler rebinds to canonical party Pokemon") + T.eq(restoredBattle.player.mon.shiny, true, + "player shiny metadata rewinds through battle reconstruction") + T.check(Stats.isShiny(restoredBattle.player.mon.dvs), + "player shiny DVs rewind through battle reconstruction") + T.eq(restoredBattle.enemy.mon.shiny, true, + "enemy shiny metadata rewinds with copied battle Pokemon") + T.check(Stats.isShiny(restoredBattle.enemy.mon.dvs), + "enemy shiny DVs rewind through battle reconstruction") + T.eq(cooperator.stage(), "battle-A", "battle restore rewinds mod.save progress") + T.eq(cooperator.cachedStage(), "battle-A", + "battle restore event rebuilds cooperating runtime cache") + T.eq(passive.cachedStage(), "battle-B", + "battle restore still does not serialize arbitrary mod runtime") + T.same(cooperator.storage:read(game, "history"), { generation = "battle-B" }, + "battle restore leaves independent history current") + T.eq(loader.modOptions.cooperator.mode, "C", + "battle restore leaves per-mod global options current") + T.eq(cooperator.restoreCount(), 2, "successful battle restore emits once") + local battleEvent = cooperator.lastRestore or {} + T.eq(battleEvent.kind, "battle", "restore event identifies battle") + T.eq(battleEvent.top, restoredBattle, + "battle restore event runs after final battle installation") + T.same(cooperator.checkpoints:capture(game), battleA, + "combined battle and mod progress is a differential roundtrip") +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +love.math.getRandomState = oldGetRandomState +love.math.setRandomState = oldSetRandomState +_G.CROSS_MOD_CHECKPOINT = nil + +T.finish() diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua new file mode 100644 index 00000000..adea5f90 --- /dev/null +++ b/tests/modkit/cases/checkpoints.lua @@ -0,0 +1,442 @@ +-- Public mod.checkpoints contract over a semantic Game/StateStack fixture. +-- The mod entry chunk sees no private module; the harness builds the engine side. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local oldGetRandomState = love.math.getRandomState +local oldSetRandomState = love.math.setRandomState +local checkpointRngState = "overworld-rng-A" +love.math.getRandomState = function() return checkpointRngState end +love.math.setRandomState = function(state) checkpointRngState = state end + +local T = require("tests.harness").suite("mod checkpoints") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local GameMethods = require("src.core.Game") +local BattleState = require("src.battle.BattleState") +local Fixtures = require("tests.modkit").fixtures +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Version = require("src.core.Version") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function memfs(files) + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end, + } +end + +local function baseSave() + return { + version = "red", + meta = { format = 4, mods = {}, playthroughId = "play-a" }, + player = { + map = "PALLET_TOWN", x = 5, y = 6, facing = "down", surfing = false, + name = "RED", rival = "BLUE", id = 7, + }, + money = 3000, + party = { { species = "BULBASAUR", level = 5, hp = 19, + moves = { "TACKLE" } } }, + flags = { GOT_STARTER = true }, + inventory = { POTION = 1 }, + pcItems = { POTION = 2 }, + box = { { species = "BULBASAUR", level = 4, hp = 16, + moves = { "TACKLE" } } }, + boxes = { [2] = { { species = "BULBASAUR", level = 3, hp = 14, + moves = { "TACKLE" } } } }, + defeatedTrainers = { PALLET_RIVAL = true }, + objectToggles = { PALLET_TOWN = { OAK = false } }, + itemsTaken = { PALLET_TOWN_POTION = true }, + pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } }, + modData = {}, + options = { volume = 4, bindings = {} }, + } +end + +local function makeGame() + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game + local ow = { + map = { id = "PALLET_TOWN" }, + player = { cellX = 5, cellY = 6, facing = "down", surfing = false }, + scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {}, + runner = { isRunning = function() return false end }, + } + function ow:captureSave(save) + save.player.map = self.map.id + save.player.x = self.player.cellX + save.player.y = self.player.cellY + save.player.facing = self.player.facing + save.player.surfing = self.player.surfing and true or false + end + function ow:enter(mapId, x, y, facing, opts) + game.lastEnterOpts = opts + if game.failNextEnter then + game.failNextEnter = false + error("injected reconstruction failure") + end + self.map = { id = mapId } + self.player = { + cellX = x, cellY = y, facing = facing, + surfing = game.save.player.surfing and true or false, + } + self.scriptMoves, self.pendingScripts = {}, {} + self.parallelRunners, self.parallelQueue = {}, {} + self.runner = { isRunning = function() return false end } + end + game = setmetatable({ + save = baseSave(), stack = stack, overworld = ow, + data = { + pokemon = { BULBASAUR = { dex = 1 } }, + moves = { TACKLE = { pp = 35 } }, + items = { POTION = {} }, + constants = { fallbackMove = "TACKLE" }, + field = { boot = { startMap = "PALLET_TOWN", startX = 5, startY = 6 } }, + maps = { + PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 }, + ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 }, + BROKEN = { id = "BROKEN", width = 10, height = 9 }, + }, + }, + }, { __index = GameMethods }) + stack.states[1] = ow + return game, ow +end + +local files = { + ["mods/probe/manifest.json"] = + '{"id":"probe","name":"probe","version":"1.0.0",' + .. '"entry":"main.lua","api":2,"profile":"content"}', + ["mods/probe/main.lua"] = [[ +return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end +]], +} +local game, ow = makeGame() +local loader = Loader.new({ fs = memfs(files) }) +loader.game = game +T.check(loader:load({}) == true, "checkpoint fixture mod loads") +local checkpoints = _G.MOD_CHECKPOINTS +T.check(type(checkpoints) == "table", + "Loader exposes mod.checkpoints through the public mod object") +if type(checkpoints) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + _G.MOD_CHECKPOINTS = nil + T.finish() +end + +local capability = checkpoints:inspect(game) +T.same(capability, { canCapture = true, canRestore = true, kind = "overworld" }, + "plain overworld control is a stable checkpoint boundary") + +local function refused(mutator, expectedCode, message) + local undo = mutator() + local result = checkpoints:inspect(game) + T.check(result.canCapture == false and result.reason == expectedCode, message) + undo() +end + +refused(function() + ow.transitioning = true + return function() ow.transitioning = nil end +end, "transition_busy", "transition frames are rejected") + +refused(function() + ow.runner = { isRunning = function() return true end } + return function() ow.runner = { isRunning = function() return false end } end +end, "script_busy", "foreground suspended scripts are rejected") + +refused(function() + ow.parallelRunners = { { isRunning = function() return true end } } + return function() ow.parallelRunners = {} end +end, "script_busy", "parallel suspended scripts are rejected") + +refused(function() + ow.pendingScripts = { { rows = {} } } + return function() ow.pendingScripts = {} end +end, "script_busy", "queued scripts are rejected") + +refused(function() + ow.scriptMoves = { { entity = ow.player } } + return function() ow.scriptMoves = {} end +end, "script_busy", "scripted movement is rejected") + +refused(function() + game.stack.states[2] = { screenId = "StartMenu" } + return function() game.stack.states[2] = nil end +end, "screen_busy", "modal screens over the overworld are rejected") + +refused(function() + ow.emote = { frames = 1 } + return function() ow.emote = nil end +end, "animation_busy", "partial overworld animations are rejected") + +refused(function() + ow.player.moving = true + return function() ow.player.moving = nil end +end, "movement_busy", "partial player movement is rejected") + +local titleGame = { save = game.save, stack = { + top = function() return { screenId = "TitleState" } end, +} } +local titleCapability = checkpoints:inspect(titleGame) +T.check(titleCapability.canCapture == false + and titleCapability.reason == "not_overworld", + "title and non-playthrough runtime is rejected") + +-- Capture synchronizes semantic position into a detached data-only record. +ow.map.id, ow.player.cellX, ow.player.cellY = "ROUTE_1", 7, 8 +ow.player.facing, ow.player.surfing = "left", true +local snapshot, code, message = checkpoints:capture(game) +T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message)) +T.eq(snapshot.format, 1, "checkpoint format is explicit") +T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit") +T.same(snapshot.identity, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", + }, + "checkpoint carries compatibility identity") +T.same(snapshot.runtime.overworld, + { map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true }, + "checkpoint carries exact semantic overworld position") +T.eq(snapshot.save.player.map, "ROUTE_1", + "captured progress is synchronized from the live controller") +T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind") +T.same(snapshot.rng, { love = "overworld-rng-A" }, + "overworld checkpoint carries deterministic gameplay RNG") + +local legacy = checkpoints:capture(game) +legacy.rng = nil +checkpointRngState = "legacy-runtime-rng" +local legacyRestored, legacyCode = checkpoints:restore(game, legacy) +T.check(legacyRestored == true, + "legacy format-1 overworld checkpoint without RNG remains loadable: " + .. tostring(legacyCode)) +T.eq(checkpointRngState, "legacy-runtime-rng", + "legacy checkpoint leaves the current RNG stream untouched") +checkpointRngState = "overworld-rng-A" + +snapshot.save.money = 1 +snapshot.runtime.overworld.x = 1 +T.eq(game.save.money, 3000, "mutating a checkpoint cannot mutate live progress") +T.eq(ow.player.cellX, 7, "mutating a checkpoint cannot move the live player") + +-- Recapture the unmodified canonical A used for the differential roundtrip. +snapshot = checkpoints:capture(game) +local original = snapshot + +game.save.money = 999999 +game.save.flags.GOT_STARTER = nil +game.save.party[1].hp = 1 +game.save.inventory.POTION = 99 +game.save.pcItems.POTION = nil +game.save.box = {} +game.save.boxes = {} +game.save.defeatedTrainers.PALLET_RIVAL = nil +game.save.objectToggles.PALLET_TOWN.OAK = true +game.save.itemsTaken.PALLET_TOWN_POTION = nil +game.save.pokedex.seen.BULBASAUR = nil +game.save.pokedex.owned.BULBASAUR = nil +game.save.options.volume = 9 +checkpointRngState = "overworld-rng-B" +ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3 +ow.player.facing, ow.player.surfing = "up", false + +local restored, restoreCode, restoreMessage = checkpoints:restore(game, original) +T.check(restored == true, + "valid checkpoint restores: " .. tostring(restoreCode or restoreMessage)) +local recaptured = checkpoints:capture(game) +T.same(recaptured, original, + "capture A, mutate B, restore A, capture A2 yields normalized A == A2") +T.eq(game.save.options.volume, 9, + "checkpoint restoration preserves current global settings") +T.eq(checkpointRngState, "overworld-rng-A", + "overworld checkpoint restores gameplay RNG") +T.eq(game.save.inventory.POTION, 1, "inventory progress roundtrips") +T.eq(game.save.pcItems.POTION, 2, "PC item progress roundtrips") +T.eq(game.save.box[1].hp, 16, "current box Pokemon roundtrips") +T.eq(game.save.boxes[2][1].hp, 14, "stored box collection roundtrips") +T.eq(game.save.defeatedTrainers.PALLET_RIVAL, true, + "defeated trainer progress roundtrips") +T.eq(game.save.objectToggles.PALLET_TOWN.OAK, false, + "map object toggle progress roundtrips") +T.eq(game.save.itemsTaken.PALLET_TOWN_POTION, true, + "taken-object progress roundtrips") +T.eq(game.save.pokedex.owned.BULBASAUR, true, "Pokedex progress roundtrips") +T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true, + "engine reconstruction is marked to suppress map-entry side effects") + +-- Compatibility and schema failures occur before any mutation. +local beforeRejected = checkpoints:capture(game) +local wrongFormat = checkpoints:capture(game) +wrongFormat.format = 99 +restored, restoreCode = checkpoints:restore(game, wrongFormat) +T.check(not restored and restoreCode == "unsupported_format", + "unknown checkpoint format is rejected") + +local wrongGame = checkpoints:capture(game) +wrongGame.identity.gameVersion = "blue" +restored, restoreCode = checkpoints:restore(game, wrongGame) +T.check(not restored and restoreCode == "wrong_game", + "another game version is rejected") + +local wrongProfile = checkpoints:capture(game) +wrongProfile.identity.playthroughId = "play-b" +restored, restoreCode = checkpoints:restore(game, wrongProfile) +T.check(not restored and restoreCode == "wrong_playthrough", + "another playthrough is rejected") + +local badMap = checkpoints:capture(game) +badMap.runtime.overworld.map = "MISSING_MAP" +badMap.save.player.map = "MISSING_MAP" +restored, restoreCode = checkpoints:restore(game, badMap) +T.check(not restored and restoreCode == "invalid_map", + "unknown content reference is rejected") +T.same(checkpoints:capture(game), beforeRejected, + "validation failures leave the live state unchanged") + +local invalidGame = makeGame() +local badSpecies = checkpoints:capture(invalidGame) +badSpecies.save.party[1].species = "MISSING_SPECIES" +restored, restoreCode = checkpoints:restore(invalidGame, badSpecies) +T.check(not restored and restoreCode == "invalid_content", + "unknown Pokemon content is rejected before reconstruction") +T.eq(invalidGame.save.party[1].species, "BULBASAUR", + "invalid Pokemon content leaves the live party unchanged") + +-- A reconstruction exception rolls back to the exact pre-operation state. +local target = checkpoints:capture(game) +target.runtime.overworld.map = "BROKEN" +target.runtime.overworld.x, target.runtime.overworld.y = 1, 1 +target.save.player.map = "BROKEN" +target.save.player.x, target.save.player.y = 1, 1 +target.save.money = 42 +local beforeFailure = checkpoints:capture(game) +game.failNextEnter = true +restored, restoreCode = checkpoints:restore(game, target) +T.check(not restored and restoreCode == "restore_failed", + "reconstruction exception is returned as a structured failure") +T.same(checkpoints:capture(game), beforeFailure, + "failed reconstruction rolls back the complete pre-operation checkpoint") + +-- The same public facade must carry a real battle checkpoint end to end. The +-- engine-side fixture is deliberately constructed outside the probe mod; the +-- mod sees and calls only mod.checkpoints. +local function makeBattleGame() + local data = Fixtures.fresh() + local save = SaveData.newGame() + save.meta.playthroughId = "public-battle-playthrough" + save.party = { Pokemon.new(data, "FIXMON_A", 20) } + -- The tiny fixture registry intentionally omits several full-game defaults. + -- Normalize those once, then place the save on its fixture map. + SaveData.validate(save, data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local battleGame + local battleOw = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function battleOw:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function battleOw:enter(mapId, x, y, facing) + self.map = { id = mapId } + self.player = { cellX = x, cellY = y, facing = facing, surfing = false } + end + function battleOw:restoreBattleContinuation(restoredBattle, origin) + if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then + return false + end + restoredBattle.onFinish = function() end + return true + end + battleGame = setmetatable({ + data = data, save = save, stack = stack, overworld = battleOw, + }, { __index = GameMethods }) + stack.states[1] = battleOw + local battle = BattleState.newWild(battleGame, "FIXMON_B", 12) + battle.phase, battle.queue = "menu", {} + battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } + battle.musicKind = battle:computeMusicKind() + battle.onFinish = function() end + stack.states[2] = battle + return battleGame, battle +end + +checkpointRngState = "public-battle-rng-A" +local battleGame, liveBattle = makeBattleGame() +T.same(checkpoints:inspect(battleGame), { + canCapture = true, canRestore = true, kind = "battle", +}, "public mod.checkpoints reports a settled battle boundary") +liveBattle.turnCount = 4 +liveBattle.player.stages.attack = 2 +local battleSnapshot, battleCaptureCode = checkpoints:capture(battleGame) +T.check(battleSnapshot and battleSnapshot.kind == "battle", + "public mod.checkpoints captures a data-only battle: " + .. tostring(battleCaptureCode)) +if battleSnapshot then + battleGame.save.money = 1 + liveBattle.turnCount = 99 + checkpointRngState = "public-battle-rng-B" + local battleRestored, battleRestoreCode, battleRestoreMessage = checkpoints:restore( + battleGame, battleSnapshot) + T.check(battleRestored == true, + "public mod.checkpoints reconstructs a battle: " + .. tostring(battleRestoreCode) .. " / " .. tostring(battleRestoreMessage)) + local restoredBattle = battleGame.stack:top() + T.eq(restoredBattle.turnCount, 4, + "public battle reconstruction restores the exact turn") + T.eq(restoredBattle.player.stages.attack, 2, + "public battle reconstruction restores battler stages") + T.eq(checkpointRngState, "public-battle-rng-A", + "public battle reconstruction restores gameplay RNG") + T.same(checkpoints:capture(battleGame), battleSnapshot, + "public battle capture/restore/capture is a normalized differential roundtrip") +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +_G.MOD_CHECKPOINTS = nil +love.math.getRandomState = oldGetRandomState +love.math.setRandomState = oldSetRandomState + +T.finish() diff --git a/tests/modkit/cases/platform_lifecycle_hooks.lua b/tests/modkit/cases/platform_lifecycle_hooks.lua new file mode 100644 index 00000000..88d95336 --- /dev/null +++ b/tests/modkit/cases/platform_lifecycle_hooks.lua @@ -0,0 +1,93 @@ +-- core.update / core.quit_to_launcher through the public mod API: a +-- platform-launcher integration can pause the simulation and veto the +-- return-to-launcher decision from a mod, with no main.lua patch. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local PlatformHooks = require("src.core.PlatformHooks") + +local FIXTURE = { + ["mods/fix_platform_bridge/manifest.json"] = [[{ + "id": "fix_platform_bridge", + "name": "Fixture Platform Bridge", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_platform_bridge/main.lua"] = [[ + local mod = ... + local paused = false + local extraPolls = 0 + mod.hooks:wrap("core.update", function(nextFn, game, dt) + extraPolls = extraPolls + 1 + if not paused then nextFn(game, dt) end + end) + mod.hooks:wrap("core.quit_to_launcher", function(nextFn) + if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end + return nextFn() + end) + -- test-only knobs, read back through mod.storage-free globals since + -- this fixture never leaves the process + _G.__fixturePlatformBridge = { + setPaused = function(v) paused = v end, + extraPolls = function() return extraPolls end, + } + ]], +} + +-- core.update: a subscriber can pause (skip vanilla) and still run every frame +do + local run = T.sdk.loadMods({ "mods/fix_platform_bridge" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + + local calls = 0 + local fakeGame = { update = function(self, dt) calls = calls + 1 end } + + _G.__fixturePlatformBridge.setPaused(false) + PlatformHooks.update(fakeGame, 1 / 60) + T.eq(calls, 1, "unpaused: vanilla Game:update runs") + T.eq(_G.__fixturePlatformBridge.extraPolls(), 1, + "the subscriber's wrapper runs every frame") + + _G.__fixturePlatformBridge.setPaused(true) + PlatformHooks.update(fakeGame, 1 / 60) + T.eq(calls, 1, "paused: vanilla Game:update is skipped") + T.eq(_G.__fixturePlatformBridge.extraPolls(), 2, + "the subscriber keeps polling every frame while paused") + + run.release() + _G.__fixturePlatformBridge = nil +end + +-- core.quit_to_launcher: a subscriber can veto without the vanilla +-- condition ever running, or pass it through unchanged +do + local run = T.sdk.loadMods({ "mods/fix_platform_bridge" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + + local realGetenv = os.getenv + os.getenv = function(name) + if name == "FIXTURE_VETO_QUIT" then return "1" end + return realGetenv(name) + end + local vanillaCalls = 0 + local vetoed = PlatformHooks.quitToLauncher(function() + vanillaCalls = vanillaCalls + 1 + return true + end) + T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision") + T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition") + os.getenv = realGetenv + + local passed = PlatformHooks.quitToLauncher(function() return true end) + T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged") + + run.release() +end + +T.finish("platform_lifecycle_hooks") diff --git a/tests/modkit/cases/storage.lua b/tests/modkit/cases/storage.lua new file mode 100644 index 00000000..56564c31 --- /dev/null +++ b/tests/modkit/cases/storage.lua @@ -0,0 +1,181 @@ +-- Public mod.storage contract: data-only transactions, namespace isolation, +-- deterministic listing, recovery, and failure retention. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod storage") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.Version") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",') + :format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}' +end + +local function memfs(files) + local fs = { files = files, failTmp = false, failMain = false } + + function fs.read(path) return files[path] end + function fs.write(path, body) + if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end + if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end + files[path] = body + return true + end + function fs.remove(path) files[path] = nil return true end + function fs.createDirectory() return true end + function fs.getInfo(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end + function fs.load(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end + function fs.getDirectoryItems(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end + return fs +end + +local function game(version, playthroughId) + return { save = { + version = version, + meta = { format = 4, mods = {}, playthroughId = playthroughId }, + } } +end + +local files = { + ["mods/alpha/manifest.json"] = manifest("alpha"), + ["mods/alpha/main.lua"] = [[ +return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end +]], + ["mods/beta/manifest.json"] = manifest("beta"), + ["mods/beta/main.lua"] = [[ +return function(mod) _G.MOD_STORAGE_BETA = mod.storage end +]], +} +local fs = memfs(files) +local loader = Loader.new({ fs = fs }) +local current = game("red", "play-a") +loader.game = current +T.check(loader:load({}) == true, "storage fixture mods load") + +local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA +T.check(type(alpha) == "table" and type(beta) == "table", + "Loader exposes mod.storage through the public mod object") +if type(alpha) ~= "table" or type(beta) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil + T.finish() +end + +-- Removing scope identity or exposing a mutable private slot id breaks this. +local context = alpha:context(current) +T.same(context, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", +}, "context exposes stable engine/game/playthrough compatibility identity") + +-- Data-only write/read. The literal expected table is independent of storage. +local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } +local ok, code, message = alpha:write(current, "states/quick/q1", payload) +T.check(ok == true, "data-only payload writes: " .. tostring(code or message)) +local loaded = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "stored payload roundtrips as data") +T.check(loaded ~= payload and loaded.nested ~= payload.nested, + "read returns decoded data rather than the caller's live table") + +local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) +T.check(not bad and badCode == "encode_failed", + "functions are rejected with a stable data-only error") + +local escaped, escapedCode = alpha:write(current, "../escape", {}) +T.check(not escaped and escapedCode == "invalid_key", + "path traversal is rejected before persistence") + +-- Logical enumeration is deterministic and prefix-scoped. +T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta") +T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") +T.check(alpha:write(current, "settings", { enabled = true }), "write settings") +local keys = alpha:list(current, "states/quick") +T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" }, + "list returns sorted logical keys under the requested prefix") + +-- Mod, playthrough, and game namespaces cannot observe each other. +local missing, missingCode = beta:read(current, "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", + "another mod cannot read the first mod's payload") +missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", + "another playthrough cannot read the payload") +missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", + "another game version cannot read the payload") + +-- Find the implementation-owned file only to inject corruption; assertions stay +-- on public read behavior, not the path shape. +local function mainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then return path end + end +end + +local q1Main = mainFor("q1") +T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") +files[q1Main] = "not a serialized table" +loaded, code = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "corrupt main recovers the last verified payload") +T.eq(code, nil, "successful recovery is a normal read") + +-- A failed replacement cannot destroy the prior verified value. +T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value") +fs.failTmp = true +ok, code = alpha:write(current, "replace", { version = 2 }) +fs.failTmp = false +T.check(not ok and code == "write_failed", "staging failure is reported") +T.same(alpha:read(current, "replace"), { version = 1 }, + "staging failure leaves the prior value readable") + +-- Delete is exact and idempotent-not-found is explicit. +T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") +T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") +T.check(alpha:delete(current, "delete/me") == true, "delete removes its target") +missing, missingCode = alpha:read(current, "delete/me") +T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable") +T.same(alpha:read(current, "delete/keep"), { yes = true }, + "delete leaves neighboring keys untouched") + +-- No-mod parity: constructing/loading an empty loader creates no storage bytes. +local emptyFiles, emptyFs = {}, nil +emptyFs = memfs(emptyFiles) +local emptyLoader = Loader.new({ fs = emptyFs }) +emptyLoader.game = current +T.check(emptyLoader:load({}) == true, "no-mod loader still boots") +T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil + +T.finish() diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index 3a688793..90b795f9 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -636,6 +636,52 @@ local packed = io.open(cleanPkg, "rb") check(packed ~= nil, "pack writes the package") if packed then packed:close() end +-- Reproducible-build callers pin the informational pack timestamp through the +-- standard SOURCE_DATE_EPOCH contract. Two clean invocations over the same +-- input must then produce identical archive bytes and metadata. +local epoch = "1234567890" +local envPrefix = isWindows + and ('set "SOURCE_DATE_EPOCH=%s" && '):format(epoch) + or ("SOURCE_DATE_EPOCH=%s "):format(epoch) +local deterministicA = root .. "/declared-a.modpkg" +local deterministicB = root .. "/declared-b.modpkg" +out, code = run(envPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, deterministicA)) +check(code == 0, "SOURCE_DATE_EPOCH package A succeeds: " .. out) +out, code = run(envPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, deterministicB)) +check(code == 0, "SOURCE_DATE_EPOCH package B succeeds: " .. out) +local archiveA = assert(io.open(deterministicA, "rb")) +local bytesA = archiveA:read("*a") +archiveA:close() +local archiveB = assert(io.open(deterministicB, "rb")) +local bytesB = archiveB:read("*a") +archiveB:close() +check(bytesA == bytesB, "SOURCE_DATE_EPOCH makes package bytes reproducible") +local inspectPack = root .. "/inspect_pack.py" +write(inspectPack, [[ +import json, sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as archive: + meta = json.loads(archive.read(".modkit/pack.json")) +assert meta["packed_at"] == "2009-02-13T23:31:30Z", meta["packed_at"] +]]) +out, code = run(("%s %q %q"):format(python, inspectPack, deterministicA)) +check(code == 0, "pack metadata honors SOURCE_DATE_EPOCH: " .. out) +local invalidEpochPrefix = isWindows + and 'set "SOURCE_DATE_EPOCH=not-a-time" && ' + or "SOURCE_DATE_EPOCH=not-a-time " +local invalidEpochPkg = root .. "/declared-invalid-epoch.modpkg" +out, code = run(invalidEpochPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, invalidEpochPkg)) +check(code == 2, "invalid SOURCE_DATE_EPOCH is a usage failure: " .. out) +check(out:find("SOURCE_DATE_EPOCH", 1, true) ~= nil, + "invalid source epoch names the failed contract") +check(io.open(invalidEpochPkg, "rb") == nil, + "invalid source epoch writes no package") + -- MK305 diffs shipped tables against the imported dataset; fake one under -- a scratch repo root so the check exercises the same on ROM-less machines local fake = root .. "/fakerepo" diff --git a/tests/parity_B.lua b/tests/parity_B.lua index 3a0291a9..4c5876c0 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -60,6 +60,17 @@ for _, r in ipairs(rows) do end check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame") +-- The post-battle walk takes the right-hand detour before heading north, so +-- the player does not visibly pass through the rival at (4,2). +local route = {} +for _, r in ipairs(rows) do + if r[1] == "move_player" then route[#route + 1] = r end +end +eq(route[#route - 1] and route[#route - 1][2], "right", + "walk-out route first moves right around the rival") +eq(route[#route] and route[#route][2], "up", + "walk-out route then heads north to Hall of Fame") + -- (3) Commands.face_player_dir sets the player's facing local Commands = require("src.script.Commands") check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function") diff --git a/tests/parity_substitute_anim.lua b/tests/parity_substitute_anim.lua index 8092c87b..1071d150 100644 --- a/tests/parity_substitute_anim.lua +++ b/tests/parity_substitute_anim.lua @@ -75,6 +75,19 @@ do check(anyText(tb, "SUBSTITUTE"), "the failure text still prints") end +-- Exact quarter HP is also not enough: accepting it would leave the user at +-- 0 HP with substituteHP set, so the next trainer-battle turn cannot advance. +do + local tb = freshBattle() + local cost = math.floor(tb.enemy.mon.stats.hp / 4) + tb.enemy.mon.hp = cost + tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false) + check(tb.enemy.substituteHP == nil and tb.enemy.mon.hp == cost, + "exact quarter HP cannot create a zero-HP substitute") + check(not anyAnim(tb, "SUBSTITUTE"), + "the exact-boundary failure plays no animation") +end + -- .alreadyHasSubstitute: same, with a doll already standing do local tb = freshBattle() diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua index 415f470a..0bfa5015 100644 --- a/tests/parity_true_color_ui.lua +++ b/tests/parity_true_color_ui.lua @@ -93,6 +93,7 @@ local titleGame = { field = { title = { cycleSpecies = { "PIKACHU" } } } }, } local title = TitleState.new(titleGame, {}) +title.phase, title.scy = "loop", 0 local titleSprite, titleTrueColor = title:currentSprite() check(titleSprite and titleTrueColor, "title cache keeps a Pokemon sprite's trueColor flag") diff --git a/tests/parity_yellow_oak_speech.lua b/tests/parity_yellow_oak_speech.lua new file mode 100644 index 00000000..2b74da3f --- /dev/null +++ b/tests/parity_yellow_oak_speech.lua @@ -0,0 +1,51 @@ +-- Pokemon Yellow's Oak-speech show-off mon is the player's Pikachu, not +-- Red/Blue's NIDORINO (engine/battle/core.asm BATTLE_TYPE_PIKACHU, the +-- ProfOak demo; engine/movie/oak_speech/oak_speech.asm). The import +-- manifest must carry field.oakSpeech.demoSpecies, and +-- Data:applyVersionedFieldData repairs Yellow caches made before the +-- manifest carried it (#915). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.field and Data.field.oakSpeech) then Data:load() end +local GameVersion = require("src.core.GameVersion") +local S = require("tests.harness").suite("parity Yellow Oak speech") +local check, eq = S.check, S.eq + +local oldVersion = GameVersion.get() +local oldTrades = Data.field.trades +local oldOldManBattle = Data.field.oldManBattle + +local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r")) +local manifest = manifestFile:read("*a") +manifestFile:close() + +check(manifest:find('"demoSpecies": "PIKACHU"') ~= nil, + "Yellow manifest stamps field.oakSpeech.demoSpecies as PIKACHU") + +-- a stale Yellow cache carries shrink frames but no demoSpecies +local stale = { shrink1 = "assets/generated/intro/shrink1.png", + shrink2 = "assets/generated/intro/shrink2.png" } +local oldOakSpeech = Data.field.oakSpeech +Data.field.oakSpeech = stale + +GameVersion.set("yellow") +Data:applyVersionedFieldData() +eq(Data.field.oakSpeech.demoSpecies, "PIKACHU", + "applyVersionedFieldData fills a stale Yellow cache with PIKACHU") + +-- fill-if-absent: an importer that learns to stamp the key wins +local preStamped = { demoSpecies = "RAICHU", + shrink1 = "assets/generated/intro/shrink1.png" } +Data.field.oakSpeech = preStamped +Data:applyVersionedFieldData() +eq(Data.field.oakSpeech.demoSpecies, "RAICHU", + "applyVersionedFieldData leaves an already-stamped demoSpecies alone") + +Data.field.oakSpeech = oldOakSpeech +Data.field.trades = oldTrades +Data.field.oldManBattle = oldOldManBattle +GameVersion.set(oldVersion) + +return S:finish() diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index 000d2a31..986e3dfc 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -291,6 +291,106 @@ do eq(mon.level, 1, "stepSpecies keeps the level") end +do + -- Nicknames: the editor edits mon.nickname, which is nil when un-nicknamed + -- (every display site reads `mon.nickname or def.name`, GenSave.lua). The + -- game's naming screen caps at 10 glyphs and treats an empty confirm as "no + -- nickname", so the verbs below mirror that: "" clears, a name matching the + -- species' standard name normalizes back to nil, too-long or unrenderable + -- names refuse with a status line, and nothing silently no-ops. + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + local mon = MonOps.create(Data, "CHARIZARD", 50) + S.save.party = { mon } + S.editingMon = mon + + eq(Ops.nicknameLength("POKEMON"), 7, "nicknameLength counts ASCII glyphs") + eq(Ops.nicknameLength("ééé"), 3, "nicknameLength counts a multi-byte char as one glyph") + eq(Ops.nicknameLength("♂♀!"), 3, "nicknameLength counts symbol glyphs") + check(Ops.nicknameUsable(S, "CHARIZARD"), "ASCII letters are renderable") + check(Ops.nicknameUsable(S, "Nidoking"), "lower case is renderable") + check(Ops.nicknameUsable(S, "é") == true, "a charmap glyph is renderable") + check(Ops.nicknameUsable(S, "PIKA€") == false, "a non-charmap glyph is not renderable") + check(Ops.nicknameUsable(S, "🤖") == false, "an emoji is not renderable") + -- "@" is the Gen1 string terminator: the codec has an entry for it but the + -- game font has no tile, so Font.encode draws it as a space in-game + check(Ops.nicknameUsable(S, "POKE@MON") == false, + "the terminator @ is not a renderable nickname glyph") + check(Ops.nicknameUsable(S, "POKE#MON") == false, + "the # marker is not a renderable nickname glyph") + + -- the field gate: sanitize skips unrenderable glyphs and clamps at 10, so + -- what reaches the mon can only ever be a legal Gen 1 nickname + eq(Ops.nicknameSanitize(S, "PIKA\226\130\172"), "PIKA", + "sanitize drops an unrenderable glyph") + eq(Ops.nicknameSanitize(S, "PIKA\226\130\172CHU"), "PIKACHU", + "sanitize skips a bad glyph mid-name instead of aborting the rest") + eq(Ops.nicknameSanitize(S, "POKE@MON"), "POKEMON", + "sanitize strips the invisible @ terminator") + eq(Ops.nicknameSanitize(S, "1234567890123"), "1234567890", + "sanitize clamps the draft at 10 glyphs") + eq(Ops.nicknameSanitize(S, "\195\169"), "\195\169", + "sanitize keeps a charmap glyph") + eq(Ops.nicknameSanitize(S, ""), "", "sanitize of empty is empty") + + Ops.setNickname(S, mon, "SPARKY") + eq(mon.nickname, "SPARKY", "setNickname stores the name") + check(S.dirty == true, "setNickname marks the save dirty") + eq(S.status:match("SPARKY") ~= nil, true, "setNickname narrates the new name") + S.dirty = false + + check(Ops.setNickname(S, mon, "SPARKY") == false, + "setting the same nickname again is a no-op") + check(S.dirty == false, "the no-op did not dirty the save") + check(S.status:match("Already nicknamed") ~= nil, "the no-op explains itself") + + -- a name matching the species' standard name is the un-nicknamed state + Ops.setNickname(S, mon, "CHARIZARD") + eq(mon.nickname, nil, "a name equal to the standard name normalizes to nil") + eq(S.status:match("standard name") ~= nil, true, "the normalization explains itself") + + check(Ops.clearNickname(S, mon) == false, + "clearing an already-un-nicknamed mon is a no-op") + check(S.status:match("no nickname") ~= nil, "the no-op explains itself") + + -- empty input means clear, like an empty naming-screen confirm + Ops.setNickname(S, mon, "SPARKY") + eq(mon.nickname, "SPARKY", "re-nicknamed for the empty-clear check") + check(Ops.setNickname(S, mon, "") == true, "an empty name is a valid clear") + eq(mon.nickname, nil, "an empty name clears the nickname") + check(S.status:match("Cleared") ~= nil, "the clear narrates") + + Ops.setNickname(S, mon, "1234567890") + eq(mon.nickname, "1234567890", "a 10-glyph name is accepted") + S.dirty = false + check(Ops.setNickname(S, mon, "12345678901") == false, + "an 11-glyph name is refused") + eq(mon.nickname, "1234567890", "a refused name leaves the mon alone") + check(S.dirty == false, "a refused name does not dirty the save") + check(S.status:match("capped at 10") ~= nil, "the length refusal explains itself") + + check(Ops.setNickname(S, mon, "PIKA€") == false, + "a name with an unrenderable glyph is refused") + eq(mon.nickname, "1234567890", "a refused glyph leaves the mon alone") + check(S.status:match("cannot render") ~= nil, "the glyph refusal explains itself") + check(Ops.setNickname(S, mon, "POKE@MON") == false, + "a name with the invisible @ terminator is refused") + eq(mon.nickname, "1234567890", "a refused @ name leaves the mon alone") + check(S.status:match("cannot render") ~= nil, "the @ refusal explains itself") + + check(Ops.setNickname(S, nil, "X") == false, "setNickname without a mon refuses") + check(S.status:match("Pick a slot") ~= nil, "and explains itself") + check(Ops.clearNickname(S, nil) == false, "clearNickname without a mon refuses") + + -- the canonical round trip: what the game reads back is the same either way + mon.nickname = "SPARKY" + local encoded = SaveData.encode(S.save) + local back = SaveData.decode(encoded) + eq(back.party[1].nickname, "SPARKY", "a nickname survives a save round trip") +end + -- App.load corrupt-save vs missing-save (Important fix #2): App.load takes -- an optional path override precisely so tests can drive this without -- touching the real default save file. @@ -799,6 +899,83 @@ do for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end end +do + -- The inspector's nickname field is commit-on-Enter: the draft lives in + -- S.nicknameDraft while typing, Enter commits it through Ops.setNickname, + -- and Escape discards it. Drive it through App the way a player would: + -- focus the field (a click is just a Kit.focus assignment here), type, + -- drain the edits with a draw, then press Enter / Escape. + local Kit = require("Kit") + local tmpPath = os.tmpname() .. "-nickname-save.lua" + local data = SaveData.newGame() + data.party = { MonOps.create(Data, "CHARIZARD", 50) } + local f = io.open(tmpPath, "wb") + f:write(SaveData.encode(data)) + f:close() + + App.load(tmpPath, { version = "red" }) + local S = App.getState() + S.tab = "party" + Ops.selectParty(S, 1) + local mon = S.editingMon + eq(mon.nickname, nil, "the save starts un-nicknamed") + + -- type "SPARKY" and commit with Enter + Kit.focus = "mon-nickname" + App.textinput("SPARKY") + App.draw() + eq(S.nicknameDraft, "SPARKY", "typed text lands in the draft") + App.keypressed("return") + eq(mon.nickname, "SPARKY", "Enter commits the draft to the mon") + check(S.dirty == true, "the commit marks the save dirty") + check(Kit.focus == nil, "Enter blurs the field") + S.dirty = false + + -- The field has no select-all, so a rename is backspace-then-type (the + -- caret parks at the end, exactly like the editor's other fields). + local function clearField(n) + Kit.focus = "mon-nickname" + for _ = 1, n do App.keypressed("backspace") end + App.draw() + end + + -- type junk, then Escape: nothing is committed and the draft is discarded + clearField(#mon.nickname) + App.textinput("ZEPTO") + App.draw() + eq(S.nicknameDraft, "ZEPTO", "the draft holds the new typing") + App.keypressed("escape") + eq(mon.nickname, "SPARKY", "Escape does not commit") + eq(S.nicknameDraft, "SPARKY", "Escape resets the draft to the committed name") + check(Kit.focus == nil, "Escape blurs the field") + + -- an unrenderable glyph is blocked AT INPUT: the euro sign never reaches + -- the draft, so the field can only ever hold what the game can render + clearField(#mon.nickname) + App.textinput("PIKA\226\130\172") -- PIKA + euro sign, not a charmap glyph + App.draw() + eq(S.nicknameDraft, "PIKA", "an unrenderable glyph is dropped at input") + App.keypressed("return") + eq(mon.nickname, "PIKA", "the clean draft commits on Enter") + + -- and the 10-glyph cap blocks extra input the same way + clearField(#mon.nickname) + App.textinput("123456789012345") + App.draw() + eq(S.nicknameDraft, "1234567890", "typing past 10 glyphs clamps at 10") + + -- the @ terminator never reaches the draft either: it draws as a space + -- in-game, so the field strips it like any other unrenderable glyph. The + -- clamp test above left an uncommitted draft, so clear the whole draft. + clearField(#S.nicknameDraft) + App.textinput("POKE@MON") + App.draw() + eq(S.nicknameDraft, "POKEMON", "the @ terminator is stripped at input") + + os.remove(tmpPath) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + do -- #541 modal shield. Kit hit-tests without a z-order, so the picker cannot -- simply be drawn last: the chrome and the panel underneath would take the diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 92b21cd2..389cea06 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -446,16 +446,17 @@ check(misted2.stages.attack == nil and mistMsgs[1]:find("MIST", 1, true) ~= nil, "primary stat drop still blocked by MIST") --- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP --- (substitute.asm only fails on subtraction underflow) +-- Substitute boundary: the move must fail when its quarter-HP cost would +-- consume all current HP, preventing a zero-HP user with a live substitute. local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" } -MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) -check(subUser.substituteHP ~= nil and subUser.mon.hp == 0, - "substitute built at exactly 1/4 max HP leaves 0 HP") -local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } -local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) -check(subUser2.substituteHP == nil +local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) +check(subUser.substituteHP == nil and subUser.mon.hp == 10 and subMsgs[1]:find("weak", 1, true) ~= nil, + "substitute fails at exactly 1/4 max HP") +local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } +local subMsgs2 = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) +check(subUser2.substituteHP == nil and subUser2.mon.hp == 9 + and subMsgs2[1]:find("weak", 1, true) ~= nil, "substitute fails below 1/4 max HP") -- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of @@ -2759,29 +2760,43 @@ do -- SPEED below: a full loop of #STEPS presses returns to the 60 default. for _ = 1, #FrameCap.STEPS - 1 do press("a") end eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") + -- RFC 0007: the single GAME SPEED row is now three independent rows, + -- one per GameSpeed.CATEGORIES entry. press("down") - eq(om.index, 21, "cursor reaches GAME SPEED") + eq(om.index, 21, "cursor reaches OVERWORLD SPEED") press("a") - eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") + eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X") -- Driven by the level list rather than a literal press count: adding a -- speed (20X went in for the bot runs) otherwise fails this as a wrap -- bug when the cycling is fine and the row is simply one longer. for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end - eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") + eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL") press("down") - eq(om.index, 22, "cursor reaches MODS") + eq(om.index, 22, "cursor reaches BATTLE SPEED") + press("a") + eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X") + for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end + eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL") press("down") - eq(om.index, 23, "cursor reaches CONTROLS") + eq(om.index, 23, "cursor reaches MENU SPEED") + press("a") + eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X") + for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end + eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL") press("down") - eq(om.index, 24, "CANCEL stays the fixed final row") - eq(om.scroll, 19, "CANCEL keeps the last option boxes on screen") + eq(om.index, 24, "cursor reaches MODS") + press("down") + eq(om.index, 25, "cursor reaches CONTROLS") + press("down") + eq(om.index, 26, "CANCEL stays the fixed final row") + eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 24, "up from the top wraps to CANCEL") - eq(om2.scroll, 19, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 26, "up from the top wraps to CANCEL") + eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options) diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 8f0e02a4..8c4a9325 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -92,6 +92,17 @@ def version_for_manifest(manifest, requested_version=None, manifest_explicit=Fal return detected or requested_version or "red" +def detect_rom_version(path): + """Read a canonical ROM once and return its supported game version.""" + rom = RomImage(path, None) + version = SHA1_TO_VERSION.get(rom.sha1) + if version is None: + expected = ", ".join(VERSION_SHA1[name] for name in VERSION_MANIFESTS) + raise ValueError( + f"unsupported ROM SHA-1 {rom.sha1}; expected one of {expected}") + return version, rom + + def extract_constants(manifest, out_dir): data = manifest["constants"] util.write_lua( @@ -1736,6 +1747,17 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): "title/copyright.png") raw_2bpp( "GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png") + # Yellow NineTile (pokeyellow gfx/font.asm): final "9" of (c)1995-1999, + # parked in the 16 bytes between GameFreakLogoGraphics and TextBoxGraphics. + if _has_symbol(symbols, "GameFreakLogoGraphics") \ + and _has_symbol(symbols, "TextBoxGraphics"): + gf = _symbol(symbols, "GameFreakLogoGraphics") + tb = _symbol(symbols, "TextBoxGraphics") + if tb.address == gf.address + 9 * 16 + 16: + nine_raw = rom.bytes(gf.bank, gf.address + 9 * 16, 16) + _save_png( + _decode_2bpp(nine_raw, 8, 8, False), + os.path.join(assets_dir, "title/nine.png")) # Yellow fixed Pikachu title (pret/pokeyellow title_yellow.asm): tilemap # composition over both tile banks -- PokemonLogoGraphics in vChars2 @@ -2086,48 +2108,73 @@ def build(rom, symbols, manifest, out_dir, assets_dir, datasets): return results -def main(): +def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--rom", required=True, help="canonical US Pokemon Red, Blue, or Yellow ROM") parser.add_argument( - "--version", choices=sorted(VERSION_MANIFESTS), default="red", - help="select the shipped manifest for this version (default: red)") + "--version", choices=["auto", *sorted(VERSION_MANIFESTS)], default="auto", + help="select the shipped manifest for this version (default: detect from ROM)") parser.add_argument( "--manifest", default=None, help="explicit manifest path (overrides --version default path; " "RomImage hash still comes from the file's romSha1)") - parser.add_argument("--out", default="data/generated") - parser.add_argument("--assets", default="assets/generated") + parser.add_argument( + "--out", default=None, + help="generated data directory (default: version-specific cache path)") + parser.add_argument( + "--assets", default=None, + help="generated assets directory (default: version-specific cache path)") parser.add_argument("--clean", action="store_true") parser.add_argument( "--only", action="append", choices=DATASETS, help="build one dataset (repeatable); default builds all implemented") - args = parser.parse_args() + args = parser.parse_args(argv) try: manifest_explicit = args.manifest is not None - manifest_path = resolve_manifest_path(args.version, args.manifest) - manifest = load_manifest(manifest_path) - version = version_for_manifest( - manifest, args.version, manifest_explicit=manifest_explicit) - expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] - rom = RomImage(args.rom, expected_sha1) + requested_version = None if args.version == "auto" else args.version + if manifest_explicit: + manifest_path = resolve_manifest_path( + requested_version or "red", args.manifest) + manifest = load_manifest(manifest_path) + version = version_for_manifest( + manifest, requested_version, manifest_explicit=True) + expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] + rom = RomImage(args.rom, expected_sha1) + elif requested_version is None: + version, rom = detect_rom_version(args.rom) + manifest_path = resolve_manifest_path(version, None) + manifest = load_manifest(manifest_path) + expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] + if rom.sha1 != expected_sha1: + raise ValueError( + f"unsupported ROM SHA-1 {rom.sha1}; expected {expected_sha1}") + else: + version = requested_version + manifest_path = resolve_manifest_path(version, None) + manifest = load_manifest(manifest_path) + version = version_for_manifest(manifest, version) + expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version] + rom = RomImage(args.rom, expected_sha1) symbols = SymbolTable(manifest["symbols"]) except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 + prefix = "" if version == "red" else version + os.sep + out_dir = args.out or prefix + os.path.join("data", "generated") + assets_dir = args.assets or prefix + os.path.join("assets", "generated") if args.clean: - for path in (args.out, args.assets): + for path in (out_dir, assets_dir): if os.path.isdir(path): shutil.rmtree(path) - os.makedirs(args.out, exist_ok=True) - os.makedirs(args.assets, exist_ok=True) + os.makedirs(out_dir, exist_ok=True) + os.makedirs(assets_dir, exist_ok=True) datasets = tuple(args.only) if args.only else DATASETS try: - build(rom, symbols, manifest, args.out, args.assets, datasets) + build(rom, symbols, manifest, out_dir, assets_dir, datasets) except (ValueError, KeyError, IndexError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 diff --git a/tools/extract/field.py b/tools/extract/field.py index 36740642..839be555 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -1471,9 +1471,11 @@ def extract(pokered, out_dir): util.die("bike riding tileset extraction sanity check failed") if indoor_encounters["firstIndoorMap"] != 0x25: util.die("indoor encounter boundary sanity check failed") - if len(title) != 5 or any(not v["width"] for v in title.values()) \ + if len(title) not in (5, 6) or any(not v["width"] for v in title.values()) \ or (title["gamefreakInc"]["width"], - title["gamefreakInc"]["height"]) != (72, 8): + title["gamefreakInc"]["height"]) != (72, 8) \ + or ("nine" in title and (title["nine"]["width"], + title["nine"]["height"]) != (8, 8)): util.die("title asset extraction sanity check failed") if any((intro["gengar"][f]["width"], intro["gengar"][f]["height"]) != (56, 56) for f in ("frame1", "frame2", "frame3")) \ diff --git a/tools/extract/gfx.py b/tools/extract/gfx.py index 6a75483b..4dcbbf5b 100644 --- a/tools/extract/gfx.py +++ b/tools/extract/gfx.py @@ -130,6 +130,12 @@ TITLE_GRAPHICS = [ ("gamefreakInc", "gfx/title/gamefreak_inc.png", False), ] +# Yellow only (pokeyellow gfx/font.asm NineTile): final "9" of (c)1995-1999 +# on the title copyright line and intro/credits copyright card. +OPTIONAL_TITLE_GRAPHICS = [ + ("nine", "gfx/title/nine.png", False), +] + def extract_title(pokered, assets_dir): """Convert the title-screen graphics to assets/generated/title/. @@ -150,6 +156,19 @@ def extract_title(pokered, assets_dir): "height": size[1], "source": src_rel, } + for key, src_rel, matte in OPTIONAL_TITLE_GRAPHICS: + src = os.path.join(pokered, src_rel) + if not os.path.isfile(src): + continue + base = os.path.basename(src_rel) + size = convert_png(src, os.path.join(assets_dir, "title", base), + transparent_matte=matte) + out[key] = { + "path": f"assets/generated/title/{base}", + "width": size[0], + "height": size[1], + "source": src_rel, + } return out diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 4e0e3782..de491f63 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -481,6 +481,12 @@ def derive(red, pokeyellow, symbols_path): for i, name in enumerate(yellow_bubbles)], } + # The Oak-speech show-off mon is the player's Pikachu in Yellow + # (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo); + # the deep-copied Red field.oakSpeech has no demoSpecies, so stamp + # it or the import falls back to NIDORINO (#915). + yellow["field"]["oakSpeech"]["demoSpecies"] = "PIKACHU" + # Ensure Melanie / Summer Beach town-map entries exist after rebuild. locations = yellow["field"]["townMap"]["locations"] if "CERULEAN_MELANIES_HOUSE" not in locations \ diff --git a/tools/modkit.py b/tools/modkit.py index 522b8008..09f868a5 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -716,7 +716,7 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None): driver_path = handle.name try: proc = subprocess.run([LUAJIT, driver_path], cwd=repo, - capture_output=True, text=True, timeout=120) + capture_output=True, text=True, encoding="utf-8", timeout=120) except FileNotFoundError: findings.append(Finding("MK100", "error", f"cannot run {LUAJIT} (install luajit or " @@ -1056,7 +1056,7 @@ def check_data_dump(repo, path, base, rel): driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla)) try: proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo, - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, encoding="utf-8", timeout=60) except FileNotFoundError: # the gate must fail closed: a missing interpreter is a broken # environment, not a clean mod @@ -1085,6 +1085,20 @@ def cmd_lint(args, repo): # ---------------------------------------------------------------- pack +def pack_timestamp(): + raw = os.environ.get("SOURCE_DATE_EPOCH") + if raw is None: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), None + try: + epoch = int(raw, 10) + if epoch < 0: + raise ValueError("negative epoch") + stamp = datetime.fromtimestamp(epoch, timezone.utc) + except (ValueError, OverflowError, OSError): + return None, "SOURCE_DATE_EPOCH must be a nonnegative Unix timestamp" + return stamp.strftime("%Y-%m-%dT%H:%M:%SZ"), None + + def cmd_pack(args, repo): mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: @@ -1119,6 +1133,10 @@ def cmd_pack(args, repo): mod_id = manifest["id"] version = manifest.get("version", "0.0.0") out = args.output or f"{mod_id}-{version}.modpkg" + packed_at, timestamp_problem = pack_timestamp() + if timestamp_problem: + print(f"modkit: {timestamp_problem}") + return 2 files = mod_files(mod_dir) records = [] for rel in files: @@ -1127,8 +1145,7 @@ def cmd_pack(args, repo): "sha256": hashlib.sha256(body).hexdigest()}) pack_meta = { "modkit": MODKIT_VERSION, - "packed_at": datetime.now(timezone.utc) - .strftime("%Y-%m-%dT%H:%M:%SZ"), + "packed_at": packed_at, "id": mod_id, "version": version, "api": manifest.get("api", 1), @@ -1399,7 +1416,7 @@ def dump_dataset(repo, base): handle.close() try: proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name], - cwd=repo, capture_output=True, text=True) + cwd=repo, capture_output=True, text=True, encoding="utf-8") finally: os.unlink(handle.name) if proc.returncode != 0: diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9bda56d2..9fa9e696 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -6566,6 +6566,7 @@ } ], "oakSpeech": { + "demoSpecies": "PIKACHU", "shrink1": "assets/generated/intro/shrink1.png", "shrink2": "assets/generated/intro/shrink2.png" }, diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 5d421f8e..7b5d3c21 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -842,6 +842,24 @@ function App.keypressed(key) return end end + -- The inspector's nickname field is a commit-on-Enter field, unlike the + -- search fields, which are live view state. Enter commits the draft through + -- Ops and blurs; Escape discards it and blurs. Both must run before + -- Kit.keypressed, which maps return/escape to the same "\r" edit and cannot + -- tell "commit" from "cancel". + if Kit.focus == "mon-nickname" then + if key == "return" or key == "kpenter" then + if S.editingMon and Ops.setNickname(S, S.editingMon, S.nicknameDraft) then + S.nicknameDraft = S.editingMon.nickname or "" + end + Kit.blur() + return + elseif key == "escape" then + Kit.blur() + if S.editingMon then S.nicknameDraft = S.editingMon.nickname or "" end + return + end + end -- A focused text field eats the keys it cares about (typing "s" into the -- map filter must not trigger Save). if Kit.keypressed(key) then return end diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 4c40d920..2197d7a4 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -426,7 +426,12 @@ end -- state because Kit had no input widget; this replaces that hack, and App -- routes love.textinput / love.keypressed in through Kit.textinput / -- Kit.keypressed. Returns the (possibly edited) value; the caller stores it. -function Kit.textfield(id, x, y, w, h, value, placeholder) +-- +-- `opts.sanitize(value)` (optional) is a post-filter run on the merged text +-- right after this frame's edits and BEFORE it draws, so a keystroke or paste +-- the filter rejects never even flashes on screen. It gets the whole value +-- because a paste arrives as one textinput chunk alongside existing text. +function Kit.textfield(id, x, y, w, h, value, placeholder, opts) audit("control", x, y, w, h, id) value = tostring(value or "") if Kit.press(x, y, w, h) then Kit.focus = id end @@ -444,6 +449,9 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) value = value .. e end end + if opts and opts.sanitize then + value = opts.sanitize(value) + end end if G then local r = 8 * Kit.scale diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 853c2d6b..5da44907 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -16,12 +16,17 @@ local PartyMod = require("src.pokemon.Party") local BoxesMod = require("src.pokemon.Boxes") local Bag = require("src.inventory.Bag") local MonOps = require("MonOps") +local Charmap = require("src.save_convert.data.charmap") local Ops = {} Ops.MONEY_MAX = 999999 Ops.STACK_MAX = 99 Ops.ARM_SECONDS = 2.5 +-- The in-game naming screen caps a nickname at 10 glyphs +-- (BattleState:askNicknameUI / src/ui/NamingScreen.lua maxLen = 10); the +-- editor mirrors that cap instead of inventing its own. +Ops.NICKNAME_MAX = 10 local function clamp(n, lo, hi) if n < lo then return lo end @@ -400,6 +405,154 @@ function Ops.healMon(S, mon) return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp)) end +-- ----------------------------------------------------------------- nicknames +-- Gen1 has no "is nicknamed" bit: an un-nicknamed mon is mon.nickname == nil, +-- and every display site reads `mon.nickname or def.name` +-- (src/save_convert/GenSave.lua). The editor edits that field directly. + +-- The byte length of the UTF-8 glyph starting at lead byte `b`. Self-contained +-- so this (and eachGlyph) also runs headless under luajit, which has no `utf8` +-- standard library. +local function glyphByteLen(b) + if b < 0x80 then return 1 end + if b < 0xE0 then return 2 end + if b < 0xF0 then return 3 end + return 4 +end + +-- Walk `name` one UTF-8 glyph at a time; fn(glyph) returning false stops the +-- walk early and eachGlyph returns false. Returns true when every glyph was +-- visited. The single place that walks a name, so the count / validate / +-- sanitize paths cannot drift apart (a glyph is "é" or "♂", not one of its +-- bytes, exactly as the naming screen counts its grid cells). +local function eachGlyph(name, fn) + local i, n = 1, #name + while i <= n do + local b = name:byte(i) + local ch = name:sub(i, i + glyphByteLen(b) - 1) + if fn(ch) == false then return false end + i = i + #ch + end + return true +end + +-- Glyph count, not byte count: "é" or "♂" is ONE game character, exactly as +-- the naming screen counts its grid cells and GenSave.encodeName counts a +-- charmap sequence. +function Ops.nicknameLength(name) + local n = 0 + eachGlyph(tostring(name or ""), function() n = n + 1 end) + return n +end + +-- The set of glyphs a nickname may hold: present in BOTH the Gen1 text codec +-- charmap (so the name round-trips through a .sav) and the game's font +-- charmap (so it actually draws). The codec alone is not enough: "@" is the +-- string-terminator byte, and "#" plus the dakuten kana have codec entries +-- but no font tile, so Font.encode (src/render/Font.lua) draws them as a +-- space -- an invisible nickname. Only single-codepoint entries qualify: +-- multi-character macros ("", the 'd ligature) cannot be typed one +-- character at a time, so they have no place in the input gate. +-- Built once per loaded font table (a mod replacing the font rebuilds it); +-- falls back to the codec-only set when no font data is loaded (headless +-- suites that never call Data:load). +local glyphCache, glyphCacheFont +local function nameGlyphSet(S) + local font = S and S.data and S.data.font + if not (font and font.charmap) then return Charmap.byToken end + if glyphCache and glyphCacheFont == font then return glyphCache end + local set = {} + for _, e in ipairs(font.charmap) do + local s = e.seq + if type(s) == "string" and s ~= "" and Charmap.byToken[s] + and #s == glyphByteLen(s:byte(1)) then + set[s] = true + end + end + glyphCache, glyphCacheFont = set, font + return set +end + +-- True when every glyph is a legal nickname glyph (see nameGlyphSet): the +-- name can be stored in a .sav AND draws in the game. Anything else either +-- encodes as "?" (GenSave.encodeName) or renders as a space (Font.encode), +-- which the user did not ask for, so it is refused rather than mangled. +function Ops.nicknameUsable(S, name) + local set = nameGlyphSet(S) + return eachGlyph(tostring(name or ""), function(ch) + return set[ch] ~= nil + end) +end + +-- The species' display name, what an un-nicknamed mon reads as. +local function speciesName(S, species) + local def = species and S.data.pokemon[species] + return (def and def.name) or tostring(species or "") +end + +-- The input gate for the inspector's nickname field. Given the whole draft +-- (existing text plus this frame's keystrokes and any paste), return the +-- version the game can actually hold: every glyph kept draws in the game +-- (see nameGlyphSet) and the result never exceeds the naming screen's +-- 10-glyph cap. Unrenderable glyphs are skipped, not used to abort the rest +-- of the string, so a paste of "PIKA€CHU" lands as "PIKACHU". The field runs +-- this through Kit.textfield's opts.sanitize, so a blocked character never +-- appears at all. +function Ops.nicknameSanitize(S, name) + local set = nameGlyphSet(S) + local out, count = {}, 0 + eachGlyph(tostring(name or ""), function(ch) + if count < Ops.NICKNAME_MAX and set[ch] then + out[#out + 1] = ch + count = count + 1 + end + end) + return table.concat(out) +end + +-- One verb for both writing and clearing. An empty field means "no nickname", +-- exactly like an empty confirm on the in-game naming screen (which falls +-- through to the species' standard name). A name that equals the species' +-- standard name is the un-nicknamed state in this save format +-- (importedNickname in GenSave.lua maps exactly that to nil), so it is +-- normalized to nil rather than stored as a literal copy of the default. +function Ops.setNickname(S, mon, name) + if not mon then return Ops.say(S, "Pick a slot first") end + name = tostring(name or "") + if name == "" then + return Ops.clearNickname(S, mon) + end + if name == mon.nickname then + return Ops.say(S, ("Already nicknamed %s"):format(name)) + end + if name == speciesName(S, mon.species) then + if mon.nickname == nil then + return Ops.say(S, ("%s is already un-nicknamed"):format(mon.species)) + end + mon.nickname = nil + return Ops.mark(S, ("%s matches its standard name; nickname cleared") + :format(name)) + end + if Ops.nicknameLength(name) > Ops.NICKNAME_MAX then + return Ops.say(S, ("Nicknames are capped at %d characters"):format(Ops.NICKNAME_MAX)) + end + if not Ops.nicknameUsable(S, name) then + return Ops.say(S, + "That name has characters the game cannot render or export cleanly") + end + mon.nickname = name + return Ops.mark(S, ("Nicknamed %s \"%s\""):format(mon.species, name)) +end + +function Ops.clearNickname(S, mon) + if not mon then return Ops.say(S, "Pick a slot first") end + if mon.nickname == nil then + return Ops.say(S, ("%s has no nickname to clear"):format(mon.species)) + end + mon.nickname = nil + return Ops.mark(S, ("Cleared %s's nickname"):format(mon.species)) +end + -- ------------------------------------------------------------------ boxes function Ops.boxes(S) return BoxesMod.ensure(S.save) diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index cdbf84ea..258e32d2 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -43,6 +43,8 @@ function State.new() partyOffset = 0, -- roster scroll position (#715) inspectorScroll = 0, -- MonEditor body pixel scroll (#715) editingMon = nil, -- reference into party or a box + nicknameDraft = nil, -- text being typed in the inspector's nickname field + nicknameMon = nil, -- the mon the draft belongs to (nil for none) -- species picker overlay: nil when closed, otherwise { query, offset } -- plus mode = "box-add" when it is adding to a box instead of changing a -- species (Ops.openBoxAddPicker). Modal in the literal sense -- App diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 0a2230ee..8ccdc13d 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -218,7 +218,11 @@ function MonEditor.draw(S, Kit, x, y, w, h) else colsH = capH + 10 * s + colRowsH + 12 * s + actH end + -- the nickname section: a caption line (with the Clear button on it) plus + -- the field + Set row + local nickFieldH = 30 * s local contentH = pad + headerH + 18 * s + + capH + 10 * s + nickFieldH + 18 * s + capH + 10 * s + cellH + 18 * s + colsH + pad @@ -266,8 +270,45 @@ function MonEditor.draw(S, Kit, x, y, w, h) drawLevelRow(S, Kit, mon, cx, cy + math.max(sprite, titleH) + 12 * s) end + -- ---------------------------------------------------------- nickname + -- Editing the field is a draft (S.nicknameDraft) held on the mon it belongs + -- to; Set / Enter commit it through Ops.setNickname, which clears on an + -- empty value, and Clear goes through Ops.clearNickname. The draft resets + -- when the selection moves so one mon's typing can never leak onto another. + local nickY = cy + headerH + 18 * s + Kit.caption(cx, nickY, "NICKNAME") + local clearW = 74 * s + local clearH = 24 * s + if Kit.button(cx + inner - clearW, nickY + (capH - clearH) / 2, clearW, clearH, + "Clear", { kind = "danger", font = "micro", radius = 6 * s }) then + Ops.clearNickname(S, mon) + S.nicknameDraft = "" + end + local fieldY = nickY + capH + 10 * s + local setW = 64 * s + local fieldW = inner - setW - 10 * s + if S.nicknameMon ~= mon then + local switching = S.nicknameMon ~= nil + S.nicknameMon = mon + S.nicknameDraft = mon.nickname or "" + -- a still-focused field would keep appending keystrokes to the newly + -- selected mon; the selection move counts as leaving the field. The + -- first sync (nicknameMon starts nil) never blurs: the species picker + -- owns focus when it opens, and blurring there drops the player's typing. + if switching and Kit.focus == "mon-nickname" then Kit.blur() end + end + S.nicknameDraft = Kit.textfield("mon-nickname", cx, fieldY, fieldW, nickFieldH, + S.nicknameDraft or "", "no nickname", + { sanitize = function(value) return Ops.nicknameSanitize(S, value) end }) + if Kit.button(cx + fieldW + 10 * s, fieldY, setW, nickFieldH, "Set", + { kind = "accent", font = "small", radius = 8 * s }) then + if Ops.setNickname(S, mon, S.nicknameDraft) then + S.nicknameDraft = mon.nickname or "" + end + end + -- ------------------------------------------------------- derived stats - local statsY = cy + headerH + 18 * s + local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s Kit.caption(cx, statsY, "STATS . recalculated from level + DVs") statsY = statsY + capH + 10 * s local gap = 12 * s