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/docs/modding.md b/docs/modding.md index 31721d9c..8bbb7dde 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -140,6 +140,62 @@ 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. + +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 +277,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 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..54e928de --- /dev/null +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -0,0 +1,132 @@ +# 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`. + +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.** No existing hook, event, save, controller, or world action changes +when `mod.checkpoints` is unused. The reconstruction path is called only by a +successful public restore after validation. + +## 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, and injected reconstruction rollback. + +## 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..6735f5d2 --- /dev/null +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -0,0 +1,133 @@ +# 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. + +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. + +## 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 and RNG field are additive. Overworld-only callers may continue to +filter `capability.kind`. 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; +- legacy overworld checkpoint compatibility; +- complete ROM-free engine and public mod-API suites. 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/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..86b2ab18 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -98,6 +98,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. @@ -424,7 +433,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 +1420,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 +1491,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 +1500,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 @@ -5587,9 +5615,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 +5627,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 +5638,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/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..f6827fa8 --- /dev/null +++ b/src/core/Checkpoint.lua @@ -0,0 +1,415 @@ +-- 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 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 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/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..5226dd16 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1130,4 +1130,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/Music.lua b/src/core/Music.lua index be6ba0e1..745d45a9 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -368,11 +368,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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 882897bb..8de64ea8 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() @@ -351,6 +358,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 +387,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 +537,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 +877,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 +971,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 +1182,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 +1612,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/RomImporter.lua b/src/import/RomImporter.lua index de2d3e7d..a38c5b0e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -156,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 @@ -244,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/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..daca6548 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") 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..c834ce41 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 })', 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/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/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/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..c44f5718 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -585,8 +585,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..6fdb6b90 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, diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index f7d3ffe5..aec5c71a 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -107,7 +107,12 @@ local CYCLE_FRAMES = 240 -- the original waits ~4s between picks 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,13 +156,28 @@ 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") self.blue = GameVersion.isBlue() self.yellow = GameVersion.isYellow() or title.layout == "yellow_pikachu" @@ -347,8 +367,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,8 +425,10 @@ 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 @@ -490,7 +516,10 @@ function TitleState:draw() -- the Yellow fallback layout draws no ribbon at all. if self.version and not self.yellow then local iw, ih = self.version:getDimensions() - if self.blue then + if self.versionFull then + -- a continuous ribbon (versionRibbon) centers as one piece + love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64) + elseif self.blue then love.graphics.draw(self.version, love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64) else diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d6fcdb56..1ac89816 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -206,7 +206,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 @@ -227,7 +227,7 @@ function OverworldState:enter(mapId, x, y, facing) -- 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 +282,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 @@ -460,13 +460,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 +476,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 +764,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 +973,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 +1012,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 @@ -3045,6 +3059,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 @@ -3582,6 +3604,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) @@ -3986,6 +4012,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 +4195,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 @@ -4860,7 +4924,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 +4976,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/WorldAPI.lua b/src/world/WorldAPI.lua index fb2488af..3654a128 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,6 +6,7 @@ -- 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") @@ -14,6 +15,45 @@ local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local overviewShades = {} + +Assets.register(function() overviewShades = {} 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, perRow = {}, tileset.tilesPerRow + for ty = 0, map.heightCells * 2 - 1 do + local row = {} + for tx = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(tx, ty) + local shade = cached.shades[tile] + if shade == nil then + local sum = 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) + sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + shade = tostring(math.max(0, math.min(3, + math.floor((1 - sum / 64) * 3 + 0.5)))) + cached.shades[tile] = shade + end + row[#row + 1] = shade + end + rows[#rows + 1] = table.concat(row) + end + return rows +end function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) @@ -44,6 +84,29 @@ 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). +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + local map, rows = 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 tileRows = mapTileRows(map) + return { mapId = map.id, width = map.widthCells, + height = map.heightCells, rows = rows, tileRows = tileRows, + tileWidth = tileRows and map.widthCells * 2, + tileHeight = tileRows and map.heightCells * 2 } +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) 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/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/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/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/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/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/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/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/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/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua new file mode 100644 index 00000000..d0a4b825 --- /dev/null +++ b/tests/engine/world_map_overview_test.lua @@ -0,0 +1,42 @@ +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) + local shade = x < 8 and 1 or 0 + 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 } +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 + +api = WorldAPI.new({ stack = { states = { + { isOverworld = true, map = map }, +} } }, "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") + +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], "0303", "tile overview preserves map shading") + +T.finish("world map overview") 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/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_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/tools/build_rom_data.py b/tools/build_rom_data.py index 8f0e02a4..c4020d30 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( @@ -2086,48 +2097,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/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..4eaf7f8b 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -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), 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" },