diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..4da038a33 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,60 @@ +name: API 3 contracts +on: + pull_request: + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + reference: [79ea62f45fb2ea8f65d081a109431e4eb1bbdb36, 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91] + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: ${{ matrix.reference }} + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report-${{ matrix.reference }} + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 0ba18515f..19c16fb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,10 @@ mobile/ios/bundle_id.local # secrets as public -- but GitHub's secret scanning flags it, so it is kept out # of the repository and injected into release builds instead. /cloud_clients.lua + +# Owned compatibility fixtures; external source remains outside this checkout. +!/tests/fixtures/ +/tests/fixtures/* +!/tests/fixtures/upstream_mods/ +!/tests/fixtures/legacy_npc_controls/ +!/tests/engine/ diff --git a/docs/api3-npc.md b/docs/api3-npc.md new file mode 100644 index 000000000..3a38e387b --- /dev/null +++ b/docs/api3-npc.md @@ -0,0 +1,142 @@ +# API-3 public NPC actions + +Generations 1–3 support the same public creation, lookup and removal calls as +Gen1Recomp. Check availability and retain the world facade at mod entry: + +```lua +local mod = ... +local world = mod.world + +local function greet(mapId, spriteId) + if not mod:supports("world", "spawnNpc") then return end + local id, why = world:spawnNpc(mapId, { + name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down", + }) + if not id then return nil, why end + local npc, reason = world:npc(mapId, id) + if not npc then return nil, reason end + return npc:scriptMove("down", 2, function() + npc:face("left") + end) +end +``` + +Invoke this later from an idle world. Reading the facade never bootstraps Game. +Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior; +this does not add portable `queueScript` verbs. + +## Creation, lookup and ownership + +- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`. + It can add a runtime definition to a known inactive map; an actor appears when + the native map lifecycle instantiates it. The return value promises a recorded + definition, not immediate live lookup or a common lifecycle event order. +- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References + are the live actor's string ID, its definition's numeric index or its name. + Only the active map and currently live neighboring map actors can be queried. +- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by + the calling mod may be removed. Imported objects and other owners are refused. + A mod may still look up and control an imported live actor. + +Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the +current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is +absent. IDs/names are nonempty strings of at most 256 bytes without control +characters; numeric references are finite nonnegative integers. Use the ID +returned by `spawnNpc` for removal. + +Definitions are copied before native construction. Required `x` and `y` are +whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is +`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers. +A supplied sprite must exist in the active sprite registry. Native generation +fields remain supported as data; this API does not invent a canonical sprite, +movement or NPC definition schema. The host assigns runtime ownership and +identity, ignoring caller-supplied ownership/index markers. + +The copy accepts plain acyclic tables, strings, finite numbers and booleans. +Functions, userdata, metatables and cycles are refused before native writes. +Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes +per string and 64 KiB total string bytes. Refusal returns `nil, reason`. + +## Handles + +Handles expose `id` and these methods; they never expose the native actor: + +| Method | Result and behavior | +| --- | --- | +| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. | +| `isMoving()` | Boolean native movement state. | +| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. | +| `face(direction)` | Set facing. | +| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. | +| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. | +| `setPassable(boolean)` | Set the live actor's passability. | +| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. | +| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. | +| `marchInPlace(onDone?)` | Queue one native stationary march. | + +Directions are the four lowercase names above. Mutations return `true` or +`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are +bound to the original actor, map instance, Game, save, data and version. Reused +native IDs or editing the public `id` field cannot redirect a retained handle. +The facade follows the current Game, while old actor handles do not. + +Spawn, removal and handle mutations use the shared API-3 idle-world guard: +a save and topmost overworld are required; menus, native/portable/parallel +scripts, player movement, controller locks, transitions and link sessions refuse +admission. Reads remain usable during overlays. Synchronous reentry into NPC or +object/block mutations is refused. Movement tracks require the active map; +other handle calls also work for live neighboring actors. + +`stepNow` and `placeAt` are authored operations, matching the native distinction +from `canStep`: they do not automatically perform collision validation or clamp +to map bounds. Choose coordinates and query collision as appropriate for the +scene. Appearance changes construct a renderer before committing the actor +change, retain its cell position and leave the native template sprite intact; +they are not persistent content edits. + +## Movement and mod lifetime + +Completion callbacks are optional, one-shot and attributed to the initiating +mod. Native synchronous completion is deferred until admission finishes and +the shared mutation guard is released. Native callback arguments, including +nil holes, are preserved. Callback errors are contained and reported once. + +Pending completions are permanently retired on observed Game/save/data/world, +map, stack, version, actor or runtime-bus replacement, and on native map lifecycle +events. The input-step boundary observes replacements before and after native +updates. Retiring removes only the movement entries owned by that operation; +it does not pop screens or cancel another mod's tracks. + +Rollback, reload and teardown retire the mod's facade and pending tracks and +remove its recorded runtime definitions/actors, including definitions spawned +on inactive maps and instantiated later as neighbors. Foreign runtime objects +and imported actors remain. Controls already applied to imported actors are +ordinary native scene changes, not a transactional patch with automatic undo. +Retained handles/facades return `nil, "world facade is retired"` after retirement. + +## Verification + +```sh +luajit tests/engine/api3_npc.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The clean Gen1 reference stays pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs +through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons +and 96 isolated runs. The shared fixture compares creation/lookup, detached +definitions, collision/passability, appearance, authored/queued movement, +callback replay/arguments, optional callbacks and ownership-aware removal. + +Local tests additionally cover bounded validation, busy worlds, reentry, stale +sessions/handles, callback errors, lifecycle cancellation and scoped cleanup. +They exercise the host's actual NPC constructor, native runtime add/remove, +scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts. +Synthetic datasets and a deterministic SpriteRenderer construction seam keep +these ROM-free. The tests verify the documented shared subset, not retail-ROM +playthroughs, GPU artwork or identical native internals/event timing. Host +validation and lifecycle guards are intentionally stricter than some reference +native paths. diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..5065c4c1a --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,158 @@ +# Shared API 3 entry + +Use the same manifest and entry file on Gen1Recomp and Gen2Recomped: + +```json +{ + "id": "portable_example", + "name": "Portable Example", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} +``` + +```lua +local mod = ... +if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", {power = 45}) + if not ok then mod.log:warn("%s", reason) end +end +``` + +Create a portable starter with: + +```sh +python3 tools/modkit.py scaffold portable_example --api 3 +``` + +It generates the shared games list and a guarded canonical move patch. It +omits an engine-release range because Gen1 and Gen2 have independent engine +version numbers. The default scaffold remains API 2 with its existing range +and profile templates. The `--api` value is now honored instead of silently +emitting API 2 for every value. + +API 3 automatically selects unified content, callbacks and portable scripts. +No `content_format`, `callback_format` or `script_format` fields are needed. +An explicit `"native"` for one of those fields is an error under API 3; +`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains +its existing individual opt-ins. The engine advertises `Version.modApi = 3`. +A higher unknown API remains a load error. + +This is a common entry and a tested compatibility subset, not a claim that +all of Gen1Recomp's services have been ported. It builds on PRs #90–#99. +The existing unified-content limitations still apply: unsupported writes +return `nil, reason` before recording an operation, native diagnostics are +read-only, and ordinary schema errors raise an attributed load error. +Entry failure rolls back the mod's content and subscriptions. Content is +frozen after loading. + +## Public contracts + +Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the +existing unified adapters. Other registries retain their native record +shapes, rather than inventing canonical fields. Every content handle exposes +`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration +return detached values too; change data through the write methods. + +The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`, +`PAR`, `TOX`), nickname-first display names and a common Special alias. +Writing `stats.special` in a writable callback sets both later Special stats; +contradictory split values are rejected before commit. API-2 unified status +names and callback behavior are unchanged. DVs/shiny presentation is read-only +in the callback boundary. Party and battle reads remain detached snapshots. + +Capture `mod.world` and `mod.battle` at entry time. World methods can be called +before Game exists and return `nil, reason`; reading the facade never boots +Game. The same world handle follows the loader's current Game/save instead of +retaining an earlier one. Battle snapshots and intents retain their existing +revision/replay validation. + +The common world calls currently enabled for generations 1–3 are: + +- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`, + `effectiveEncounters`; +- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, + `canFly`, `flyTo`; +- `getFlag`/`setFlag` for private `mod:` boolean flags; +- `queueScript` for the existing bounded portable script language; +- [`warpTo`](api3-warp.md) for validated native map transitions; +- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native + visibility and active block/metatile edits; +- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, + ownership-aware removal and opaque live actor controls. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: `startWildBattle` and `invalidateMap` have not yet been verified against the +Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native +symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. +Gen4/5 do not claim the shared world surface in this first entry PR. + +Private flags use `save.modData[modId].worldFlags`, independently of native +flags and other mods. A read does not create a bucket. Clearing removes the +stored key. A change emits `flag.changed` with +`name = "mod.."` and the boolean value; setting the same value +emits nothing. Invalid save stores are refused without overwriting them. + +Gen1 API-3 callback names from the pinned reference are recognized even when +this engine has no call site for them. Registration is allowed; availability +still requires a native call site in the selected version. For example, +`render.output` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases. Eight additional warp +cases use the same public mod on both engines and native-shaped timing seams. +Eight object/block cases and eight NPC cases extend this to 48 comparisons +and 96 isolated loader runs. It compares JSON projections of public results and +records hashes of both shared and generated fixture files. It refuses a different or modified reference. + +Coverage includes canonical content reads/writes/iteration and native merge +results, detached core and passthrough reads, callable early handles, later +Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native +writeback, custom events, unsupported-name handling, frozen content and failed +owner cleanup. Warp cases also compare validation errors, native dispatch +arguments, destination events, boundary coordinates, arrival options, callback +argument holes/replay, stale sessions/owners, synchronous completion and error +attribution. Object/block cases compare native reference routing, visibility/flag +changes, block geometry/bounds, native reads and one event per successful edit. +NPC cases compare runtime creation/lookup, movement/appearance, optional +completion callbacks and ownership-aware removal using the same public handles. +Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. diff --git a/docs/api3-warp.md b/docs/api3-warp.md new file mode 100644 index 000000000..6e4c1f055 --- /dev/null +++ b/docs/api3-warp.md @@ -0,0 +1,130 @@ +# Portable API-3 world warps + +API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped: + +```lua +local mod = ... +local world = mod.world -- Safe to retain at entry; does not create Game. + +-- Call later, from an idle overworld interaction/update. +local function goToDestination(mapId, cellX, cellY) + if not mod:supports("world", "warpTo") then return end + local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", { + keepMusic = true, + onDone = function() + mod.log:info("Arrived") + end, + }) + if not accepted then mod.log:warn("Warp declined: %s", reason) end +end +``` + +`warpTo(mapId, x, y, facing, options)` returns `true` after native admission, +or `nil, reason`. Admission does not mean arrival. It uses the existing native +`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping +and arrival-tile guard. No direct player/save relocation bypasses that path. +This API-3 adapter does not change API-1/2 facade defaults or validation. +Generations 1–3 advertise support; API 3 still refuses this world operation on +Gen4/5. Availability is a capability, not a promise that the current world is +idle or the named map exists. Reading the facade or querying support does not +bootstrap Game. + +## Admission and coordinates + +The current Game must have a save and a live, topmost overworld. Screens, +native/portable scripts, queued/parallel scripts, movement, transitions, +field/controller locks, link sessions and another pending API-3 warp decline. +Gen2Recomped reuses its existing world-action admission guard and checks its +additional native foreground owners. Idle-state details differ between the +engines; both keep their own native readiness checks. + +Map IDs are nonempty strings of at most 256 bytes, without control characters. +The map must exist in the current merged `game.data.maps`; custom merged maps +are supported. Gen3 also accepts unprefixed names when the current version's +prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias. +There is no cross-game map-name translation: the mod selects a real map in +the active dataset. + +`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel +walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3 +uses the map's metatile dimensions directly. The final valid cell is +`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse +before dispatch. Coordinates are bounded, not clamped; terrain passability is +not part of this contract. + +Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common +contract: `down` on generation 1, current player facing on generations 2/3, +with `down` as fallback. Explicit facing is recommended for portable scenes. + +Options must be a plain table. Only these fields are accepted: + +| Field | Meaning | +| --- | --- | +| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation | +| `keepMusic` | Boolean; forwarded to native map loading | +| `onDone` | Function, called at most once after accepted native completion | +| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen | + +Unknown options, invalid types and unsupported `fade` use decline the whole +request before native dispatch. Options are copied; later caller edits cannot +change an accepted request. Invalid admission does not set arrival effects, +change maps/coordinates/saves, raise map-entry events or call `onDone`. +Representative reasons include `no game`, `no overworld`, `no save`, +`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`, +`coordinates out of bounds`, `invalid facing`, `invalid options`, +`invalid arrival`, `invalid callback`, `unsupported fade` and +`unknown warp option: `. These are admission failures, not exceptions. +Native failures after dispatch can already have native effects; there is no +transactional rollback of an entered transition. + +## Callback ownership + +`onDone` runs only for an accepted request whose Game, save, data, stack, +overworld, player, version, loader buses and mod lease are still current, at +the requested destination. It preserves the native callback arguments, +including nil holes. Synchronous native completion is held until the native +return confirms admission. A rejected/throwing native dispatch cannot invoke +the mod callback, and duplicate native completions are ignored. + +A replaced session/owner observed at input-step boundaries, another map entry +or leaving the destination retires the completion permanently. Returning to an +older session cannot revive a completion already retired. Loader rollback, +reload and teardown retire the facade and its pending callback; a retained +facade returns `nil, "warp facade is retired"`. Retirement does not pop screens +or undo work belonging to the native controller. + +The callback is attributed to its mod, errors enter the loader's runtime error +list once, and the previous runtime owner is restored. An error never retries +a warp or the callback. A completion may start a new request after the native +world becomes idle. The adapter adds no synthetic map-entry event; normal +native map lifecycle remains in control. + +The portable `queueScript` language is unchanged: adding this direct method +does not enable a `warp` script row. + +## Verification + +```sh +luajit tests/engine/api3_warp.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin remains +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by +both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and +Emerald. The shared driver builds each engine's native-shaped Game/world data +and replaces only native transition timing seams. It compares validation +reasons, dispatch arguments, public destination events and callback results. +The mod has no engine imports or backend-specific branches. Together with the +existing normal, rollback, generated-starter and object/block cases, CI now +performs 48 comparisons / 96 isolated loader runs. + +The local suite additionally drives Gen2Recomped's real native +`startWarpTo` and `Transition` using synthetic map materialization, including +fade/no-fade, music options, native bookkeeping, lock release and one-shot +completion. Further cases cover host-specific locks, malformed maps, callback +reentry, native refusal/exception, lease retirement and API-2 behavior. These +are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered +arrival-effect equivalence is claimed. diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md new file mode 100644 index 000000000..1e66a6fa1 --- /dev/null +++ b/docs/api3-world-edits.md @@ -0,0 +1,124 @@ +# API-3 object visibility and block edits + +API-3 mods can call the same public methods on both engines: + +```lua +local mod = ... +local world = mod.world -- Retain at entry; invoke later from an idle world. + +local function openGate(mapId, objectRef, blockX, blockY, openBlock) + if not mod:supports("world", "toggleObject") then return end + local ok, why = world:toggleObject(mapId, objectRef, false) + if not ok then return nil, why end + if mod:supports("world", "replaceBlock") then + return world:replaceBlock(blockX, blockY, openBlock) + end +end +``` + +Each call returns `true` or `nil, reason`. Calls are independent, not an atomic +multi-command transaction. Support is advertised for generations 1–3; API-3 +Gen4/5 still refuse both methods. A captured facade follows the current Game +without bootstrapping one. Rollback, reload and teardown retire its mutation +lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior +and the portable script vocabulary are unchanged. + +Both methods require a save and an idle, topmost overworld. Native scripts, +portable scripts, queued/parallel scripts, menus, movement, transitions, +controller locks and link sessions refuse admission. Reentrant object/block +edits from an edit's own event/native callback return `nil, "world is busy"`; +the guard is released on success and native exceptions. + +## `toggleObject(mapId, objectRef, visible)` + +`mapId` and string references are nonempty strings of at most 256 bytes without +control characters. Numeric references must be finite nonnegative integers; +`visible` must be an actual boolean. The map must exist in the active merged +dataset. Gen3 also resolves unprefixed map names using the current version's +prefix when an exact ID is absent, as `warpTo` does. + +Reference routing and persistence deliberately retain the Gen1 API-3/native +contracts, rather than treating all generations' object numbers as identical: + +| Generation | Reference | Scope and effects | +| --- | --- | --- | +| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. | +| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. | +| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. | + +This API does not make map/object IDs portable between cartridges: select a +reference from the active dataset. On Gen2/3, an unknown reference returns +`nil, "no such object: "`; an inactive map returns +`nil, "map is not active: "`. Missing object lists and malformed native +object/store metadata refuse before native writes. Gen1's key store keeps its +existing ability to prepare a later map's visibility. + +Flagged Gen2/3 objects use their native persistent flags. Flagless objects +retain native visit-scoped behavior. A targeted sync does not respawn, relocate +or re-derive unrelated live actors, even when several templates share a flag. +Other native visibility gates (time, story flags, script-spawn rules) still +apply; acceptance is not a promise to override every native gate. + +A successful call emits one `world.object_toggled` event with +`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted +value still follows the native call/event contract. Gen1's active refresh may +also produce its normal native map lifecycle events. Gen2/3 do not reload the +whole map for this operation. + +## `replaceBlock(blockX, blockY, blockId)` + +This changes only the active map. Coordinates are zero-based whole **block** +coordinates, with `0 <= x < map.width` and `0 <= y < map.height`: + +| Generation | One coordinate unit | Valid ID range | +| --- | --- | --- | +| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset | +| 3 | 16-pixel metatile, one walk cell | 0–1023 | + +Coordinates/IDs must be finite integers. Gen3 range validation does not promise +that every ID has artwork in a particular tileset. The adapter checks the +current block, runtime geometry and redraw entry before mutation. Unknown +blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and +out-of-bounds coordinates are declined without writing a patch or emitting an +event. Typical reasons are `invalid block id`, `unknown block id`, +`invalid block coordinates`, `block coordinates out of bounds`, +`block unavailable`, `unsupported block geometry` and +`block renderer unavailable`. + +Gen1/2 use the existing native world block replacement. Gen3 uses the host's +native metatile command with its default non-impassable setting, including +clearing old collision/shut-cell overrides, and redraws the runtime map. +Terrain/collision tables remain native; there is no invented shared collision +registry. `activeBlockAt` immediately sees the change. One successful operation +emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`. + +On Gen2Recomped this is an instance patch, leaving generated map data intact. +It is not a persistent content edit: permanent layout changes belong in the +map registry. Native execution failures can occur after a native write and do +not promise transactional rollback; preflight refusals have no such effects. + +## Verification + +```sh +luajit tests/engine/api3_world_edits.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin stays at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source +runs under both production loaders for Red, Blue, Yellow, Gold, Silver, +Crystal, FireRed and Emerald. Engine-specific setup stays in the shared +driver, never in the mod. Native-shaped map/render/object seams let the driver +compare refusal reasons, visibility/flag effects, native block reads, geometry +and event payloads. The full checker now runs 48 comparisons / 96 isolated +processes, including all previous entry, rollback, generated-starter and warp +cases. + +Local tests additionally exercise the actual Gen2Recomped native Map, +OverworldState block replacement and object synchronization with synthetic +pooled actors, including shared flags, retained positions, flagless resets, +packed metatiles, collision clearing, malformed stores, reentrancy and retired +owners. These are ROM-free runtime-boundary tests; they do not establish +rendered retail-ROM equivalence or full scene-playthrough coverage. diff --git a/docs/battle-actions-api.md b/docs/battle-actions-api.md new file mode 100644 index 000000000..f0e232773 --- /dev/null +++ b/docs/battle-actions-api.md @@ -0,0 +1,96 @@ +# Battle action API + +The action extension builds on `mod.battle:snapshot()` and is available to +API 1 and API 2 mods on the shared Gen 1–5 battle engine. No manifest opt-in +is required. If capability queries are installed, +`mod:supports("battle", "submit")` reports that this interface exists. It does +not promise the current battle accepts a particular command. + +```lua +local nextIntentId = 1 -- keep increasing for this captured facade +local snapshot = mod.battle:snapshot() +if snapshot and snapshot.prompt == "menu" then + local ok, reason = mod.battle:submit({ + id = nextIntentId, + revision = snapshot.revision, + kind = "menu", + choice = "fight", + }) + nextIntentId = nextIntentId + 1 + if not ok then mod.log:info("Battle command refused: %s", reason) end +end +``` + +Always fetch a fresh snapshot before the next command. Do not hold a revision +across a frame, other input, a native callback, another mod's command, a screen +change or a Game/battle replacement. The engine rechecks the live context; +returning to an apparently identical screen does not revive an old command. + +## Intent records + +Intents must be plain tables without a metatable. Every record contains +`id`, `revision`, `kind` and only the fields listed below. Integers must be +finite; `id` and `revision` are positive safe integers up to 2^53−1. Extra +fields, unknown kinds, strings in numeric fields and missing arguments are +rejected. The facade copies accepted input fields before calling native code. + +| `kind` | Extra field | Meaning | +| --- | --- | --- | +| `menu` | `choice`: fight / party / item / run | Select an action on the uncovered regular battle menu | +| `move` | `slot`: 1–4 | Select a current battle move; may enter the native target selector | +| `target` | `target`: 0–3 | Select a currently offered, occupied positional battler ID | +| `back` | none | Cancel move selection/reorder or targeting; in a double's second action menu undo the first choice when native B permits it | +| `safari` | `action`: ball / bait / rock / run | Select a native Safari command; `rock` is Platinum's mud button | +| `mimic` | `index`: 1–4 | Select a current Mimic **option index**, not the source move slot | + +Positions are 0 player-left, 1 opponent-left, 2 player-right, 3 opponent-right. +These are not indices into `snapshot.battlers` or `snapshot.targets`. Target +choices are checked again against the native selector and live field. A +pending move must still be the same current move and still pass move checks. + +`move` uses the actual chooser, including the right flank and Transform's +current move list. Native Disable, PP, Taunt, Torment and BW Choice/Imprison/ +Gravity/Heal Block restrictions are checked before accepting the request. +Mimic uses its queued user, including a right-flank user during turn resolution, +and preserves the native copy/restoration bookkeeping. + +`menu` opens the existing bag/party flow. It does not select a party member, +use an inventory item or throw a normal bag ball directly. Those screens keep +their own controller. Native forced FIGHT/Struggle, ghost refusals, trainer +RUN refusals and bug-contest item behavior are preserved. A valid menu choice +can therefore be accepted and produce a native refusal message. + +## Context, replay and errors + +`submit(intent)` returns `true` when admitted and dispatched; otherwise +`nil, reason`. Accepted means the native command path ran, not that a turn +succeeded, a move hit, a ball caught or a battle finished. + +An intent must refer to a snapshot actually returned by this facade and still +be current. Each facade owns its own monotonically increasing ID sequence; +IDs survive battle and Game changes. Once native dispatch starts, that ID is +consumed. Equal or lower IDs are refused as replayed. Skipping IDs is allowed. +A rejection before dispatch does not consume its ID; correct the problem and +read a new snapshot before retrying. + +All public facades on one Game share an in-progress guard and an invalidation +epoch outside gameplay/save state. Another mod or a callback cannot submit +recursively during validation or execution. Snapshots observed during execution +are locked and cannot authorize a command after it finishes. Accepted calls +invalidate other mods' old snapshots even if exposed data happens to stay the +same. These counters are local process bookkeeping, not link/save data. + +Native errors are caught, reported and clear the guard. **An entered native +call is not retried or rolled back.** Its ID stays consumed even if native +code throws after a partial effect or returns a refusal. Fetch the current +snapshot and use a new ID for any deliberate next action. Validation errors +also clear the guard. Errors do not automatically drive subsequent frames or +drain the turn queue. + +No battle, covered menus, mismatched phases, finished battles, fainted/NPC +choosers, automatic locks, stale targets, demos/tutorials, link/spectator, +Palace and Arena controllers are refused. This API does not advance text, +drive a covered party/bag/choice screen, perform a forced replacement, or +bypass a script/controller. Ordinary keyboard/touch input continues through +the same extracted native dispatch methods and retains its existing messages, +queueing, RNG, targeting and per-version behavior. diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md new file mode 100644 index 000000000..e6e1cfa3c --- /dev/null +++ b/docs/battle-snapshot-api.md @@ -0,0 +1,126 @@ +# Battle snapshot API + +```lua +local battle = mod.battle -- safe in the mod entry chunk, before Game exists +local snapshot, reason = battle:snapshot() +if snapshot then + local actor = snapshot.player + if actor then print(actor.name, actor.hp, actor.maxHp) end +end +``` + +Available to API 1 and API 2 mods on all supported Gen 1–5 versions. Neither +`content_format` nor `callback_format` is required. This reads the shared +BattleState in the current Game's stack, including beneath another screen. +A read before Game is loaded also returns `nil, "no battle"` without +bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen merely holding a +`.battle` reference does not count as another battle. + +If the capability-query extension is installed, +`mod:supports("battle", "snapshot")` returns true without constructing Game. +When the [battle action extension](battle-actions-api.md) is installed, +`mod:supports("battle", "submit")` also returns true. Other battle capabilities +return false with a reason. + +## Records + +All returned tables, including nested records and `native` diagnostics, are +copies. They carry no engine objects, functions, userdata, threads or +metatables. Unknown native Pokemon extension fields are copied as data; +cycles are preserved. These diagnostics are not a portable write format or +necessarily JSON-serializable. Changing a snapshot never changes battle/save +state or future reads. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive, monotonically increasing within this facade; see below | +| `kind`, `phase`, `result` | Battle kind (including ghost/safari/oldman variants), native phase and optional outcome | +| `turn` | Native resolved-turn count; zero before the first turn | +| `double` | Whether this battle is configured as a double | +| `active` | Player position the menu currently selects for (0 or 2) | +| `player` | Occupied battler at `active`, or nil | +| `enemy` | Occupied opponent-left battler (1), or nil; use `battlers` for all foes | +| `battlers` | Array of occupied battler records in position order | +| `party` | Battle-specific player party when supplied (link copies), otherwise save party order, with one-based `slot` and `active` | +| `moves` | Current actor's battle moves, including Transform/Mimic overrides | +| `items` | Positive inventory quantities; acquisition order, then sorted unlisted IDs | +| `catchable` | Wild battle, one living foe, no ghost/noCatch/result restriction | +| `prompt` | Description of the battle screen's input phase; see below | +| `message` | Optional array of currently revealed text lines; unknown glyphs leave it unavailable | +| `messageSource` | Optional current message source text, which can include unrevealed text | +| `safariBalls` | Optional native remaining Safari ball count | +| `menuIndex`, `moveIndex`, `moveSwapIndex` | Optional one-based native menu/move/reorder cursors | +| `targets` | During targetSelect, ordered positional IDs from the native selector; otherwise empty | +| `targetIndex` | Optional one-based cursor into `targets` | +| `mimicMoves`, `mimicIndex` | Optional Mimic options and one-based cursor | + +Battler IDs are **0 player-left, 1 opponent-left, 2 player-right, 3 +opponent-right**. Array indices are not battler IDs. Empty positions are +omitted; an enemy at 3 never masquerades as enemy-left. `player` follows the +second player chooser, including after the left flank leaves the field. + +Pokemon records expose `species`, optional `item`, `nickname`, `name`, +`level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, and copied `native` +diagnostics. Species/move/item IDs use canonical string names from this boot's +registries. Numeric DS IDs resolve through the imported names and aliases; +unresolved IDs use their string form. Pristine IDs remain stable across +presentation-name patches; definitions registered by later mods are visible. +Status codes SLP/PSN/BRN/FRZ/PAR/TOX become +sleep/poison/burn/freeze/paralyze/toxic; unknown values are retained. + +Stats use `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, +`specialDefense`; Gen 1's special supplies both split stats. `maxHp` comes +from stored `stats.hp`. HP and status are logical state, not interpolated HUD +values. Battlers additionally expose `id`, `side` (player/enemy), `active`, +`absent` (false for occupied rows), `ally`, optional one-based `partyIndex`, +`displaySpecies`, `battleStats`, `types`, and `stages`. Original species and +stored stats stay separate from Transform's displayed species/current stats. +NPC allies are not added to the player's party. + +Moves expose one-based `slot`, portable `id`, optional `name`, `pp`, `maxPp`, +`ppUps`, `type`, `power`, `accuracy`. Max PP follows the native base PP plus +clamped 0–3 PP Ups, each adding floor(base PP / 5). Definition fields are base +values, not damage/accuracy predictions. Only the top-level actor `moves` +also expose `disabled`: true for Disable, no PP, Taunt or Torment; false if +none applies on Gen 1–4. In BW this field is omitted unless a common +restriction is known, because additional native Choice/Imprison/Gravity/ +Heal Block controllers may refuse the move. Nil is unknown, not false. +Mimic options retain their original move `slot` even if options are compacted. + +Items expose `id`, optional `name`, `count`, `ball`, `catchable`, optional +`pocket`. This is an **inventory view**, not a list of currently usable items; +it can include badges/key items. Ball classification follows imported ball +metadata. `catchable` describes the target restriction, not permission to use +an item: noItems/frontier rules, party capacity and the active bag controller +still own item eligibility. No `needsTarget`, catchChance, hitChance, +displayPower, effectiveness or guessed preview is produced. + +## Prompts and revisions + +`prompt` is `menu`, `moves`, `target`, `mimic`, `safari`, `advance`, or +`locked`. Intro, automatic demos/Palace, conservative Arena menu handling, +known automatic battler locks and every covered battle are locked. Message +advance is reported only when native manual text waits have finished their +pre-advance delay. Party, bag and choice screens above battle stay locked; +this API does not inspect or drive those screens. Link controllers remain locked. +Visible messages decode the native revealed glyph buffer through the imported +charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, +not an action-permission token. The [battle action extension](battle-actions-api.md) supplies separately validated +submission; the snapshot's descriptive prompt is not its permission check. + +Identical successive snapshot records and action contexts retain their revision. +The action extension also tracks native controller/selector identity and private +control flags, so replacement objects can invalidate an otherwise identical +record. Accepted public actions invalidate all facades on that Game, including +snapshots observed during native callbacks. A change to +any exposed data advances it once at the next read; animation clocks and +interpolated HP alone do not. A different battle or Game also advances it, +even with identical records. Observing `no battle` invalidates the previous +record, so re-entry advances it. Unobserved transitions between reads cannot +be detected if the same Game and battle objects are reused. Each mod has its +own counter; revisions are not save data or globally synchronized IDs. + +Reading constructs no Pokemon or screen, loads no gameplay/graphics module, +executes no action, emits no event/hook and consumes no RNG. Only facade +bookkeeping and loader-local ID indexes change. The API provides an observer +boundary, not hidden-information filtering or a write-back transaction. diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md new file mode 100644 index 000000000..7941a0580 --- /dev/null +++ b/docs/decoded-script-callbacks.md @@ -0,0 +1,84 @@ +# Per-mod decoded script callbacks (M5) + +The host can select a callback profile without editing a mod's manifest or Lua +files. Native callbacks remain the default. For example, the normal options +record can include: + +```lua +modCallbackProfiles = { + example_mew_starter = "decoded", +} +``` + +`Loader.new({ callbackProfiles = { example_mew_starter = "decoded" } })` is +an alternative for an embedding host. Select `"native"` or remove the entry to +roll back. The profile applies to Gen 2/3 and requires the mod's native callback +format. Unified callback records remain a separate existing choice. + +The selected mod registers `script.command` on a private, loader-owned bus. +Native subscribers retain their existing bus, names, operand arrays, ordering +and context. The original compiled script table remains identical until a mod +actually changes a decoded operation. Compiler provenance and tracing stay in +private side tables; no scratch records are placed in public script rows. +Provenance holds weak source references: retained rows cannot keep a retired +dataset alive, and replaced source pools explicitly retire their old provenance. + +The profile presents `(next, ctx, op, row)` for Gen 3 and +`(next, ctx, op, args, cmd)` for Gen 2. Numeric operand slots and the supported +named aliases describe the source operation, before native lowering. A single +source operation may have zero, one or several native rows. `next` runs that +whole native group; code after `next` observes its native effects. Rewriting an +opcode or operand recompiles a private overlay using the same native compiler. +Calls keep their active return addresses. Gen-2 call hooks remain open until +the called subroutine returns, including across native waits. Each loop visit offers the original +source operation again, so a previous rewrite or veto cannot rewrite the +imported source. Vetoes also remove compile-time text state. A retained `next` +cannot replay a completed operation. + +Gen-2 numeric `b/w/m` layouts in the stock editions support lossless raw byte +edits. Normalized pointers, rich menu/queue records and hack-specific operand +layouts do not have a lossless raw byte reconstruction here. Use supported +decoded fields; unsupported byte edits fail explicitly. This does not add +missing native opcode implementations or a separate foreign VM. Manually +replaced compiled row lists cannot be paired with stale source provenance and +are explicitly declined by the decoded profile. Hand-authored ordinary runner +lists keep their native callbacks. + +`pokemon.before_give` uses its existing event call site once. Only the selected +listener receives a separate view with symbolic species, `speciesId` when the +native species order provides it, and its profile context. Species changes +(including numeric IDs), bounded level changes and nicknames commit to the +native gift. A variable-backed Gen-3 gift also retains the edited species in +that native variable. Existing native listeners receive their original record. +Priorities, unsubscribe, once and failed-entry cleanup use the existing buses. + +The current context translation has one explicit map alias: the verified +FireRed/LeafGreen native Oak's Lab definition (group 4, number 3) appears as +`OAKS_LAB`. The definition must belong to that live map registry; conflicting +slot definitions decline translation. Other maps retain their existing IDs. +Shared map objects, storage keys and saves are never renamed. Broader map-name +compatibility is a separate existing integration. + +## Qualification + +The authenticated, unchanged starter package at pinned reference `535a5ec` runs +through the production Loader, compiler, runner, gift event and Pokémon +constructor over owned synthetic data. Its Gen-3 hook changes variable `0x4002` +to 151 and creates exactly one level-20 Mew named `HOGHEAD`; native map identity +remains `MAP_G04_N03`. This is a runtime contract check, not cartridge gameplay +acceptance. The fixture authentication manifest and mod package bytes are +unchanged. + +The focused suite also covers native command order and script identity, +post-next effects, multi-row calls, opcode replacements, return remapping, +zero-row commands, vetoed text state, repeated visits, Gen-2 raw/named operands, +retained callbacks, once/rollback cleanup and native gift record identity. + +`tools/check_decoded_profiles.py` runs an owned LÖVE window, the actual Loader +profile and the real ScriptRunner. It compares a pre-change runner, the current +native path and the selected decoded path over 64 writes plus an end per frame, +with 120 warm-up and 120 measured frames per phase. F11 reports +`script.exec (64 writes + end)` and each hook's own time. This deliberately +stresses script dispatch every frame; it does not measure a world/battle scene, +GPU improvements or handheld performance. The enabled translation has a cost; +no general FPS improvement is claimed. See the PR for fingerprinted results. diff --git a/docs/gen2-registry-contracts.md b/docs/gen2-registry-contracts.md new file mode 100644 index 000000000..435b17f30 --- /dev/null +++ b/docs/gen2-registry-contracts.md @@ -0,0 +1,30 @@ +# Gen2 registry contracts + +The `apricorns`, `decorations`, `landmarks`, `phone_contacts` and +`radio_channels` record descriptors match the pinned `535a5ec` Gen2 contract. +Their native adapters, imported built-ins, merge ownership and rollback already +exist; this change aligns the remaining descriptor bounds without replacing +those implementations. Gen3 decoration and landmark adapters are outside this +Gen2 contract. + +Record validation and native execution have separate constraints. Native phone +indices remain 1..255 with original rows and holes reserved; landmark indices +remain bytes. Native apricorn event operands and decoration ownership events +remain 0..65534; 65535 has a variable-operand meaning in native scripts. +Apricorn ordering indices remain 1..65535. The adapters reject these unsupported +values before recording the contribution, so errors remain attributable and +earlier writes by the same failed owner roll back. Finite-value, reference, +collision and canonical-ID checks remain in place. + +Landmark coordinates retain their existing native OAM units. This change does +not reinterpret previously authored records. Public record fields, merge +semantics and existing valid registrations keep their behavior. + +Run `tools/check_gen2_registry_contract.py --reference +--lua --report /fields.json` to compare the complete Gen2 +field descriptors, including optionality, bounds and referenced registries. +The same owned inspector runs unchanged in both checkouts. Native content +suites separately exercise the real Loader, merge targets and consumers. + +This is load-time validation. It adds no per-frame operation or performance +flag. ARM performance has not been measured. diff --git a/docs/legacy-npc-controls.md b/docs/legacy-npc-controls.md new file mode 100644 index 000000000..6459e74e7 --- /dev/null +++ b/docs/legacy-npc-controls.md @@ -0,0 +1,55 @@ +# API-2 native NPC controls (M6) + +Legacy `mod.world:npc(mapId, indexOrName)` handles now expose `stepNow`, +`canStep`, `placeAt`, `isMoving`, `setPassable` and `setAppearance`. Existing +`ow`, `npc` and `id` fields, scripted movement, facing, position and elevation +remain unchanged. + +The operations share the existing API-3 native actor implementation through +`src/world/NpcControls.lua`. API 3 keeps its original admission, validation, +ownership and cleanup policies. API 2 adds controls to its existing handle and +retains immediate replay semantics: `stepNow(direction)` deliberately skips +collision and the scripted movement queue. Call `canStep(direction)` first +when the map should decide whether that move is legal. + +`placeAt(x, y, facing)` clears the active step, its target and phase, and aligns +native cell/pixel coordinates. Coordinates must be finite nonnegative integers +up to 1,000,000. The optional facing uses `up/down/left/right`. `isMoving()` +reads native motion; `setPassable(value)` uses legacy boolean coercion. +`setAppearance(id)` resolves the native sprite on a private definition copy +before committing it to the actor. A missing sprite or a rendering error leaves +the actor and imported template unchanged. + +New controls decline stale handles after a map/world/save/data/version change, +actor removal or mod retirement. Failed-entry rollback, disable and replacement +of the loader's bus retire the new controls. Previously applied controls remain +native scene changes. Existing legacy handle methods keep their behavior. +Caller permission attribution is restored on both success and failure. + +The extension is available without changing a mod manifest, API level or public +hook. It is an additive compatibility feature; reverting the M6 commit removes +the extension while restoring the original shared implementation. + +## Qualification + +The unchanged API-2 consumer fixture runs through both production loaders +against pinned reference `535a5ec` on Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its public movement, passability, placement, motion and +appearance results match in all eight comparisons. The fixture declares those +three generations explicitly and is test-owned. + +The native suite also uses real Map collision and NPC updates, checks queue-free +motion and cancellation of a running step, preserves old fields/methods, checks +peer passability and sprite failure atomicity, and exercises stale ownership. +The existing API-3 NPC and portable movement suites qualify the shared helper. +Sprite construction uses a deterministic test seam; rendered artwork equivalence +and retail gameplay are outside these checks. + +`tools/check_decoded_profiles.py --npc-controls` uses the same guarded LÖVE host +as M5. The first two phases measure 64 identical legacy handle lookups, position +and facing calls per frame before and after M6. A third phase exercises all six +new controls and 1,024 native NPC updates per frame. It reports separate F11 +sections for lookup and new-control workloads, with 120 warm-up and 120 measured +frames per phase. These are owned CPU contract workloads with deterministic +sprite construction. The PR records the measured cost and source fingerprints; +no world-scene or handheld FPS claim is made. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..d6169c421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,68 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, +[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, +and [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles. + +## Feature queries (`mod:supports`) + +`mod:supports(surface, name)` returns `true` when the selected host exposes an +existing API-2 capability, or `false, reason` when it does not. Unknown names, +unknown surfaces and invalid names return `false` with an explanation. Check +for the method itself when supporting older engine builds: + +```lua +if mod.supports and mod:supports("hooks", "battle.damage") then + mod.hooks:wrap("battle.damage", function(next, ctx) + return next(ctx) + end) +end + +local canFly = mod.supports and mod:supports("world", "flyTo") -- false here +``` + +The four surfaces are: + +| Surface | Meaning of `true` | +| --- | --- | +| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. | +| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. | +| `hooks` | The engine has a native hook call site for this version. | +| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. | + +Queries work in the entry chunk without an active overworld, save, graphics +context or imported ROM. They do not subscribe, wrap hooks, load the Game or +materialize `mod.world`. Answers are bound to the host selected when that mod +API was built; changing the mod's copy of `gameVersion` or `host` cannot change +them. Availability does not imply the current game state permits the action: +`warpTo` can still return `nil, "no overworld"`, and elevation readers can +return `nil, "map carries no elevation"`. + +Callback availability follows Gen2Recomped's actual implementation, rather +than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` +and `egg.hatched` have no native call sites here; the hatch event here is +`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on +Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. +Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply +to Emerald. Other versions use their own intros. An event still needs its +trigger to occur; custom events also need another mod to emit them. + +For API 1/2, the query describes the existing interfaces and does not itself +normalize records, IDs or callback payloads. API 3 selects the portable +adapters described in [Shared API 3 entry](api3-portability.md), with explicit +limits on availability. Existing API-1/2 registration/subscription behavior +remains unchanged. When adding a native callback or changing screen routing, +update `src/mods/Capabilities.lua` and the capability tests in the same change. + +Additional read-only world summaries: [map overview and encounter queries](world-overview.md). + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives @@ -70,6 +132,14 @@ is a normal answer rather than a failure, and telling it apart from per frame, and a directory walk over several hundred blobs per frame is not something a menu can afford. +## Read-only world and party queries + +`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose +common Pokémon snapshots and current runtime map data across generations +1–5. Reads return detached data and do not load maps or change gameplay. +See [record fields, coordinate units and DS limitations](world-read-api.md). +The existing `loadedMaps()` method retains its legacy shape. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and @@ -87,6 +157,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Portable dialogue scripts (opt-in) + +API-2 mods can select `"script_format": "unified"` for prevalidated, copied +`mod.world:queueScript()` dialogue/control-flow programs with exactly-once +completion and owner-only cancellation. Existing manifests keep native script +behavior. See [portable commands, ownership and limits](portable-world-scripts.md). + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it @@ -152,6 +229,17 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Read-only battle snapshots + +`mod.battle:snapshot()` returns a detached common battle view on Gen 1–5, +including singles and doubles, or `nil, "no battle"`. It can be captured in +an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) +for records, revisions, prompt semantics and intentionally omitted previews. +The snapshot reads state only. The [Battle action API](battle-actions-api.md) +adds validated `mod.battle:submit(intent)` for the native menu, move, target, +back, Safari and Mimic paths. When capability queries are +installed, `mod:supports("battle", "snapshot")` reports this interface. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way @@ -192,6 +280,14 @@ 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. +## Unified hook and event records (opt-in) + +API-2 mods can select `"callback_format": "unified"` independently of their +content format. Known Pokémon, battler, trainer-slot and encounter records +then use common fields and symbolic IDs. Existing mods keep native payloads. +See [the callback format and mutation contract](unified-mod-callbacks.md) for +adapted arguments/results, native handles and validation rules. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload @@ -219,6 +315,14 @@ The console understands these verbs (anything else is evaluated as Lua): - `trace PAT | trace off` — trace events/hooks matching a glob pattern. - `help` — list the verbs. +## Public party, bag and dialogue actions + +`mod.ui:snapshot()` returns detached options for the current native screen; +`mod.ui:submit(intent)` selects a row, goes back, changes bag pocket or advances +ready text through its native flow. See [Public UI actions](ui-actions-api.md) +for revision/ID requirements, per-generation menu behavior and supported screens. +The existing `mod.ui` list-building helpers retain their original signatures. + ## Tool input and title-menu hooks Tool mods that need to act once per game logic tick can wrap `input.step`. @@ -255,6 +359,15 @@ without the engine knowing the layout. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. +## Public world actions + +`mod.world` exposes guarded party swaps, verified contextual field actions +and native flight through `canReorderParty`, `reorderParty`, +`availableFieldActions`, `useFieldAction`, `canFly` and `flyTo`. +See [contracts, busy-state checks and generation coverage](world-actions-api.md). +Calls revalidate the live state and preserve native gameplay paths; the +field-action adapter explicitly refuses unsupported script-driven cases. + ## Support link (`support`) A mod can add a link where players can thank its creator: a Ko-fi, Patreon or @@ -277,3 +390,10 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +## Unified content records (opt-in) + +API-2 mods can select `"content_format": "unified"` to read and edit common +Pokémon, move, item, trainer and encounter definition shapes. Existing mods +keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) +for the manifest guard, generation coverage, symbolic IDs and failure behavior. diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md new file mode 100644 index 000000000..58d3b969e --- /dev/null +++ b/docs/native-gen2-apricorn-selection.md @@ -0,0 +1,93 @@ +# Native ApricornBalls and Kurt selection + +The GSC importer reads the actual `ApricornBalls` pairs in cartridge order, +retaining native apricorn/ball item IDs and row indices. A single `$ff` ends +the table. Missing symbols leave no invented recipe data; explicit empty or +malformed/truncated tables never fall back to retail rows. Reads are bounded +and cannot cross a cartridge bank boundary. The map-script output includes +recipe records and completeness metadata. Stock Gold/Silver/Crystal cache +markers require reimport; other edition markers are unchanged. + +Kurt's actual native special now consumes the imported order, including +cartridge-specific table changes. Historical stock caches retain the original +native item order85/89/92/93/97/99/101 until reimport, without fabricating their +missing ball mappings. The earlier colour-based fallback reordering was +incorrect and is corrected here; the ROM-table import itself was unaffected. + +Gold/Silver take one apricorn without opening a quantity menu. Crystal asks +for quantity, including a one-item stack; cancelling quantity returns to the +apricorn list with its cursor retained and consumes nothing. An explicit +CANCEL row and bounded scrolling retain access to the complete list. The +quantity is capped at99 and the native inventory is rechecked before removal, +so a changed bag cannot mint a paid job. Unreviewed hack layouts retain their +previous quantity-cancel protocol and are not given a guessed table import. + +The native script variable still receives the actual selected item byte. +The existing `g2KurtApricorns` field holds its confirmed quantity. Native +script branches, event/working flags, daily resets and ball choice remain +script-owned. This does not add a competing Kurt job model or rewrite a ROM +script's ball operand merely because its selection table contains a pair. + +Validation:216 synthetic checks use actual importer/map-script serialization, +Gen2ScriptVM, ScriptRunner, StateStack, Menu input and Bag operations. They +cover order/bounds, malformed data, cache readiness, Gold/Silver single-item +handover, Crystal confirmation/reselection/cancellation/scrolling and a real +`verbosegiveitemvar` collection path: a full ball stack preserves the event, +successful delivery grants the confirmed quantity and event clearing prevents +duplicate collection. Only text/audio IO is substituted. A private read-only +Crystal Rev0 table probe finds seven complete rows, agrees with an independent +byte decoder and leaves the original ROM unchanged. It is not a full import, +GPU, original-story or Gold/Silver actual-ROM acceptance claim. + +The `apricorns` registry now projects authored recipe records into the actual +decoded native Kurt programs. The importer queues four named roots and their +reachable scripts; incomplete or home-bank bindings are unavailable rather +than guessed. An additional stock-GSC cache revision requires that import. +The adapter derives each base recipe's event from its native selection and +collection branches, rather than assuming an event/colour order. For example: + +```lua +mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"}) +``` + +All fields are `apricorn`, `ball`, `event` and `index`, as in the reference +registry. Items must resolve to real indexed GSC item bytes. Additional recipes +can use previously indexed native items and new word-sized events, excluding +the `$ffff` indirect-event sentinel. The selector still returns the real item +byte; it is never replaced with a fabricated seven-colour ordinal. Actual +native membership and event-dispatch branches, including overlapping roots, +consume the resulting table. Working/day flags, quantity, dialogue and +full-bag refusal stay in native scripts. Removed/rekeyed recipes stop new +jobs while unclaimed original pending events retain their original output. + +The observational `apricorn.converted` event is emitted only after a native +gift succeeds and its real event is cleared. API1/2 callbacks retain native +item IDs. The fork separately tests its API3 semantic callback boundary. It cannot veto or redirect +the gift. Recipe record views use semantic IDs through upstream API1/2 native content. The fork also tests its API2/3 +unified content modes; those additional APIs are not imported by this PR. Later item registrations resolve before +projection; invalid owners and affected consumers roll back before any native +script mutation. Unload restores unchanged owned rows/scripts, preserving +external edits. A new script table invalidates the VM's compiled-root cache. + +Synthetic integration checks cover actual Loader, VM, Menu, daily resets, +bag operations, observers, invalid/forward references, removal, unload and +generation isolation. A private read-only Crystal Rev0 probe also decodes +31 reachable native programs (264 instructions, zero desyncs), registers an +eighth recipe and executes the native selection, map reentry, working branch, +daily reset, full-bag refusal, three-ball delivery and exactly-once completion. +Only external text/audio IO is substituted; the original ROM is unchanged. +These checks do not establish full GPU/story or Gold/Silver actual-ROM +acceptance. The legacy Apricorns facade, full native daily/tree lifecycle and +remaining Gen1 feature parity remain open. + +Behavior was checked against the read-only Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and primary disassembly sources: +[Gold selection/table](https://github.com/pret/pokegold/blob/master/engine/menus/menu_2.asm), +[Gold handover](https://github.com/pret/pokegold/blob/master/engine/events/specials.asm), +[Crystal selection/quantity](https://github.com/pret/pokecrystal/blob/master/engine/events/kurt.asm), +[Crystal recipe table](https://github.com/pret/pokecrystal/blob/master/data/items/apricorn_balls.asm). +These links identify behavior/provenance; no ROM or extracted table bytes are +included in the contribution. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md new file mode 100644 index 000000000..702920fc3 --- /dev/null +++ b/docs/native-gen2-decorations.md @@ -0,0 +1,127 @@ +# Native GSC bedroom decorations + +The engine imports decoration attributes, display names, default placed bytes, +gift ordinals and room-description entry points from the user's current +Gold/Silver/Crystal cartridge. No retail attribute catalogue, dialogue or room +art is bundled. The virtual `decorations` registry writes the same DECO-byte +records that native room callbacks and the player's bedroom PC consume. + +```lua +local decorations = mod.content.decorations +local available, reason = decorations:supports() +if available then + decorations:patch("deco:2", { name = "CUSTOM" }) +end +``` + +Native API 1/2 use `deco:0` through `deco:255`. The number is +the cartridge's DECO byte, not a sorted list index. Records contain `type` +(1..6), `name`, optional `action`, `flag` (native event word) and `sprite` +(native object-sprite or map-block byte). Only the fourteen known +`SET_UP_`/`PUT_AWAY_` actions are accepted. Cancel and put-away rows do not grant +ownership. API views are detached; invalid/failed mod entries roll back and +reload retires owned writes individually while retaining external edits. +Removing a record removes both numeric and string aliases of its byte. + +The importer derives the attribute count from the adjacent native names-table +boundary and reads six bytes per row. Invalid/truncated attributes or names +publish no partial table. Species-backed names keep their native species ID, +so later species renames remain visible unless the decoration itself is +renamed. Native initialization is recognized only when the actual instruction +operands store valid bed/poster IDs to the corresponding WRAM symbols. Missing +initialization does not invent retail defaults. Existing placed state, +including an explicitly empty table, is retained. + +The house-PC option appears only with an imported native table. Owned records +form the native categories; their put-away headers and cancel choice follow +the records, with eight visible rows and the native right/left/cancel order +for ornaments. Feedback and side prompts enter the normal ROM text importer +and `TextBox` path. A replacement uses buffers three/four for old/new names. +State changes commit after feedback closes. Moving an ornament clears an +opposite duplicate; selecting the same ornament does not mutate the opposite +side. Ordinary item-storage PCs do not gain this option. + +`PlayersHousePC` waits until LOG OFF/B and returns the accumulated change bit. +The exact GSC `warp NONE` pair reloads the current map after the script ends, +without relocating the player to cell zero. Unknown map pairs are not reloads. +The existing native callback queue then applies placed object visibility, +variable sprites and room blocks. Description bytecode branches into the +actual imported poster/ornament/big-doll scripts rather than being skipped. +Block zero is valid for a nonzero DECO byte; carpet offsets wrap as native +bytes. An empty slot writes no block, leaving the newly loaded bare room. + +Raw SRAM conversion reads/writes the eight placed bytes using the selected +edition's WRAM-to-SRAM layout. The physical order ends with the big doll and +is distinct from the public slot list. Absent legacy decoration state leaves +the imported template intact; invalid placed bytes fail instead of wrapping. +Unreviewed hack layouts receive neither guessed retail tables nor guessed +save offsets. + +## Legacy module contract + +`require("src.core.gen2.Decorations")` resolves to a signature adapter, not +directly to `src.world.Gen2Decorations`. It exposes `state(save)`, +`attributes(id)`, `name(id, monName)`, `idFor(id)`, `idForFlag(ordinal)`, +`registerInto(registry, data, owner)`, ownership/gift methods, +`ownedCategories(events)`, `rows(events, category)`, `apply(state, id, side, +monName)`, `clearOtherSide`, `visibility`, `tiles` and `posterVisible`. +The action/slot/description constants retain the legacy shapes. `ATTRIBUTES`, +`CATEGORIES` and `NOTHING_TO_CHOOSE` are read projections of the current +native dataset. Attribute rows remain the actual native records; registering +them into another registry passes copies. + +Data initialization and Loader entry bind the current native dataset. Reads +follow subsequent registry folding and rollback. Teardown or an edition +change retires the binding. Before a complete import, the facade returns no +catalogue/defaults instead of fabricating retail content. Removed records do +not fall back to a second catalogue. Both numeric bytes and numeric-string +bytes resolve and placements store numbers. + +Ownership accepts the legacy numeric `events:get/set` protocol or a native +save directly. `events(save)` supplies a numeric view backed by the same +canonical flags used by scripts and SRAM, with no parallel bitfield. That +view expires on rebinding/unload. Cancel/header rows cannot grant event zero. +Categories are rebuilt from native action identities; added rows participate +and removed headers are not invented. Existing empty placement is preserved. + +`apply` follows the legacy two-result contract `(changed, pages)` and does +not check ownership; the menu owns that check. Ornament side cancellation +does nothing, and `clearOtherSide` remains a separate operation for these +callers. The pages use the actual imported feedback text and buffers 3/4, +including authored replacements and explicit script-text removal. The native +PC and facade share placement planning and tile/object layout routines; the +PC still commits after feedback closes. A valid block zero and wrapped +carpet offsets retain native behavior rather than the old facade's filter. +This adapter does not turn a detached external event bitfield into the live +world, nor does wrapping a facade method patch every native module method. + +## Evidence and remaining scope + +ROM-free tests exercise actual importers, Loader, native script compiler and +runner, menus, TextBox, StateStack, ownership flags, callback commands and save +codec. The eight save offsets are independently checked against each shipped +GSC symbol manifest. Test cartridges and dialogue are authored synthetic data. +The callback/warp tests use controlled overworld presentation/queue boundaries; +they are not GPU-render or complete-game acceptance tests. + +A separate private read-only Crystal probe independently compares all 53 +attribute rows' numeric bytes, imports 45 gift ordinals and the native defaults, +executes the original house-PC/description scripts and room callbacks, and +round-trips all eight placed bytes. Original ROM hashes are checked unchanged. +The local font/audio/render harness is used; no visual acceptance is inferred. + +The public-module tests load a synthetic mod through the actual Loader, +change and remove records, grant ownership, place decorations and execute +the native room callback VM. They verify feedback pages, byte identity, +retirement on reload/edition changes, and rollback without a second catalogue. + +Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). +The remaining gift lifecycle, legacy MomShopping/phone contracts, +new graphical acceptance and a full unchanged VASC playthrough remain separate +open work. Importing the gift-ordinal table alone does not implement those +callers. The existing preview is not refreshed by this source change. + +Contract references: [Crystal decoration routines](https://github.com/pret/pokecrystal/blob/master/engine/overworld/decorations.asm), +[Gold bedroom script](https://github.com/pret/pokegold/blob/master/maps/PlayersHouse2F.asm). +Original fork additions: [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/docs/native-gen2-landmarks.md b/docs/native-gen2-landmarks.md new file mode 100644 index 000000000..66898dff0 --- /dev/null +++ b/docs/native-gen2-landmarks.md @@ -0,0 +1,70 @@ +# Native GSC landmark records + +`mod.content.landmarks` projects semantic records into the native numeric +`field.townMap.landmarks` table. Pokegear, TownMap and the native +`getcurlandmarkname` / `getlandmarkname` script commands read that same table. +It does not create a competing map or name service. + +```lua +mod.content.landmarks:patch("LANDMARK_ROUTE_29", {name="CUSTOM ROAD", x=80}) +mod.content.landmarks:register("LANDMARK_EXTRA", { + name="CUSTOM COVE", x=100, y=104, index=200, +}) +``` + +The shape matches the reference: optional `id`, required `name`, `x`, `y` +and `index`. The native map-header index is a byte, including zero. +Coordinates retain the cartridge's hardware OAM units. They are not tile +cells: the existing TownMap marker-centre and Pokegear sprite-origin +corrections still apply once at drawing time. Changing a display name does +not rename the landmark identity. + +The import records constant identities and the actual native row indices. +Gold/Silver have95 rows; Crystal has96, including byte95 for the ship. The +previous fixed95-row reader omitted Crystal's last row. Crystal's Battle +Tower also shifts the first Kanto byte from46 to47. Pokegear no longer uses +Crystal's literal boundary for Gold/Silver; both map readers consume +the authored native Pallet landmark index, retaining imported-map-header +fallbacks for older caches. An explicit manifest identity order overrides +the stock order. Unreviewed hack layouts do not acquire guessed stock names. +Stock caches require the `gen2-landmark-identities-v1` revision and reimport. + +Valid native names survive a failed/absent map-picture decode without +inventing map artwork. Missing coordinate data is not replaced with guessed +positions. A new indexed authored name can also serve native scripts in a +dataset with no town-map art. + +Upstream API1/2 native content uses detached record views. The fork separately +tests API2/3 unified content and its radio helper; those layers are not +imported by this standalone PR. +Failed entries roll back before native projection; content freezes after +loading. Numeric/string native aliases cannot resurrect a removed slot. +Index collisions follow the reference's deterministic rules: the native +identity wins its original slot, otherwise the lexically earlier ID wins. +The reference order remains a fallback for unclaimed original slots after +moving a record. Native map-header bytes are not implicitly rewritten. +Unload restores unchanged owned slots and removes exclusively created +namespaces, preserving external edits. Other generations report this native +target unavailable instead of mutating a similar-looking table. + +Validation exercises the real mod loader, ROM reader/text decoder, native VM, +TownMap controller and Pokegear draw commands. Image loading/drawing uses an +authored IO fixture; the independent existing town-map pixel decoder suite +still runs. A private read-only Crystal Rev0 probe reads96 actual records, +compares every raw coordinate pair with an independent byte reader and +executes the ship's authored name through the real VM. The original ROM is +unchanged. This is not fresh GPU, full original-story, Windows hardware, +actual Gold/Silver ROM or complete Gen1 parity acceptance. + +Reference behavior: read-only Gen1 source +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Schemas.landmarks` and +`core/gen2/Nests.lua`. Constant identities and order were checked against +[pokegold landmark constants](https://github.com/pret/pokegold/blob/master/constants/landmark_constants.asm) +and [pokecrystal landmark constants](https://github.com/pret/pokecrystal/blob/master/constants/landmark_constants.asm). +Only compatibility identifiers/order and original adapter logic are included; +no cartridge coordinates, dialogue, graphics or private mod content is shipped. +Original contributions by Roxas2712; retain existing engine/third-party rights +and credits. The [specific permission for Cedric / UNDERdecoded, with visible +credit](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) +applies to offered contributions; it does not license cartridge data or +other authors' work. diff --git a/docs/native-gen2-mom-shopping.md b/docs/native-gen2-mom-shopping.md new file mode 100644 index 000000000..9a3277427 --- /dev/null +++ b/docs/native-gen2-mom-shopping.md @@ -0,0 +1,87 @@ +# Native GSC Mom accounts and purchases + +The existing native banking screen changed a separate saving boolean, while +trainer rewards went entirely to the wallet. The cartridge's shopping tables +and notification scripts were not imported. This connects the banking choice, +native flags, trainer reward accounts, ROM purchase rows, original phone +programs, decoration ownership and raw-save state. + +## Cartridge data and account state + +`map_scripts.momShopping` contains the user's cartridge's random and ladder +lists. Each row is eight bytes: big-endian three-byte trigger and price, then +kind and item/DECO bytes. End symbols determine the count. The random threshold +increment is read from the native instruction operands that populate +`hMoneyTemp`; no retail catalogue, purchase dialogue or guessed hack layout is +bundled. Both original item/doll phone programs enter the shared script/text +importer. Invalid or incomplete bindings publish no partial lists and do not +partially enqueue programs. The new GSC cache marker requires reimport. + +The native account fields are `g2MomMoney`, `g2MomSavingMode` and +`g2MomBankOpen`, alongside the existing boolean `g2MomSaving`. Saving bit zero +and the active bit agree with the same engine flags original scripts read. +The bank UI uses those shared fields; an imported active account is recognized +instead of silently opening a new account. Explicit start/stop choices select +SOME/none. Imported HALF/ALL settings retain their native low-bit meanings. + +Trainer rewards deal four quarters between the accounts. SOME sends one, +HALF two and ALL four to Mom; an already maxed Mom account sends everything to +the wallet. Each account saturates independently, so near-cap overflow is not +redirected. Held-item reward modification happens before this split. Original +reward-text entry points select the current mode and receive the real player +name and full prize total. Other generations and unreviewed hacks retain their +existing reward paths, including Prism's separate bank. + +## Purchase and phone lifecycle + +`g2MomShopping` retains the native ladder index, selected random-row byte and +three-byte trigger balance. The current ladder row has priority whenever its +trigger is met. Otherwise the threshold advances to/past the balance and only +an exact landing selects a random row; that threshold is consumed even when a +full PC refuses delivery. A random purchase does not advance the ladder. + +Items resolve by the current dataset's native index and enter the existing PC +store with the native stack/capacity rule. Dolls set the actual imported DECO +ownership event; they do not place themselves in the room. Successful delivery +precedes deduction and ladder advancement. No reception or an occupied native +reentry queue prevents a purchase before selection state changes. Missing +original phone programs do not grant an unannounced purchase. + +The common native trainer-return path attempts the purchase once. Its original +phone program waits behind the trainer's queued post-battle script and UI +overlays, then enters the existing native FIFO with Mom's current contact ID. +Caller state, ring/hang-up audio, and receive-delay reset use the existing native call path. +Where the host exposes an incoming-call notification seam, it is also used. Repeated battle +callbacks cannot duplicate the purchase, and a pending call from another +save/dataset cannot execute in the current session. Link and Battle Tower +returns do not trigger shopping. + +Raw SRAM conversion uses each GSC edition's actual WRAM-derived account, +mode, ladder, row-selection and threshold offsets. The account/threshold use +big-endian triples; surrounding mode bits and absent legacy template values +are retained. Unsupported layouts receive no guessed new offsets. + +## Verification and limits + +ROM-free tests exercise actual table/instruction readers, banking VM/UI, +trainer battle win, native world return, script FIFO, flags, capacity refusal, +threshold selection and SRAM conversion. Save addresses are independently +compared with the shipped GSC manifests. Synthetic cartridges, texts and +records are authored fixtures rather than retail data. + +A private read-only Crystal probe independently compares every numeric byte +of both shopping lists, executes all ten original ladder phone notifications, +checks actual item and DECO ownership delivery, resolves native reward slots +and round-trips the account/progress fields. It uses controlled dataset and +font/render/audio IO, not a GPU acceptance or full natural-story playthrough. +Original ROM hashes are checked unchanged. No ROM/private mod/save data is +included in the contribution. + +The legacy `src.core.gen2.MomShopping` facade, full legacy phone parity, +new GPU acceptance and a complete unchanged VASC playthrough remain open. +This source change does not refresh public preview assets. + +Contract references: [native purchase routine](https://github.com/pret/pokecrystal/blob/master/engine/events/mom_phone.asm), +[native trainer reward split](https://github.com/pret/pokecrystal/blob/master/engine/battle/core.asm). +Original additions use the [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md new file mode 100644 index 000000000..e5d1868ca --- /dev/null +++ b/docs/native-gen2-phone-book.md @@ -0,0 +1,35 @@ +# Native ordered phone book + +The native script verbs and Pokegear now share ten ordered phone slots. +The existing `save.g2Phone` registration set stays available; the additive +`g2PhoneSlots` array retains insertion order and script-deletion holes. +Older sets receive deterministic initial order. External set edits are +adopted without resurrecting deletions or renumbering contact IDs. Reads do +not discard overflow entries from historical oversized sets. + +Missing permanent numbers reserve slots, so eight ordinary contacts cannot +lock out Mom and Elm. Their imported non-trainer identities are authoritative; +explicit metadata and relocated rows take precedence over legacy retail +positions. The number being added never reserves against itself. Duplicate, +zero, unknown imported and full-book additions are refused. Native +checkcellnum retains the cartridge's empty-byte membership behavior. + +Script deletion leaves a hole. Pokegear DELETE compacts the actual slots, +while the incoming selector walks registration order. The native card shows +ten slots, uses explicit blank rows instead of invented Mom/Elm entries, +refuses calls on blanks and scrolls slots nine and ten into view. + +Validation:277 focused synthetic checks use actual Gen2ScriptVM/ScriptRunner +add/check/delete/ask opcodes, Pokegear input and drawing with only prompt, +audio and graphics IO substituted. Gold/Silver/Crystal exercise reservations, +ordering, external edits, slot deletion, empty rows, native ask result codes, +relocated permanent identities and visible slot-ten dispatch. This establishes +model/VM/UI behavior, not a hardware phone call or original story acceptance. +The existing content-bridge tests also check ten-slot native consumption. + +The display hook is covered separately in [native-gen2-phone-list-hook.md](native-gen2-phone-list-hook.md). +The public legacy Phone facade, full special call/rematch contracts, new authored phone scripts/forward references, and +fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. + +Original implementation by Roxas2712; existing upstream/third-party rights and +credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-call-selection.md b/docs/native-gen2-phone-call-selection.md new file mode 100644 index 000000000..a380ac381 --- /dev/null +++ b/docs/native-gen2-phone-call-selection.md @@ -0,0 +1,23 @@ +# Native incoming caller selection + +The native incoming-call path now consumes one unsigned random byte for +ChooseRandomCaller, swaps its nibbles, masks to five bits and reduces it by +the eligible caller count. Previously it drew a uniform Lua list index, +which changed the native byte-to-contact mapping. Candidates retain saved +phone-slot order, independently of the display hook. + +The timer expiry remains consumed before the coin flip; service and empty +book gates consume no caller-choice byte. A successful choice still compiles +and executes the selected contact's imported receive program and resets the +receive delay. The entrance gate leaves both the timer and RNG untouched. + +The algorithm and gate order were compared with the read-only reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua` (ChooseRandomCaller and CheckPhoneCall). +Synthetic tests exhaust all256 byte values for each book size1..10 against +an independent bitwise oracle and exercise actual native call selection, +Gen2ScriptVM and ScriptRunner in Gold/Silver/Crystal. This does not establish +complete radio/phone parity, a hardware call or original-story acceptance. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-content.md b/docs/native-gen2-phone-content.md new file mode 100644 index 000000000..01ff19004 --- /dev/null +++ b/docs/native-gen2-phone-content.md @@ -0,0 +1,32 @@ +# Semantic contacts in the native Gen2 phone book + +`phone_contacts` presents imported numeric records through explicit phone and +trainer identity metadata. Changes reach `map_scripts.phone`, the actual +Pokegear, native incoming-call selector and Gen2ScriptVM phone scripts. The +non-trainer `number` is kept separate from native map group/number fields. +Names remain imported or authored text; localized trainer names never select +trainer identities. Ambiguous map spellings are not guessed. + +New contacts append after the full imported row bound, including reserved +holes. Existing indices cannot move; removed records retain reservations +within the load. Explicit indices are recommended when changing enabled mod +sets. Saved numeric slots are never rewritten. Reads and iterator rows are +detached; finite/cycle, identity, index, mask and native-script validation +happens before journaling the operation. Failed entries roll back, reload +retires unchanged owned rows, and external native edits survive. Other +generations report an unavailable target and perform no native writes. + +Caller/callee names and `callerKey`/`calleeKey` currently address existing +imported native script keys. Native VM execution remains authoritative. +Authored new scripts, forward references, the complete legacy Phone facade, +rematch-event semantics and actual story/hardware acceptance remain open. +This is a native content bridge, not a complete phone parity claim. + +The focused synthetic suite runs the actual Loader, Pokegear saved book, +incoming selector, Gen2ScriptVM and ScriptRunner across Gold/Silver/Crystal, +API1/2 native. It also checks invalid-entry rollback, +index ownership, detached reads, reload, external changes and target isolation. +No GPU or complete playthrough claim follows from these tests. + +Original implementation by Roxas2712; existing upstream and third-party rights +and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-identities.md b/docs/native-gen2-phone-identities.md new file mode 100644 index 000000000..d0e3f3ce5 --- /dev/null +++ b/docs/native-gen2-phone-identities.md @@ -0,0 +1,43 @@ +# Native Gen2 phone identities and Buena import + +The importer used a Gold-sized four-name list for all cartridges. Actual +Crystal contact37 uses non-trainer identity5, so Buena and both of her phone +script pointers were discarded. Read the name range referenced by the actual +native contact rows instead. Native outgoing/incoming pointers and masks stay +in their original directions. Empty and reserved rows remain uncallable. + +Each non-trainer row now preserves `contactNumber` independently of `number`, +which remains the native **map number**. `map_scripts.phoneInfo` carries the +full reserved table size, actual rows read and imported non-trainer names. +Even a completely empty book retains its reserved bound. + +Native constants also retain explicit phone-contact and trainer class/member +orders. These are constant identifiers from reference535a5ec0, not localized +display-name guesses or ROM dialogue. Existing authored identity metadata is +authoritative; explicit manifest metadata supports alternative cartridges. +A partial authored identity set does not silently inherit unrelated retail +roster rows. Retail fallback is limited to Gold/Silver/Crystal. Legacy +`PHONE_OAK` is retained as a compatibility spelling for slot2; the actual +imported display name remains authoritative. + +The `gen2-phone-identities-v1` marker rebuilds only stock G/S/C imports once. +Existing saves are not modified by this change. Other edition cache markers +are unchanged. + +Validation:138 synthetic checks exercise both native directions through +Gen2ScriptVM/ScriptRunner, Buena's actual name consumer, reserved/empty rows, +separate map/contact numbers, copied metadata, authored precedence, generation +isolation and the real cache-readiness gate. A read-only actual Crystal table +probe changes32 to33 callable rows (28 trainer /5 non-trainer),38 reserved +rows and62 to64 queued phone scripts; all trainer members resolve through +explicit metadata. The original ROM is unchanged. This is table extraction, +not full-import, GPU, story-call or complete phone-facade acceptance. + +The semantic `phone_contacts` registry writer, stable added-contact allocation, +rollback/reload and the remaining legacy Phone facade still need implementation. +These fields are prerequisites for connecting those APIs to native consumers; +the native numeric saved phone book remains authoritative. + +Original implementation by Roxas2712. Existing upstream and third-party +licenses/credits remain applicable; see the specific contribution permission and credits: +. diff --git a/docs/native-gen2-phone-list-hook.md b/docs/native-gen2-phone-list-hook.md new file mode 100644 index 000000000..da8fbf286 --- /dev/null +++ b/docs/native-gen2-phone-list-hook.md @@ -0,0 +1,33 @@ +# Native phone list display hook + +Gold, Silver and Crystal now dispatch `phone.contact_list(save, list)` when +the native Pokegear reads its contacts. The signature follows the read-only +Gen1 reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua`: the save remains an opaque native handle and the +list is a detached array of ten numeric contact IDs, with zero for blanks. +Native API 1/2 share this signature. Other generations do not dispatch this +native Pokegear hook. + +Mods can reorder or hide display rows and show contacts registered through +`phone_contacts`. A return value of the wrong type or length retains the +native display. Invalid, fractional, non-finite and unknown contact IDs +become blanks without shifting slots. Mutating the display list does not +register contacts, reorder the saved phone book or filter incoming callers. + +Each displayed contact retains its actual physical slot. The native DELETE +action therefore removes the selected contact even after display reordering; +an injected non-member cannot delete an unrelated saved slot. Unsubscribing, +failed-entry rollback and loader teardown restore the native presentation. + +Validation:focused checks use the actual Loader, content registry, +Pokegear contacts/input and incoming selector across all three editions and +two native API levels. They cover invalid returns, detached arrays, +saved-book isolation, reordered deletion, injected non-members, rollback and +other-generation dispatch isolation. This is synthetic behavioral +acceptance; no new hardware phone-call or original-story claim is made. + +The complete legacy Phone facade, special-call/rematch contracts, authored +phone scripts/forward references and full story acceptance remain open. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-received-event.md b/docs/native-gen2-phone-received-event.md new file mode 100644 index 000000000..e05e5cd82 --- /dev/null +++ b/docs/native-gen2-phone-received-event.md @@ -0,0 +1,39 @@ +# Native incoming phone notification + +Gold, Silver and Crystal now emit `phone.call_received` after the actual +overworld controller queues an admitted incoming call. Both random calls and +queued special calls use this boundary. Rejected candidates, outgoing calls +and other generations do not emit it. The callback is an observation, not a +veto; returning false or editing its descriptor does not redirect the queued +receive program or change the saved caller. + +The notification follows the read-only reference's PhoneRing.script boundary +at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: +`call`, `contact`, `name`, `className`, `special` and `scriptKey`. +Names come from the actual native contact/trainer roster or authored contact +name. Renaming a trainer contact preserves its actual trainer-class label. The detached call +descriptor has incoming direction, native numeric contact ID and actual +script key; canonical trainer class/member metadata is included when known. +This engine retains native extracted script keys rather than inventing +unavailable symbol labels. Special notifications use the queued wrapper key, +which can differ from the contact's ordinary receive script. + +The actual native controller dispatches this event only for Gen2. Native +API1/2 share delivery. Unsubscribe, entry rollback and teardown use the +existing event-owner journal. + +Focused synthetic checks run actual controller admission and queueScript, the real +incoming selector, Gen2ScriptVM and ScriptRunner in all three editions and +two native API levels. Only audio IO, RTC-facing time and RNG are +controlled. The controller's process-local Game binding is installed and +restored by the test; protected player instances are never used. Tests cover +random/special descriptors, actual queued program execution/completion, +observer mutations and false returns, rejected candidates, outgoing calls, +unsubscribe, rollback and other-generation isolation. + +This does not reproduce the complete PhoneRing dialogue/choreography, add the +legacy Phone facade, establish Mom shopping-call production, or prove full +story/hardware acceptance. Those remain separate work. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-pokegear-presentation.md b/docs/native-gen2-pokegear-presentation.md new file mode 100644 index 000000000..854c5c481 --- /dev/null +++ b/docs/native-gen2-pokegear-presentation.md @@ -0,0 +1,45 @@ +# Native Pokegear state and window-resolution presentation + +Actual unchanged Johto GPU inspection found a mismatch that the native +radio command/input assertions alone did not detect. The native card was +RADIO, but a source renderer asked for `card()` (or its legacy cursor), +fell back to CLOCK and painted the wrong panel. It also received the small +cartridge canvas rather than a window-resolution drawing boundary. + +The native Pokegear now exposes `card()`, `cardLabel()`, `stations()` and +`currentStation()` over its existing native selection and tuner. Station +rows carry the real native identity and knob-derived frequency. Its `radio` +view follows native program text advancement and retuning; leaving or closing +the card clears that view. This is presentation of the existing native +program text, not the complete cartridge show or song state machine. + +`drawsWidescreen()` / `drawWidescreen(width, height)` are an optional +screen-space drawing boundary. A replacement of the Pokegear's fallback +drawer opts into it; the unmodified native menu retains its original canvas. +The normal world/compositor runs first. Only the selected wide owner's small +UI drawing is omitted; native overlays above it remain on their own existing +canvas and are composited after the high-resolution owner. This works even +when a mod's `render.compose` hook owns the ordinary world composite. +Graphics state is restored around the window drawer and overlay. + +Focused synthetic tests cover native card navigation, retuning, text +advancement, borrowed-record isolation, ordinary no-mod behavior, window +owner selection, overlay ordering and graphics-state isolation. Real +unchanged-mod GPU acceptance of this correction is still pending. + +The earlier exact `e435f5ff` package passed 36 automatic checks per OpenGL +and Metal backend with actual Crystal and the unchanged Johto package. +All eight fresh OS images were inspected. Wall-radio names were upright, +but the four Pokegear images showed CLOCK instead of RADIO, so that set was +rejected as complete radio visual acceptance and was not published. Metal +deprecation overlays also remain visible at the lower edge. + +Full radio shows/audio, source phone/map facade completion, ordinary story +admission and Windows/Intel hardware GPU gameplay remain open. In particular, +the pre-existing native Snorlax special checks only EXPN; a real native VM +probe incorrectly returns true without flute music or adjacency. That story +gate requires a separate audio/ownership correction and is not fixed here. + +This standalone upstream composition has not received unchanged-mod GPU acceptance. +Original contributions by Roxas2712 are offered under the specific permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). Existing project and third-party rights remain effective. diff --git a/docs/native-gen2-radio-audio.md b/docs/native-gen2-radio-audio.md new file mode 100644 index 000000000..e494dd9dd --- /dev/null +++ b/docs/native-gen2-radio-audio.md @@ -0,0 +1,34 @@ +# Native Gen2 radio music and Snorlax admission + +The native tuner and wall-radio script now start the imported channel song +through `src.core.Music`. Retuning starts the new station once; advancing +text or drawing never restarts it. Leaving the card or removing its screen +transfers the actual playing song to the native map owner, so battle music +can restore it. A new map replaces that override normally. Unavailable or +failed channel audio produces silence and restores map music on exit; it +cannot accidentally leave a previously selected flute song active. + +The EXPN Card still controls availability of the flute station. The native +`SnorlaxAwake` special now checks actual flute playback and the player's +local map cell against the five cartridge proximity cells. Owning EXPN, +stale `g2RadioChannel`/`g2MapMusic` fields and rendering coordinates cannot +substitute for those conditions. A music selection hook replacing the flute +is respected and does not manufacture flute admission. + +`tests/engine/gen2_radio_audio.lua` exercises actual Music with a fake audio +device, native Pokegear input/StateStack and compiled Gen2 scripts through +ScriptRunner: 167 checks across Gold, Silver and Crystal. It covers all five +cells, wrong-cell refusal, real wall-radio suspension/return, retuning, +failed audio, close, battle restoration, map replacement, external music +ownership, mod-selected audio and generation isolation. + +This change covers station entry/exit and the native story gate. Complete +ROM-driven show text, timing, schedules, intermediate jingles and music-show +choices are still open. Device-free tests do not establish audible hardware +playback, original story-trigger admission, unchanged-mod GPU acceptance or +a complete playthrough. Exact-source runtime/GPU qualification is required +before inclusion in the existing preview. + +Original implementation by Roxas2712; existing upstream/third-party credits +and licenses remain applicable. Specific contribution permission and required credits: +. diff --git a/docs/native-gen2-radio.md b/docs/native-gen2-radio.md new file mode 100644 index 000000000..844d9df63 --- /dev/null +++ b/docs/native-gen2-radio.md @@ -0,0 +1,49 @@ +# Native Gen2 radio content and wall-radio lifecycle + +The native `MapRadio` special previously only assigned a successful check +result. It now opens a native radio overlay, holds its station announcement +for 100 update frames, and resumes the suspended native script once on A/B. +Closing a covered or already closed overlay cannot pop another state or +resume the script again. The existing `setval` result is preserved. + +`mod.content.radio_channels` provides the shared native station records for +Gold, Silver and Crystal. Records accept an optional `channel` integer +0–255 and optional authored display `name`. Positive wall-radio channel +numbers must be unique. Channel zero remains the automatic Pokemon Channel: +Johto morning selects Pokedex, other Johto hours select Oak, and Kanto +selects Places & People. Region detection uses the loaded map definition +and imported Pallet landmark boundary when available. + +API 1/2 native mods can inspect detached records, patch, +override, register and remove them. The native Pokegear uses the same +records for its existing program slots; a new numbered station is available +to wall radios without inventing a Pokegear tuning frequency. An explicitly +empty dataset remains empty. Built-in names retain engine translation, +whereas authored names are displayed literally. + +Content writes are frozen after load. Failed entries roll back their earlier +writes and subscriptions. Reusing the mounted dataset on reload retires old +mod writes while preserving unrelated external edits and removals. A mod-free +load does not materialize an extra namespace. Other generations receive an unavailable content handle and cannot create +the native target. This standalone patch does not add the fork capability +query or unified API prerequisites. + +`radio.channel` is emitted on tuning, not on drawing or advancing text: +`station` is the stable station ID, `name` is its display name, `channel` is +the wall script byte or native Pokegear knob position, and `source` is +`map_radio` or `pokegear`. + +Focused synthetic acceptance: 113 content/loader checks and 73 native +VM/UI/input checks. These tests use the actual loader, bytecode lowering, +coroutine runner, native state stack and Pokegear input path. They do not +establish GPU or unchanged-mod gameplay acceptance. + +The existing native Pokegear program text is reused. Full cartridge show +state machines, schedule-dependent program data, songs and map-music +restoration are still open parity work. This change is not complete radio +parity and must receive new runtime/package/GPU qualification before release. + +Original contributions by Roxas2712 are offered to Cedric / UNDERdecoded +under the specific contribution permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). +Existing project, third-party and previously granted rights remain effective. diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..1391146ba --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,164 @@ +# Portable dialogue scripts + +API-2 mods can opt into a bounded, prevalidated language for +`mod.world:queueScript(rows, options)`: + +```json +{ + "id": "quest_dialogue", + "name": "Quest dialogue", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "script_format": "unified" +} +``` + +Check `mod.scriptFormat == "unified"` in the entry file. Older engines may +ignore unknown manifest fields; checking the marker prevents a portable row +list from silently reaching their native script runner. Omitted or explicit +`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other +values, or API 1 with `"unified"`, are load errors. Content and callback format +opt-ins are independent. The engine's Mod API major remains 2. + +## Contract + +```lua +return function(mod) + assert(mod.scriptFormat == "unified", "portable scripts required") + + -- Run this from an idle-world interaction/update, not at entry time. + local function offerQuest() + local ok, why = mod.world:queueScript({ + { "ask", "Help with the quest, {PLAYER}?" }, + { "jump_if_false", "declined" }, + { "show_text", "Thank you!" }, + { "jump", "end" }, + { "label", "declined" }, + { "text", "Maybe another time." }, + }, { + onDone = function(completed, reason) + if not completed then mod.log:warn("Dialogue stopped: %s", reason) end + end, + }) + if not ok then mod.log:warn("Dialogue not started: %s", why) end + end +end +``` + +Admission returns `true`, or `nil, reason` without executing any prefix of a +rejected program. The entire list, every row/argument and every jump target is +validated and copied first, including unreachable rows. Caller edits to the +original list, choice labels/options or callback options cannot change an +accepted program. Reading the facade and an early request do not construct +Game. A request with no live save/world fails quietly. + +`true` means admitted, not successfully completed. After admission, +`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime +failure or cancellation. It fires exactly once, including failures during the +initial dispatch. It is not called for rejected requests. A throwing completion +callback is isolated and attributed to its mod; completion is not retried. +Already entered native work is not rolled back or automatically replayed. + +Only `onDone` is accepted in options. Programs and rows must be dense plain +arrays without metatables or extra keys. Functions/userdata/native handles are +not command arguments. Choice options are a closed plain table. + +## Initial command set + +| Row | Meaning | +| --- | --- | +| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal | +| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false | +| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false | +| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update | +| `{ "label", name }` | Named jump destination | +| `{ "jump", target }` | Unconditional jump | +| `{ "jump_if_true", target }` | Jump after a true answer | +| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer | +| `{ "end" }` | Finish the program | + +Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox +placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/ +page holds and answer holds remain in use. These rows do not resolve native +text registry IDs or lower ROM script bytecode. Future pages are not skipped. + +`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each. +Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults +are 1 and the last choice. B follows `cancel`. Answers remain internal to the +program for conditional jumps; the completion callback reports success/failure. + +Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique. +`end` is reserved. Jump targets are existing labels, valid 1-based row indices, +or the string `"end"`. No arbitrary native verbs, custom command handlers, +extra callback arguments or executable rows are accepted. + +A program has at most 1024 rows and 128 KiB of text/choice labels. Execution +yields after each slice of 128 instructions and fails after 65536 instructions +in total. This bounds authored jump loops without a Lua debug hook or a frame +stall; it is not a wall-clock deadline on a player reading text. + +## Ownership and cancellation + +Admission requires the current overworld to own the top of the stack. Native +scripts, queued/parallel scripts, movement, transitions, field animations, +input/controller locks, link sessions and another portable program decline. +Only one portable foreground program can own a Game at a time. + +An accepted program temporarily installs an isolated instance of the native +ScriptRunner. Its `isRunning()` participates in the world's existing input and +script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes, +including their normal keyboard/touch handling and delayed answer completion. +The idle runner is restored at completion/cancellation, provided it has not +already been replaced by another controller. Global runner/UI classes and the +native command registry are unchanged. + +`mod.world:cancelScript()` cancels only this facade's active portable program. +It returns `true`, or `false, "no portable script"`. It cannot cancel another +mod or a native story script. Cancellation removes only the run's exact native +windows, even beneath a foreign overlay, and releases its coroutine. It does +not pop unrelated screens or release another controller's locks. A covering +foreign screen pauses normal progress. Removing an awaited owned window +externally cancels at the next input-step boundary. + +A replaced Game/save/data/stack/world/map/player/runner, changed map identity or +player position/version, transition, link session, mod rollback, reload or +teardown cancels the program. Session changes are checked at input-step and UI/ +runner boundaries, including while the world is covered. Retained callbacks +cannot resume a cancelled program, skip a later row's wait or pop a new screen. +Facades from an unloaded/reloaded/failed mod are retired. + +Native `script.started`/`script.ended` lifecycle events retain their context +shape; `source.modId` attributes the portable run and `completed` describes its +outcome. End notification/completion follow owned cleanup. Portable rows use +this validated language rather than resolving native/custom handlers or firing +`script.command` wrappers; native-format scripts retain those contracts. +Direct changes to engine objects from such event handlers or other mods remain +outside this boundary's guarantees; this is not a mod security sandbox. + +When optional capability queries are installed, unified mods can probe +`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`. +Unknown/unimplemented portable verbs return false. The existing native +`world/queueScript` capability is not, by itself, evidence of this opt-in. + +## Scope and verification + +This first adapter covers dialogue/control flow across the 15 supported +version selections (Gen 1–5). Flags, items/Pokémon, world movement/warps, +battles, shops and other Gen1Recomp portable verbs need separately verified +adapters; they refuse the complete list before the first effect here. + +The contract is informed by Gen1Recomp's +[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua). +This implementation uses this host's native shared UI and runner architecture. + +Run `luajit tests/engine/portable_world_scripts.lua` and +`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader, +ScriptRunner, state stack and native UI classes. It compares complete per-frame +native/portable dialogue traces, checks preflight/argument copying, rejection +immutability, identity/session changes, replay, cancellation/reentry, budget and +native failures. Fixture runs do not establish rendered retail-ROM coverage. + +When the unified content/callback PRs are also present, run +`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` +to repeat the suite with all three format opt-ins enabled together. diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md new file mode 100644 index 000000000..caa7c4d36 --- /dev/null +++ b/docs/ui-actions-api.md @@ -0,0 +1,128 @@ +# Public UI actions + +`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open** +native party, bag or dialogue screen. They are additive for API 1 and 2, with +no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list +helpers and their dot-call signatures remain available. Each mod receives its +own facade. If the optional capability-query feature is installed, +`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true. +This describes the interface, not whether the current screen accepts an action. + +The battle API can open the native party/bag menu. This API then selects its +rows and follows the native target, item, confirmation and message flow. It +does not open a menu from arbitrary world/script states. + +## Snapshot and intents + +A snapshot returns a detached table, or `nil, reason` if the top screen is +absent or unsupported. It never searches through a covering screen. Reading +early does not bootstrap Game, load a screen, poll input or render graphics. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive per-facade context revision; obtain it from this facade | +| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` | +| `rows` | Ordered options, including disabled placeholders; empty for text | +| `index` | Native selected row, where applicable | +| `locked` | Animation, typing, controller or script currently owns input | +| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered | +| `pocket` | Optional native pocket identifier; diagnostic, not portable | +| `page` | DS battle bag stage: `menu`, `pocket` or `use` | + +Every row has `index` and `enabled`. Depending on the native screen it can also +carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or +`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset +resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be +localized; native action strings are diagnostic, not a common semantic action +enumeration. There are no callbacks, native objects or future dialogue pages +in the public record. + +`select.index` is **1-based in the returned rows**. It is not always a party +slot or item number: party confirmation/cancel rows, DS list headers, GBA +blank action cells, and DS battle-bag navigation buttons retain their native +positions. Disabled rows cannot be submitted. In field bags the rows cover the +current pocket in native order; HGSS/BW scroll/page cursors are positioned for +the selected row. DS battle bags expose only their current page's six item +slots plus previous/next/back buttons. + +Intents must be plain tables with exactly the fields below: + +```lua +{ id = 1, revision = snapshot.revision, kind = "select", index = 2 } +{ id = 2, revision = snapshot.revision, kind = "back" } +{ id = 3, revision = snapshot.revision, kind = "advance" } +{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 } +``` + +`id` is a strictly increasing positive safe integer **per UI facade**; +`revision` must come from a fresh snapshot returned by that facade. Pocket +`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers, +missing/disabled rows, stale contexts and unavailable actions return +`nil, reason` before native dispatch. Failed admission does not consume the ID. +Keep the ID counter across menu/Game changes. + +```lua +local nextId = 0 +local function chooseRow(index) + local snapshot, why = mod.ui:snapshot() + if not snapshot then return nil, why end + local row = snapshot.rows[index] + if snapshot.locked or not row or not row.enabled then + return nil, "row is not currently available" + end + nextId = nextId + 1 + return mod.ui:submit({ + id = nextId, revision = snapshot.revision, kind = "select", index = index, + }) +end +``` + +Take a new snapshot after each accepted action or native input/update. Revisions +track native screen/option/callback identity, live party/inventory and relevant +selection/control state. A private per-Game guard/epoch serializes UI and battle +submissions, rejects callback reentry and expires other mods' old and in-flight +snapshots. A snapshot obtained during dispatch reports `locked = true`. + +## Native behavior and limits + +- Party lists/pickers, swap/field/item submenus, forced replacement and team + selection retain their native eligibility, callback and cancellation paths. + `back` uses that screen's B behavior; it may clear a pending swap or submenu + instead of closing. The underlying battle decides how a cancelled forced + replacement is reopened. GB/GBA/DS each keep their own return semantics. +- Bags preserve native pocket order, item-use eligibility, target pickers and + consumption. The classic bag's own action menu and GBA item action grid are + supported; unrelated generic menus/lists are not inferred from their shape. + DS battle-bag presses complete on subsequent native updates, including their + page/category return paths. `true` does not mean an item has already run. +- `advance` handles a ready manual page/line break or final text, a bag's + result message, or a DS party notice. Typing and page/scroll holds are never + skipped. Multi-page question/auto/stay text can advance its intermediate + manual pages; the final script-owned page stays with its controller. +- TextBox's native choice creates the separate YES/NO screen on the next + native update. `select` chooses YES/NO; `back` selects NO. The native answer + hold and delayed callback are preserved, so the answer cannot be replayed + while pending. +- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle + button animations, item sorting and quantity entry are locked. QuantityBox, + summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches, + arbitrary custom menus and custom screen subclasses have no adapter here; + they return `nil, reason`. This is not a generic UI automation API. + +`true` means a native action was admitted and entered. It may open another +screen, display a native refusal or schedule work. IDs are consumed **before** +native code. An error after partial native effects returns +`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no +rollback or automatic retry occurs. Inspect a fresh snapshot before continuing. +The read/validation boundary is not a security sandbox for mods that directly +replace native engine methods or mutate Game themselves. + +## Reproducible tests + +Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`. +The focused suite uses real Loader/native menus, Pokémon, item effects and text +updates with the ROM-free fixture data across all 15 supported version IDs. +It checks native A/B parity, copied reads, full-graph immutability on refusal, +identity/revision/replay checks, callbacks and recovery after partial errors. +DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay +and rendered cartridge scenes require a separate ROM-backed run. diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md new file mode 100644 index 000000000..a79661ca6 --- /dev/null +++ b/docs/unified-mod-callbacks.md @@ -0,0 +1,177 @@ +# Unified hook and event records + +This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's +unified callback boundary. It adapts Gen2Recomped's existing call sites across +generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events. + +```json +{ + "id": "portable_hooks", + "name": "Portable hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "callback_format": "unified" +} +``` + +Absent/`"native"` keeps the original API, including object identities and +custom hook names. Unknown format values and unified callbacks on API 1 fail +manifest validation. `mod.callbackFormat` exposes the selected format. The +option is independent of `content_format`; either can be adopted first. +There is no global conversion of saves, registry data or battle state. + +```lua +return function(mod) + mod.events:on("pokemon.level_up", function(event) + mod.log:info("%s reached %d", event.mon.species, event.level) + end) + + mod.hooks:wrap("battle.damage", function(next, ctx) + local damage, info = next() + if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end + return damage, info + end) + + mod.events:on("pokemon.before_give", function(gift) + if gift.species == "EEVEE" then gift.nickname = "Buddy" end + end) +end +``` + +## Records + +IDs are resolved through this boot's registries. Pokémon, move and item IDs +are symbolic strings, including on numeric-ID DS hosts. Imported numeric +records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs. +No National Dex lookup or hard-coded item-number conversion is used. Unknown +or ambiguous replacement IDs fail validation. An unchanged unknown native +ID can round-trip without being guessed or rewritten. Native aliases remain +one identity even after registry merging creates separate row tables. + +| Record | Common fields | +| --- | --- | +| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` | +| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| Live move slot | `{ id, pp, maxPp, ppUps }` | +| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known | +| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only | +| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained | +| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` | +| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` | +| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` | + +Optional fields remain absent if the native data does not provide them. +`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`; +Gen 1's single special stat is exposed through all three special fields. +Editing one keeps its aliases consistent; contradictory edits are rejected. +Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` → +`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`). +Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or +`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained +until explicitly changed. `isEgg` is the native boolean flag. + +Definitions and `native` snapshots are detached, read-only diagnostics. Use +content registration during mod initialization to change definitions. The +callback view intentionally exposes a limited common definition shape; +additional generation-specific fields are available under `native`. +Battler metadata is read-only. Change its live Pokémon via `battler.mon`. +Shared native records share one public view within a call, so `user.mon` and +`target.mon` remain the same object when they reference the same Pokémon. + +## Adapted hooks + +| Hook | Adapted boundary | +| --- | --- | +| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields | +| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle | +| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity | +| `battle.enemy_action` | Second argument battler; move-action `id` in result | +| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper | +| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition | +| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon | +| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs | +| `fieldmove.eligibility` | Move ID and bound live Pokémon result | +| `encounter.fishing` | Rod ID, candidate list and encounter result | +| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native | +| `encounter.species` | Encounter argument/result and context | +| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native | +| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native | + +The remaining native hooks pass their arguments/results through. In +particular, UI callbacks, renderers, input, script arguments, maps, movement, +saves, intro speech and link fingerprint data retain their engine contracts. +Registering an unknown engine callback in unified mode fails immediately. +The source-catalog test forces an explicit boundary decision for new seams; +registration is not a promise that a given game executes that seam. Where +available, use `mod:supports("hooks"/"events", name)` to query the host. + +`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound +public Pokémon, commits pending validated edits, calls the native helper, +and refreshes the public views. It retains native argument/result behavior, +including errors. Retained helpers and `next` cannot be used after the hook +callback returns. Live party membership/order cannot be changed through this +view. New encounter, enemy-action and trainer-party records may be returned; +live Pokémon/battler replacements must be records bound during that call. + +## Mutation and failure behavior + +Edits to represented live Pokémon fields, trainer slots, encounters and +context fields are validated before being written. Levels must be 1–100; +stats/max HP positive integers; HP must fit the maximum. Move lists contain +at most four entries, PP must fit the selected move and PP Ups, and `maxPp` +is read-only. Trainer parties contain at most six slots. Unsupported fields, +read-only changes and invalid IDs reject the transaction before any staged +record is changed. Hidden native metadata (IVs, EVs, personality, slot fields) +is retained. Native Pokémon, stats tables, move lists and existing move slots +keep their identities, including battle aliases into those tables. + +Before `next()`, validated edits are committed together. After downstream +returns, public views refresh from native state. A successful callback's +later edits commit together too. Throwing before `next()` skips the wrapper; +throwing/validation failure after `next()` keeps downstream results and +changes. Already committed edits and native helper effects are not undone. +Calling `next()` again returns the cached result (including nil holes), or +rethrows the cached error; it never repeats downstream gameplay. Native +errors preserve their identity and are not converted into mod failures. + +Only the represented record edits participate in this transaction. Game, +battle, save, world, map, data and RNG handles stay native, as do unclassified +engine-specific fields; direct changes through such handles have ordinary +native side effects. Changing a species/level does not automatically rebuild +battle definitions, recalculate stats, grant moves or emit evolution events. +Use the engine's gameplay operations when that bookkeeping is required. + +## Events + +Ordinary engine notifications copy their outer context and adapt known +record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs, +move actions, caught/received/evolved Pokémon and trade records. Edits to +these snapshots do not write back. Engine handles remain native, including +`side`, `battle`, `game`, interaction targets and script contexts. A status +source such as `SYNCHRONIZE` remains an engine label rather than a guessed +move ID. + +`pokemon.before_give` is the explicit mutable exception: `species`, `level` +and optional `nickname` are validated and committed atomically. Its script +`ctx` stays native. A failure changes none of those gift fields, and later +listeners continue through the original event bus. + +Save lifecycle/repair events, screen and intro-speech events, `game.ready`, +`mods.loaded`, and mod option events pass through natively. This preserves +save repair semantics, forwarding metatables and nested callback identities. +Namespaced `mod..` events also pass through unchanged. +Mods may listen to other mods' events and emit only their own namespace. +Priorities, unsubscribe, once semantics, owner attribution and failed-entry +rollback are provided by the original event/hook buses. + +## Verification + +`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory +filesystem and the real loader/event/hook buses across every supported host. +It covers string/numeric IDs and aliases, transactional writeback, native +identity, mixed mod chains, `next`/helper failures, event isolation, mutable +gifts, priorities, ownership rollback and the native default. The catalog +checks compare against actual source call sites, rather than another list. +Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`. +ROM-dependent gameplay checks still require an imported legal ROM. diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md new file mode 100644 index 000000000..64d83da74 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,164 @@ +# Unified definition records (opt-in) + +A mod can use the same core content records when modifying different game +versions. Enable the format explicitly in an API-2 manifest: + +```json +{ + "id": "portable_balance", + "name": "Portable Balance", + "version": "1.0.0", + "api": 2, + "content_format": "unified", + "generations": [1, 2, 3, 4, 5], + "entry": "main.lua" +} +``` + +```lua +return function(mod) + -- Required when distributing to older engine builds: those builds do not + -- understand content_format and would otherwise supply native records. + assert(mod.contentFormat == "unified", "This mod needs unified content support") + if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 }) + assert(ok, reason) + end +end +``` + +Omitting the field (or using `"native"`) preserves existing mod behavior. +API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of +portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon, +world/battle services, scripts and other registries retain their native forms. +A mod still needs testing on every game it claims. + +## Available records + +The five handles retain `register`, `override`, `patch`, `remove`, `get` and +`each`, and add `has`, `supports()` and `available`. Other content handles pass +through unchanged. `mod.assets` retains its existing content-handle aliases. +On engines that provide `mod:supports`, content queries follow the selected +format's availability; other capability surfaces retain their existing answers. + +| Handle | Unified fields | Supported versions | +| --- | --- | --- | +| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | +| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | +| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | + +Reads and iteration return detached records. Editing a returned table does +nothing until a write method is called. Each record also contains a detached, +**read-only** `native` snapshot for diagnostics. Unexposed native fields are +preserved by edits; changing that snapshot is refused. `override` requires a +complete unified record and preserves omitted optional/native extension fields; +use `mod.DELETE` to clear a represented optional field. For normal +edits use `patch`; this keeps earlier and later mods' independent fields in the +registry's normal operation order. Writes return `true` on success. + +`register` supports string-key definitions on Gen 1–3, except new Gen 3 species: +their EV/ability/storage metadata has no canonical constructor here. Numeric +DS allocations are refused. This layer does not invent storage slots or +metadata. Use native content for those additions. Existing records can be +patched, overridden and removed across all five generations. + +### IDs and references + +Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`, +`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and +names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and +`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to +`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`, +`FIGHT` and `ELECTR` map to their common names. + +A numeric table or a generated key such as `SPECIES_025` is mapped through its +name in that ROM. Renaming a definition through a mod does not change its ID +within that load. Numeric/string aliases of one record are read once and +written or removed together. Multiple distinct records with one canonical +name are ambiguous: the adapter refuses to choose one. Missing or ambiguous +`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises. + +Trainer and encounter IDs remain game-specific; no cross-game equivalence +between a trainer class, a numbered individual or two maps is implied. +Numeric trainer IDs are exposed as decimal strings. References must resolve +when a write is made: define a referenced record first or declare a mod +dependency that provides it. Missing references return `nil, reason` before +an operation is appended. Removing a referenced definition retains the +loader's post-merge, owner-attributed reference diagnostics, including DS +numeric targets and Gen 2 time tables. + +### Pokémon and trainers + +`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional +`specialAttack` and `specialDefense` for the later split. A patch to `special` +sets both halves on Gen 2–5; explicit split fields can change them separately. +Conflicting values are rejected. Gen 1 refuses unequal Special stats. + +`level1Moves` is a list of move IDs. `learnset` is +`{ { level = 9, move = "TACKLE" } }`. Evolutions use +`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE` +and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or +`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable. +Held trade items and friendship/stat conditions are translated for Gen 2–3. +DS level evolutions are translated to their numeric target and parameter. +Other DS evolution writes and unknown method changes are refused; unchanged +native methods survive a detached read/modify/write. Gen 1 refuses eggs, +breeding fields, held items and later evolution conditions. + +Trainer `parties` always has the form +`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`. +Items and moves are optional, and held items require Gen 2 or later. Native +per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` +and `parties[1]` aliases stay synchronized. + +Move accuracy is percent, as in the engine's existing normalized definitions. +Native effect selectors and item behavior remain opaque: changing a move's +effect on Gen 2–5, changing later item behavior, or changing a DS move type +without a native type adapter returns `nil, reason`. This avoids changing a +symbolic field while leaving an incompatible native selector behind. Power, +accuracy, PP, category, price and other represented fields can be edited. + +### Encounters + +An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`. +A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions +out of **256**, including fractional values needed to represent GBA rates +exactly. Conversion refuses a rate that cannot be represented in native units; +it never silently rounds. Slot edits retain the native count and probability +buckets. Changing slot counts or weights requires native content. + +Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape. +A shared rate/slot patch applies to every native time. An explicitly changed +time can override that patch. Unchanged inherited times in a detached record +do not undo a shared edit. + +DS encounter tables carry daily, seasonal, swarm and other replacements. +This adapter reports `available = false` and `supports()` returns `false, +reason` for them; reads/writes return `nil, reason` and iteration is empty. +The native encounter API remains available to mods using the native format. + +### Lists, failures and lifecycle + +Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE` +clear represented lists, including inherited learnsets and TM lists. Required +scalar fields, base stats and species types cannot be deleted. Other optional +fields support `mod.DELETE` where their native representation permits it. + +Malformed records raise schema errors. Non-representable operations return +`nil, reason` before recording anything. Payloads, returned records and native +snapshots never share writable tables with the operation log. Frozen content +still rejects writes. An entry failure rolls back every operation owned by +that mod, including all aliases. No Game, save, graphics or imported ROM is +needed to query or construct the content facade. + +## Validation + +Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets +through the real loader, exercise all registered host versions and compare +native and unified round trips. Behavioral checks use native Pokémon/trainer +construction, encounter selection, DS move parsing and evolution dispatch. +This proves the exercised formats and consumers, not real-ROM playthroughs or +visual correctness. The complete local suite and its base comparison are +reported in the pull request. diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md new file mode 100644 index 000000000..230b7737e --- /dev/null +++ b/docs/world-actions-api.md @@ -0,0 +1,135 @@ +# Public world actions + +`mod.world` adds six methods without a manifest opt-in or Mod API bump: +`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, +`canFly`, and `flyTo`. The new actions validate free-roam state before using +existing native gameplay entry points. Existing world methods, party menus +and script commands keep their contracts. + +```lua +local ok, reason = mod.world:reorderParty(1, 2) +if not ok then mod.log:info("%s", reason) end + +local actions = mod.world:availableFieldActions() +for _, action in ipairs(actions) do + -- { id = "surf", label = "SURF" }, for example. + -- Show a companion UI; revalidate when the player actually chooses it. +end + +-- Later, from an explicit player choice: +local started, why = mod.world:useFieldAction("surf") +local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID") +``` + +## Free-roam boundary + +The save, overworld map, player, native script runner and authoritative stack +top must be present and ready. Methods refuse while another screen owns the +stack (including battles/dialogue), a script is running/queued, scripted +movement is pending, a transition or field animation is active, the player +is moving/locked, a mod script lock is held, a decoration mode owns input, +or a link session is live. Native engine errors propagate; an action that +throws is never retried by this facade. + +`canReorderParty()` and `canFly()` return booleans. Mutations return `true` +when accepted, or `nil, reason` when refused. `availableFieldActions()` gives +an array of detached `{ id, label }` records, or an empty array and a reason +when the world is unavailable/busy. Queries use native eligibility checks, +including existing mod hooks; the facade does not itself perform an action +while probing. A mod's own hook can still have ordinary native side effects. +Recursive mutations from those eligibility hooks are refused, and a changed +source context invalidates the pending request. + +A previous successful probe/list is not a reservation. Every action checks +state again when invoked. Typical reasons are `no save`, `no overworld`, +`world is busy`, invalid arguments, unavailable destination or unavailable +field action. Availability of the interface does not promise every action +exists in every generation. + +## Party ordering + +`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching +the native party menus. It does not insert/remove Pokémon, recalculate them +or copy their data. Slots are integer, one-based indices from 1 to 6; both +must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is +a successful no-op. Invalid slots refuse before changing the party. + +`canReorderParty()` is true only when the world is ready and there are at +least two party members. A retained facade reads the current save when used. +No menu selection or battle party order is changed behind a screen's back. + +## Flight + +`canFly()` additionally requires a real non-egg party carrier, the native +badge/location checks and at least one currently unlocked, valid destination. +`flyTo(mapId)` accepts a native map ID from that game's destination set. It +is not a general warp: unknown, locked, unvisited or invalid landing points +are refused before departure state changes. A caller-provided coordinate, +spawn-table index or arbitrary destination object is not accepted. + +| Host | Eligibility and destinations | Native departure | +| --- | --- | --- | +| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` | +| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` | +| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` | +| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` | + +The native handlers retain departure timing, player input lock, bike/surf +state changes and exact landing coordinates. The direct map-ID call does +not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling +back directly when there is no picture to display. + +## Verified contextual field actions + +This initial adapter deliberately exposes only these native paths: + +| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 | +| --- | --- | --- | --- | --- | +| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required | +| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required | +| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required | +| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required | + +Native carrier lookup selects a current non-egg party member. Badge checks +and target/location gates are retained; fainted carriers remain eligible +where the native engine permits them. Surf starts mounting; there is no +invented generic dismount action. Dig and Teleport keep distinct native +escape/last-heal semantics. Cut uses live target eligibility rather than +matching a global block number. + +`useFieldAction(id, opts)` accepts no options or an empty plain options table. +Unsupported options, action IDs and generation adapters return a reason. +This version does not expose bicycle/fishing, Strength/Flash, healing moves, +custom source selection, Gen 3 object-script actions or DS field-script +activation. These require separate adapters with their native checks; callers +must use the returned action list rather than assuming a fixed menu. Fly is +separate because it needs a destination. + +## Deferred completion and cancellation + +A `true` result means the native request was accepted, not that the animation, +dialogue or warp has finished. Native gameplay owns those asynchronous flows. +A Gen 3 cut-in blocks competing public mutations until it finishes. Its +effect is guarded by the source save/data/map/player, position/facing, version, +party membership and fresh eligibility. Changing that context cancels the +effect. A repeated completion callback cannot replay it. Abandoning the +presentation does not leave a permanent facade lock; a stale callback cannot +start a later effect. Already executed native gameplay is not rolled back. + +## Capabilities and tests + +With the optional capability-query feature, each of these six method names +returns true from `mod:supports("world", name)` without constructing Game or +the world. Other capability answers are delegated unchanged. Use +`canFly`/`availableFieldActions` and the mutation's return value for current +state and action-level availability. + +`tests/engine/world_actions_api.lua` uses the real loader across all 15 host +version IDs. It exercises refusal parity for world/player/script/link locks, +party identities, slots, destination gates, eggs, partner/costume checks, +contextual dispatch and reentrant/deferred/error paths. It also runs actual +native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3 +Dig/Teleport departure state changes without ROM assets or graphics. A cut-in +test double drives completion/cancellation deterministically; it is not a +rendered playthrough. ROM-dependent map scripts and graphics still need an +imported legal ROM. diff --git a/docs/world-overview.md b/docs/world-overview.md new file mode 100644 index 000000000..e3c494197 --- /dev/null +++ b/docs/world-overview.md @@ -0,0 +1,105 @@ +# World overview and encounter queries + +These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and +`effectiveEncounters` result shapes. They return detached tables and do not +load maps, consume RNG, run mod callbacks, refresh objects, or change the save. +They are available through the normal lazy `mod.world` facade. Hosts with +`mod:supports` report both names on the `world` surface before Game exists. +This does not opt a mod into API 3 or change existing actions/encounter rolls. + +```lua +local overview, reason = mod.world:mapOverview() +if overview then + -- Cells and marker coordinates start at zero; Lua row arrays start at one. + local terrain = overview.rows[y + 1]:sub(x + 1, x + 1) +end + +local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", { + daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT +}) +if encounters then + local total = 0 + for species, weight in pairs(encounters.dist) do total = total + weight end + -- Conditional species share = weight / total (when total > 0). +end +``` + +## `mapOverview()` + +Requires an active overworld; menus/battles above it do not prevent reads. +At the title screen, returns `nil, "no overworld"`. + +| Field | Meaning | +| --- | --- | +| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells | +| `rows` | `height` strings, each exactly `width` ASCII characters | +| `markers` | Array of detached `{kind, x, y}` records | +| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution | +| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution | + +Terrain characters are `+` for a native warp tile, `~` for water, `.` for +walkable ground, and a space for a wall, in that order of precedence. This +summarizes terrain, not directional movement permissions, elevation crossings, +NPC occupancy, or a pathfinding result. Runtime block/collision patches win. +Packed GBA blocks are decoded privately; a cold map remains cold afterwards. + +Markers identify in-bounds `warp` events, `item` objects actually in the active +NPC list (at their current cells, excluding hidden objects and fruit trees), +and untaken `hidden` item spots. Hidden-item signs also honor their event flags +and their distinct native taken keys. Neighbour-map objects are not included. +The snapshot reflects the loaded object list; it does not run visibility or +spawn filters again. Hidden markers deliberately reveal item locations to the +requesting mod, as in Gen1Recomp. + +GB visual grids use the resolved, mod-overridable atlas's pixel luminance: +`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile; +`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted. +The private visual cache is invalidated with the engine's asset cache. +GBA metatile compositions and DS 3D art have no GB atlas, so visual grids +are omitted there. Terrain grids support GB/GBA maps and resident DS interior +permission grids. DS maps without a resident behaviour grid return +`nil, "map overview unavailable for streamed terrain"`; reading streamed +renderer chunks would load/cache terrain during a read query. + +## `effectiveEncounters(mapId, terrain, opts?)` + +Takes an explicit, known map ID, including at the title screen. `terrain` is +`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors). +Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`. +Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid +bucket/rate metadata return `nil, reason`. A known map without that table +returns `{chance = 0, dist = {}}`. + +`chance` is the table's independent integer rate gate, clamped to its native +denominator (256 by default, 100 on DS adapter tables). It is **not** the final +probability of a battle on the player's next step. `dist` aggregates consecutive +cumulative bucket differences for duplicate species. Weights are not percentages: +use their sum when computing conditional shares. Missing slots/uncovered bucket +space remain empty outcomes, just as in the native chooser. Zero-rate tables +return an empty distribution. Species IDs use the same dataset-derived public +names as the other portable readers, including numeric DS species and aliases. +Loader identity metadata is captured before presentation-name patches. + +Gen2 land tables use the current clock period, or the last effective cached +period when a `world.tod` hook is installed. `opts.daytime` overrides this +without dispatching the hook or updating the world. Active Gold/Silver/Crystal +swarm tables are selected by the native resolver. DS previews use the native +map-to-table indirection, day/night substitutions, active daily slots/outbreaks +and BW season selection. DS `daytime` selects a representative hour (6/12/22). +Water uses its own table. + +This is a **table preview**: it excludes per-step ability/radio/held-item rate +changes, movement/grace gates, Repel, roamers, lead-based slot filtering, +fishing, Radar, phenomena and dark-grass overrides. It never samples or executes +`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native +`encounter.table` hook; this PR does not introduce one or pretend to preview +arbitrary mod callbacks. A future shared table-transform seam can extend both +the native roll and preview together. + +HGSS's position-dependent Safari Zone returns +`nil, "position-dependent Safari encounters unavailable"`; its native resolver +consults the global player's position and can initialize save state. Emerald's +Battle Pike wild room and Battle Pyramid floors return +`nil, "facility encounters unavailable"`; these use separate facility tables. +These refusals keep an explicit-map query from reporting another map's table +or changing gameplay state. diff --git a/docs/world-read-api.md b/docs/world-read-api.md new file mode 100644 index 000000000..01b2a60d0 --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,151 @@ +# Read-only world and party queries + +Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and +`liveMaps()` across generations 1–5. These are additive methods: no manifest +format switch or Mod API bump is required. Their records use the common live +Pokémon shape from unified callbacks, independently of the mod's selected +content/callback format. Existing world actions and `loadedMaps()` retain +their previous behavior. + +The methods read the current save/runtime map when called. They do not load +maps, decode whole packed grids, create Pokémon, emit events, write state or +advance random generators. Returned tables are detached plain data; editing +or retaining them never changes the game. Native engine handles remain +available through the existing APIs, but these three methods return none. + +```lua +return function(mod) + mod.events:on("world.stepped", function() + local here = mod.world:current() + if not here then return end + + local party, reason = mod.world:party() + if not party then mod.log:warn("%s", reason); return end + + local maps = mod.world:liveMaps() + local geometry = maps and maps[1].block + if geometry then + -- current().x/y use walk cells; activeBlockAt uses map blocks. + local cellsPerBlock = geometry.tileSize / geometry.cellSize + local block, info = mod.world:activeBlockAt(here.mapId, + math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock)) + if block ~= nil then mod.log:info("%s block %d", info.kind, block) end + end + end) +end +``` + +## Party snapshots + +`party()` returns an array in native party order. A save with an empty or +absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party +does not require an active overworld, so menus and a title-screen save are +valid contexts. A retained facade observes a replacement of `game.save` on +its next call; previously returned records remain snapshots. + +Each Pokémon has the following common fields (optional native values may +be absent): + +| Field | Meaning | +| --- | --- | +| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` | +| `nickname`, `name` | Native nickname and optional native name | +| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` | +| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained | +| `isEgg` | Native egg flag as a boolean | +| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent | +| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields | + +IDs use this boot's actual registries, including custom records and numeric +DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via +imported names; existing named keys retain their names. Standard aliases use +the shared unified-ID resolver. Identity/name metadata is captured before +mod merging, so a display-name patch does not rename a canonical ID. Records +registered later during mod initialization remain visible. Unknown IDs are +preserved as strings; this is a read-only view, not an allocation or reference +validation API. The native diagnostics retain the underlying numeric IDs. + +Gen 1's single special stat fills both special-attack and special-defense +fields; later split stats read native `spatk` and `spdef`. PP maxima use native +PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate +stats, learn moves, infer shininess or consume RNG. Functions, userdata, +threads, metatables and table-valued keys are excluded from diagnostics; +cycles in data are copied without retaining native tables. + +## Active block lookup + +`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`. +The expected map ID is required: a request prepared before a warp cannot +silently inspect the replacement map. Only the active runtime map is read. +Coordinates are finite, nonnegative integers in **zero-based block units**; +off-map coordinates are refused rather than returning the map's border fill. +A block ID of zero is a valid result: check `blockId ~= nil`. + +Runtime `blockPatch` entries take precedence, including zero-valued changes. +Table grids, packed little-endian 16-bit grids and an existing decoded cache +follow the runtime Map representation. A cold packed read extracts only the +requested word and its low ten ID bits; it does not populate `_blockArray`. +Missing/truncated data, negative/invalid IDs and inconsistent geometry give +`nil, reason`. No call into map loading, rendering or collision is made. + +Metadata is a fresh record: + +| Field | Units / meaning | +| --- | --- | +| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` | +| `cellSize` | 16 pixels per walk cell | +| `coordinateBase` | `0` | +| `width`, `height` | Map dimensions in blocks | +| `kind` | `block`, `metatile`, or `runtime_cell` | + +Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry +comes from the actual runtime tileset so custom maps need not match their +selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy +grid in the shared runtime; `runtime_cell` explicitly identifies that grid. +Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or +a public collision/elevation encoding**. Use the existing terrain/elevation +services for those questions. A DS map must carry the runtime grid; this +method does not generate one from meshes or lazily load shared layouts. + +## Loaded map snapshots + +`liveMaps()` returns only maps already held by the active overworld, with the +active map first and neighbors in native order. It never enumerates all ROM +maps or loads missing neighbors. No overworld gives `nil, "no overworld"`. +Each row contains: + +```lua +{ + mapId = "MAP_ID", active = true, + x = 0, y = 0, -- placement relative to active map, walk cells + width = 20, height = 18, -- dimensions in walk cells + block = { -- absent if runtime geometry is unavailable + tileSize = 32, cellSize = 16, coordinateBase = 0, + width = 10, height = 9, kind = "block" -- dimensions in blocks + } +} +``` + +Neighbor offsets may be negative. Exact duplicate placements collapse; +distinct placements of one map at different offsets remain separate rows. +Each row carries its own geometry, including mixed-size neighborhoods. +The legacy `loadedMaps()` method keeps its original shape, duplicates and +order for existing consumers. + +## Availability and tests + +When the optional `mod:supports` capability-query feature is installed, +`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true. +Probes do not construct Game or a world. Availability describes the method; +a call can still return `no save`, `no overworld`, `map is not active`, invalid +coordinates, unavailable geometry or unavailable data. Other capability +answers retain their existing behavior. + +`tests/engine/world_read_api.lua` exercises the real loader and Map objects +without an imported ROM or a graphics context, across all supported version +IDs. It checks detached nested data, ID aliases and late registrations, +current save/map replacement, block sizes/bounds, cold/warm packed data, +runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay +tripwires. When the unified-callback feature is present, it also compares the +party shape against an actual event's Pokémon record. diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md index 7278827ea..3a9e77ce0 100644 --- a/mods/examples/example_silly_oak/README.md +++ b/mods/examples/example_silly_oak/README.md @@ -35,8 +35,8 @@ SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ | Seam | Where | |---|---| -| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | -| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | +| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list | +| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids | | step kinds `say` / `yesno` / `choice` | `main.lua` | | pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | | `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..fff9aa581 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,362 @@ +-- Native command dispatch shared by BattleState input and mod.battle intents. +-- Keep gameplay mutations here, after the mod facade's admission checks. +local Strings = require("src.core.Strings") +local Targeting = require("src.battle.Targeting") +local Commands, Methods = {}, {} +Commands.methods = Methods + +function Methods.chooseMenu(self, choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return true + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() + self:endOfTurn() + end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return true + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return true + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end + +function Methods.chooseMove(self, index) + local chooser = self:menuBattler() + local moves = chooser.curMoves + self.moveIndex = index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then + self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if actor.disabledSlot == slot then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(b, actor, def) then + return "move is blocked by native Gen4 rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..62b238d5e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local BattleCommands = require("src.battle.BattleCommands") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -53,6 +54,10 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Data readers can identify the shared Gen 1-5 battle without loading this module. +BattleState.isBattleState = true +-- Input and public intents share the exact same native command dispatch. +for name, method in pairs(BattleCommands.methods) do BattleState[name] = method end -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -4440,7 +4445,7 @@ function BattleState:update(dt) if self.palPark then require("src.battle.Gen4PalParkBattle").skipRow(self, dir) end end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4466,7 +4471,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4546,60 +4551,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4625,63 +4580,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then - -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove) - self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4699,16 +4605,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4729,12 +4628,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then - local pick = moves[self.mimicIndex] - local ctx = self.mimicCtx - self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" - self.nextInsert = 0 -- the copy's anim + text go to the queue head - self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + self:chooseMimic(self.mimicIndex) end return end @@ -9201,20 +9095,8 @@ function BattleState:enemyMonFainted() end) return end - -- ComputeTrainerReward (0E:58D4) sets wBattleReward = the class's base - -- reward byte (TrainerClassAttributes+2) times wCurPartyLevel, the level - -- of the LAST mon the party reader loaded. That is only a QUARTER of the - -- payout though: WinTrainerBattle (0F:4F46) then runs `ld c, 4` and adds - -- wBattleReward once per iteration -- b of the four quarters through - -- .AddMoneyToMom, the remaining c through .AddMoneyToWallet -- so the - -- trainer is worth base * level * 4 in total. b comes from - -- wMomSavingMoney & 7 (0 = you keep it all, 3 = she banks all four), and - -- is forced to 0 when Mom's account is already maxed. Nothing here - -- models Mom's savings account, which is the `keep it all` branch, so all - -- four quarters land in the wallet -- Falkner pays 25 * 9 * 4 = 900 and a - -- Sprout Tower sage 8 * 3 * 4 = 96, instead of the 225 and 24 a single - -- quarter gives. Gen1's TrainerBattleVictory has no such split, so the - -- multiplier stays on the Gen2 side of the fence. + -- Native GSC rewards are four quarters. Their destination is selected + -- by Mom's saving bits; Gen2MomShopping applies the per-account cap. -- THE LEVEL THE PRIZE IS COUNTED FROM. -- -- Gen 1 and Gen 2 count from the mon that was out when the battle ended. @@ -9262,7 +9144,9 @@ function BattleState:enemyMonFainted() prize = P.prize(self.data, self.trainer, opts) if self.trainerB then prize = prize + P.prize(self.data, self.trainerB, opts) end end - self.game.save.money = self.game.save.money + prize + local momAward=require("src.world.Gen2MomShopping").reward(self.game.save,prize) + if not momAward then self.game.save.money=self.game.save.money+prize end + self.g2MomAward=momAward -- Prism's Spurge Bank ATM (event/bank.asm) offers DIRECT DEPOSIT: with it -- enabled, a quarter of what you win in battle is routed to the account -- instead of the wallet. The switch lives in the ATM special @@ -9323,9 +9207,17 @@ function BattleState:enemyMonFainted() -- Platinum's font, so a Sinnoh win read "got 円 480". This default is -- only reached when the dataset has no `_MoneyForWinningText` of its own, -- which is every Gen 3 and Gen 4 cartridge. - self:sayNext(self:romText("_MoneyForWinningText", - "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", - self.game.save.player.name, prize)) + local momPool=require("src.world.Gen2MomShopping").pool(self.data) + local moneyText=momAward and momPool and momPool.rewardTexts and momPool.rewardTexts[momAward.mode] + if moneyText and (self.data.text and self.data.text[moneyText])then + local text=self.data.text[moneyText] + text=text:gsub("{PLAYER}",function()return self.game.save.player.name end):gsub("{NUM}",tostring(prize)) + self:sayNext(text) + else + self:sayNext(self:romText("_MoneyForWinningText", + "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", + self.game.save.player.name, prize)) + end end self.result = "win" self.afterQueue = "finish" diff --git a/src/battle/Gen4MoveEffects.lua b/src/battle/Gen4MoveEffects.lua index 2bd10da81..4f1447ee2 100644 --- a/src/battle/Gen4MoveEffects.lua +++ b/src/battle/Gen4MoveEffects.lua @@ -509,9 +509,13 @@ F.REMOVE_PROTECT = { local FLAVOR_STAT = { [11] = "attack", [12] = "spatk", [13] = "speed", [14] = "spdef", [15] = "defense" } local STAT_UP = { [16] = "attack", [17] = "defense", [18] = "speed", [19] = "spatk", [20] = "spdef" } local CURE = { [1] = "PAR", [2] = "SLP", [3] = "PSN", [4] = "BRN", [5] = "FRZ" } -local CURE_TEXT = { PAR = "%s was\ncured of paralysis!", SLP = "%s woke up!", - PSN = "%s was\ncured of poisoning!", BRN = "%s's\nburn was healed!", - FRZ = "%s thawed out!" } +local CURE_TEXT = { + PAR = Strings.source("%s was\ncured of paralysis!"), + SLP = Strings.source("%s woke up!"), + PSN = Strings.source("%s was\ncured of poisoning!"), + BRN = Strings.source("%s's\nburn was healed!"), + FRZ = Strings.source("%s thawed out!"), +} local function eat(battle, eater, effect, param, def, out) local max = maxHp(eater) diff --git a/src/compat/gen2/Decorations.lua b/src/compat/gen2/Decorations.lua new file mode 100644 index 000000000..952a801a6 --- /dev/null +++ b/src/compat/gen2/Decorations.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Legacy argument/result contracts over the native cartridge and placed bytes. +-- No fallback catalogue, ownership bitfield or second decoration save exists. +local Native=require('src.world.Gen2Decorations') +local Version=require('src.core.GameVersion') +local Flags=require('src.script.Flags') +local Gen2Flags=require('src.script.Gen2Flags') +local Copy=require('src.mods.Merge').deepCopy +local M={SLOTS=Copy(Native.slots),OBJECT_SLOTS=Copy(Native.objectSlots),ACTIONS={}, + EVENT_PLAYERS_ROOM_POSTER=716,DECOFLAG_GOLD_TROPHY_DOLL=43,DECOFLAG_SILVER_TROPHY_DOLL=44, + DESC_SLOTS={DECODESC_POSTER={slot='poster'},DECODESC_LEFT_DOLL={slot='leftOrnament',named=true}, + DECODESC_RIGHT_DOLL={slot='rightOrnament',named=true},DECODESC_BIG_DOLL={slot='bigDoll'}, + DECODESC_CONSOLE={slot='console',named=true}}} +for n=1,14 do + local name=Native.actionName(n);local action=Native.action(name) + if not action.put then action.put=nil end + M.ACTIONS[name]=action +end +local bound,edition,epoch=nil,nil,0 +local function data() + return edition==Version.get()and Native.ready(bound)and bound or nil +end +function M.useRegistry(value) + epoch=epoch+1;bound=nil;edition=nil + if Native.ready(value)then bound=value;edition=Version.get()end + return bound~=nil +end +function M.idFor(id)return 'deco:'..tostring(id)end +function M.idForFlag(n) + n=n==nil and 0 or tonumber(n) + local d=data();local info=d and Native.pool(d).decorationInfo + if not(n and n%1==0 and n>=0 and n<=255 and info and info.giftsComplete)then return nil end + return info.gifts and info.gifts[n+1] +end +function M.attributes(id)return Native.attributes(data(),id)end +function M.state(save)return Native.state(data(),save)end +function M.name(id,monName)return Native.name(data(),id,monName)end +function M.registerInto(registry,source,owner) + local d=Native.ready(source)and source or data();local count=0 + if not d then return count end + local seen={} + for raw in pairs(Native.pool(d).decorations)do + local id=Native.byte(raw) + if id and not seen[id]then + seen[id]=true;registry:register(M.idFor(id),Copy(Native.attributes(d,id)),owner);count=count+1 + end + end + return count +end +-- Optional native adapter for callers of the numeric Events protocol. Its +-- get/set operations reach the same canonical flags as scripts and SRAM. +-- A retained adapter expires on rebind/unload; it never captures a new game. +function M.events(save) + if not data()or type(save)~='table'then return nil end + local generation=epoch + local function current(id) + return generation==epoch and data()and type(id)=='number'and id%1==0 and id>=0 and id<65535 + end + return{get=function(_,id) + return current(id)and type(save.flags)=='table'and Flags.get(save,Gen2Flags.eventFlag(id))or false + end,set=function(_,id,value) + if not current(id)then return false end + save.flags=save.flags or{} + if value then Flags.set(save,Gen2Flags.eventFlag(id))else Flags.clear(save,Gen2Flags.eventFlag(id))end + return true + end} +end +local function usable(id) + id=Native.byte(id) + local row=M.attributes(id);local action=row and Native.action(row.action) + return id and id~=0 and action and not action.put and row or nil +end +function M.owns(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.get)=='function'then return events:get(row.flag)and true or false end + return Native.owns(data(),events,id) +end +function M.give(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.set)=='function'then return events:set(row.flag,true)~=false end + return Native.give(data(),events,id) +end +function M.giveFlag(events,n)return M.give(events,M.idForFlag(n))end +function M.ownedCategories(events) + local out={} + for _,category in ipairs(Native.categories(data()))do + for _,id in ipairs(category.members)do + if M.owns(events,id)then out[#out+1]=category;break end + end + end + return out +end +function M.rows(events,category) + local out={} + -- Rebuild membership rather than trusting a category retained across reload. + for _,current in ipairs(Native.categories(data()))do + if category and current.id==category.id and(current.slot==category.slot or + category.slot==nil and category.id~=nil)then + for _,id in ipairs(current.members)do if M.owns(events,id)then out[#out+1]=id end end + if #out>0 then + for _,id in ipairs(current.headers)do out[#out+1]=id end + out[#out+1]=0 + end + break + end + end + return out +end +function M.apply(state,id,side,monName) + local placement=Native.placement(data(),state,id,side,monName) + if not placement then return false,{}end + state[placement.slot]=placement.value + return placement.changed,Native.feedbackPages(data(),placement) +end +function M.clearOtherSide(state,id,side) + id=Native.byte(id) + if not data()or type(state)~='table'or not id or id==0 or(side~='left'and side~='right')then return end + local other=side=='right'and 'leftOrnament'or 'rightOrnament' + if state[other]==id then state[other]=0 end +end +function M.visibility(state)return data()and Native.layoutVisibility(data(),state)or{}end +function M.tiles(state)return data()and Native.layoutTiles(data(),state)or{}end +function M.posterVisible(state)return data()~=nil and Native.posterVisible(state)end +return setmetatable(M,{__index=function(_,key) + if key=='CATEGORIES'then return Native.categories(data())end + if key=='ATTRIBUTES'then + local out={};local d=data() + for raw in pairs(d and Native.pool(d).decorations or{})do + local id=Native.byte(raw);if id then out[id]=Native.attributes(d,id)end + end + return out + end + if key=='NOTHING_TO_CHOOSE'then + local pages=Native.feedbackPages(data(),{role='noChoice',buffers={}}) + return pages[1] + end +end}) diff --git a/src/core/Data.lua b/src/core/Data.lua index 02984c906..359881c68 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1893,6 +1893,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.compat.gen2.Decorations").useRegistry(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/Game.lua b/src/core/Game.lua index fc5db6c50..50d49f3c6 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,9 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI = require('src.render.ScreenSpaceUI') + local windowUI, windowUIIndex = ScreenSpaceUI.select(self.stack,self.stack,drawFrom) + local windowUIOverlay = false local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1095,7 +1098,10 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if state == windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then + if windowUIIndex and i > windowUIIndex then windowUIOverlay = true end -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1184,6 +1190,7 @@ function Game:_draw() worldZones = self.overworld:sgbWorldZones() end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI then ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..7ebabc4fd 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous = GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous ~= GameVersion.current then + local decorations = package.loaded["src.compat.gen2.Decorations"] + if decorations then decorations.useRegistry(nil) end + end return GameVersion.current end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..dff3b2660 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,9 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.ui.gen2.MapRadio"] = "src.ui.Gen2MapRadio", + -- Explicit signature adapter: native operations take (data, save). + ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", @@ -100,6 +103,13 @@ ModCompat.ALIASES = { ["src.render.GbcPalette"] = "src.render.GBCFX", } +-- Extend the data vocabulary without changing loader permissions or public +-- module objects. Existing aliases retain their native identities. +local Compatibility = require("src.mods.Gen1Compat") +for name, target in pairs(Compatibility.ALIASES) do + if ModCompat.ALIASES[name] == nil then ModCompat.ALIASES[name] = target end +end + local seen = {} local installed = false @@ -107,6 +117,8 @@ local installed = false -- matters because "the target is missing too" is a build problem, not a mod -- problem, and should not be reported as an unknown module. function ModCompat.resolve(name) + local missing = Compatibility.unavailable(name) + if missing then return nil, missing end local target = ModCompat.ALIASES[name] if not target then return nil, nil end local ok, value = pcall(require, target) @@ -128,7 +140,23 @@ function ModCompat.install() if installed then return false end local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end - loaders[#loaders + 1] = function(name) + local searcher + searcher = function(name) + local missing = Compatibility.unavailable(name) + if missing then + -- A later host/plugin provider still outranks an unsupported descriptor. + local following = false + for _, candidate in ipairs(loaders) do + if candidate == searcher then following = true + elseif following then + local provider, origin = candidate(name) + if type(provider) == "function" then return provider, origin end + end + end + -- Raise before require installs its in-progress sentinel. Retrying the + -- import must retain the diagnostic instead of reporting a fake loop. + error(missing, 0) + end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" @@ -142,6 +170,7 @@ function ModCompat.install() return value end end + loaders[#loaders + 1] = searcher installed = true return true end diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..01b2ef2ad 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -380,6 +380,16 @@ function Music.current() return state.current end +-- Original contribution by Roxas2712; see the offered PR's contribution +-- permission. Radio ownership changes the restorable cue, not playback. +function Music.mapSong() + return state.mapSong +end + +function Music.setMapSong(song) + state.mapSong = song +end + function Music.stop() local previous = state.current stopSource(state.source) diff --git a/src/core/Version.lua b/src/core/Version.lua index 82cad2f61..b7ba8b6df 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -16,7 +16,7 @@ local Version = { -- binary (e.g. a LOVE version bump); an older shell -- refuses to chainload a payload whose minShell -- exceeds the shell it provides. - modApi = 2, -- mod API major (manifest `api`) + modApi = 3, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) diff --git a/src/import/Gen2PhoneIdentities.lua b/src/import/Gen2PhoneIdentities.lua new file mode 100644 index 000000000..2ac555308 --- /dev/null +++ b/src/import/Gen2PhoneIdentities.lua @@ -0,0 +1,183 @@ +-- Original selection/copy logic by Roxas2712. See offered PR contribution permission. +-- Compatibility constant identifiers and numeric orders only; no ROM bytes, +-- artwork, dialogue or scripts. Reference: Gen1Recomp535a5ec0 manifest constants. +-- Existing engine/third-party credits and rights remain unchanged. +local M = {} +local gold = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_CHRIS", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE"}, + ["BIKER"] = {"KAZU1", "KAZU2", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "CHRIS1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "CHRIS2", "CHRIS3", "WILTON2", "SCOTT", "WILTON3"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"KYOKO", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"ERIC_UNUSED", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF"}, +} +local crystal = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_TULLY", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN", "PHONE_BUENA"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE", "OLIVIA"}, + ["BIKER"] = {"BIKER_BENNY", "KAZU", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BUG_CATCHER_BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3", "WADE4", "WADE5", "ARNIE4", "ARNIE5", "WAYNE"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER", "TODD4", "TODD5", "QUENTIN"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3", "CARA"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN", "DARIN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "TULLY1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "TULLY2", "TULLY3", "WILTON2", "SCOTT", "WILTON3", "RALPH4", "RALPH5", "TULLY4"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3", "ANTHONY4", "ANTHONY5"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3", "IRWIN4"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"NAOKO_UNUSED", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3", "DANA4", "DANA5"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["MYSTICALMAN"] = {"EUSINE"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3", "LIZ4", "LIZ5", "GINA4", "GINA5", "TIFFANY4"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA", "JAIME"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX", "REX", "ALLAN"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH", "BRENT4", "MILLER"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI", "GAKU", "MASA", "KOJI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3", "HUEY4"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3", "JACK4", "JACK5", "ALAN4", "ALAN5", "CHAD4", "CHAD5"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"STAN", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON", "HUGH", "MARKUS"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2", "LEAANDPIA1", "LEAANDPIA2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON", "JOEY4", "JOEY5"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF", "MYSTICALMAN"}, +} +local function copy(value) + if type(value) ~= "table" then return value end + local out = {} + for key, item in pairs(value) do out[key] = copy(item) end + return out +end +function M.forVersion(version) + local data = (version == "gold" or version == "silver") and gold + or version == "crystal" and crystal or nil + return data and copy(data) +end +function M.install(constants, manifest, version) + local supplied = manifest and manifest.constants or {} + local keys = { "phoneContactOrder", "trainerClassOrder", "trainerClassMembers" } + local authored = false + for _, key in ipairs(keys) do + if constants[key] ~= nil or supplied[key] ~= nil then authored = true end + end + -- A partial custom identity set must not silently mix with a retail roster. + local fallback = not authored and M.forVersion(version) or {} + for _, key in ipairs(keys) do + -- Authored native constants win. Manifests can explicitly describe an + -- alternative cartridge; retail identities never guess a hack's roster. + if constants[key] == nil then + local value = type(supplied[key]) == "table" and supplied[key] or fallback[key] + if value then constants[key] = copy(value) end + end + end + return constants +end +return M diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..f7869558a 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -961,6 +961,9 @@ function RomExtractorGen2:constants() -- Generation is runtime metadata, not cartridge data. Battle formulas use -- it to select the Gen II critical-hit ladder without guessing from a ROM id. self._constants.generation = 2 + -- Numeric phone/trainer identities are independent of localized names. + require("src.import.Gen2PhoneIdentities").install( + self._constants, self.manifest, self.version) local badges = {} -- A CARTRIDGE MAY HAVE ITS OWN SET. Prism awards TWENTY badges across -- three engine-flag bytes, and its flag keys keep the ENGINE_ prefix that @@ -3317,6 +3320,221 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Kurt's actual selection-order and recipe pairs, not the bag's item order. +function RomExtractorGen2:gen2ApricornBalls() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local sym=self:symbol("ApricornBalls") + if not (sym and self.rom) then return nil end + local rows,seen={},{} + local info={source="ApricornBalls",complete=false,rowsRead=0} + -- Native db apricorn,ball pairs, terminated by a single $ff apricorn. + -- Never walk through a bank boundary or accept an unterminated prefix. + for slot=0,128 do + local at=sym.address+slot*2 + local ok,apricorn=pcall(function()return self.rom:byte(sym.bank,at)end) + if not ok then info.reason="truncated table";return {},info end + if apricorn==255 then info.complete=true;return rows,info end + if slot==128 then info.reason="unterminated table";return {},info end + local read,ball=pcall(function()return self.rom:byte(sym.bank,at+1)end) + if not read then info.reason="truncated pair";return {},info end + if apricorn==0 or ball==0 or ball==255 or seen[apricorn] then + info.reason="invalid or duplicate native item";return {},info + end + seen[apricorn]=true + rows[#rows+1]={index=slot+1,apricorn=string.format("ITEM_%03d",apricorn), + ball=string.format("ITEM_%03d",ball)} + info.rowsRead=#rows + end +end + +function RomExtractorGen2:gen2QueueApricornPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local symbols={ask="Kurt1.AskApricorn",pending="Kurt1.GotLureBall", + gave="Kurt1.GaveKurtApricorns",cancel="Kurt1.Cancel"} + local found={} + for role,name in pairs(symbols)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address) then return nil end + found[role]=at + end + local out={} + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + +-- Six native bytes per attribute row: kind/name/action/event-word/sprite. +-- Names immediately follow attributes in stock GSC; that boundary supplies +-- the row count instead of a copied retail catalogue. +-- Native Mom tables have eight bytes: two big-endian money triples and +-- kind/item bytes. End symbols supply counts, never a copied retail list. +function RomExtractorGen2:gen2MomShopping(pool) + if self.version~="gold"and self.version~="silver"and self.version~="crystal"then return nil end + local result={complete=false} + local ok,why=pcall(function() + for role,name in pairs({random="MomItems_1",ladder="MomItems_2"})do + local first,last=self:symbol(name),self:symbol(name..".End") + assert(first and last and first.bank==last.bank,"missing Mom table bounds") + local count=(last.address-first.address)/8 + assert(count%1==0 and count>0 and count<=255 and first.address>=0x4000 and last.address<=0x8000,"invalid Mom table bounds") + local rows={} + for i=0,count-1 do + local b=self.rom:bytes(first.bank,first.address+i*8,8) + local trigger=b[1]*65536+b[2]*256+b[3];local cost=b[4]*65536+b[5]*256+b[6] + assert(trigger<=999999 and cost<=999999 and(b[7]==1 or b[7]==2)and b[8]>0 and b[8]<255,"invalid Mom row") + rows[#rows+1]={trigger=trigger,cost=cost,kind=b[7],item=b[8]} + end + result[role]=rows + end + local at,ram=self:symbol("CheckBalance_MomItem2.check_have_2300"),self:symbol("hMoneyTemp") + assert(at and ram,"missing Mom trigger instructions") + local b=self.rom:bytes(at.bank,at.address,11) + assert(b[1]==0x21 and b[2]+b[3]*256==ram.address and b[4]==0x36 and b[6]==0x23 and + b[7]==0x36 and b[9]==0x23 and b[10]==0x36,"unrecognized Mom trigger instructions") + result.step=b[5]*65536+b[8]*256+b[11] + assert(result.step>0 and result.step<=999999,"invalid Mom trigger amount") + end) + if not ok then return{complete=false,reason=tostring(why)}end + local found={} + for role,name in pairs({item="Mom_GetScriptPointer.ItemScript",doll="Mom_GetScriptPointer.DollScript"})do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return{complete=false,reason="missing Mom phone program"}end + found[role]=at + end + result.programs={} + for role,at in pairs(found)do result.programs[role]=self:gen2QueueScript(at.bank,at.address,pool)end + result.rewardTexts={} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for mode,name in pairs({[0]="GotMoneyForWinningText",[1]="SentSomeToMomText",[2]="SentHalfToMomText",[3]="SentAllToMomText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then result.rewardTexts[mode]=self:gen2RegisterScriptText(at.bank,at.address)end + end + result.complete=true;return result +end + +function RomExtractorGen2:gen2Decorations() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} + local attr,names=self:symbol("DecorationAttributes"),self:symbol("DecorationNames") + local function fail(why)info.reason=why;return {},info end + if not attr or not names then return fail("missing decoration table symbols")end + local count=(names.address-attr.address)/6 + if attr.bank~=names.bank or count%1~=0 or count<1 or count>256 or + attr.address<0x4000 or names.address>0x8000 then return fail("invalid decoration table boundary")end + local Native=require("src.world.Gen2Decorations") + local ok,rows=pcall(function() + local raw,maxName={},0 + for i=0,count-1 do + local r=self.rom:bytes(attr.bank,attr.address+i*6,6) + assert(r[1]>=1 and r[1]<=6,"invalid decoration kind") + assert(r[3]==0 or Native.actionName(r[3]),"invalid decoration action") + assert(r[4]+r[5]*256<65535,"indirect decoration event") + raw[i]=r;if r[1]<=3 then maxName=math.max(maxName,r[2])end + end + local charmap=self:readSourceTable("charmap") + local words,pc={},names.address + for i=0,maxName do + local bytes,ended={},false + for _=1,128 do + assert(pc<0x8000,"decoration name crosses bank") + local b=self.rom:byte(names.bank,pc);pc=pc+1 + if b==0x50 then ended=true;break end + bytes[#bytes+1]=b + end + assert(ended,"unterminated decoration name") + words[i]=self:gen2GlyphBytes(bytes,charmap) + end + local pokemon=self:symbol("PokemonNames") + local out={} + for i=0,count-1 do + local r=raw[i];local name=words[r[2]] + if r[1]>=4 then + assert(pokemon and r[2]>=1,"missing decoration species name") + local address=pokemon.address+(r[2]-1)*10 + assert(address>=0x4000 and address+10<=0x8000,"decoration species name crosses bank") + local bytes={} + for n=0,9 do local b=self.rom:byte(pokemon.bank,address+n);if b==0x50 then break end;bytes[#bytes+1]=b end + name=self:gen2GlyphBytes(bytes,charmap) + info.species=info.species or{};info.sourceNames=info.sourceNames or{} + info.species[i]=string.format("SPECIES_%03d",r[2]);info.sourceNames[i]=name + end + assert(type(name)=="string" and name~="","missing decoration name") + out[i]={type=r[1],name=name,action=Native.actionName(r[3]),flag=r[4]+r[5]*256,sprite=r[6]} + end + return out + end) + if not ok then return fail(tostring(rows))end + local init=self:symbol("InitDecorations") + if init then + local valid,bytes=pcall(self.rom.bytes,self.rom,init.bank,init.address,11) + if valid and bytes[1]==0x3e and bytes[3]==0xea and bytes[6]==0x3e and bytes[8]==0xea and bytes[11]==0xc9 and + rows[bytes[2]] and rows[bytes[2]].action=="SET_UP_BED" and rows[bytes[7]] and rows[bytes[7]].action=="SET_UP_POSTER" then + local bed,poster=self:symbol("wDecoBed"),self:symbol("wDecoPoster") + if bed and poster and bytes[4]+bytes[5]*256==bed.address and bytes[9]+bytes[10]*256==poster.address then + info.defaults={bed=bytes[2],poster=bytes[7]};info.defaultsComplete=true + end + end + end + local gifts=self:symbol("DecorationIDs") + if gifts then + local valid,list=pcall(function() + local out={} + for n=0,255 do + assert(gifts.address+n<0x8000,"decoration gift table crosses bank") + local id=self.rom:byte(gifts.bank,gifts.address+n) + if id==255 then return out end + assert(rows[id] and Native.action(rows[id].action) and not Native.action(rows[id].action).put,"invalid gift decoration") + out[#out+1]=id + end + error("unterminated decoration gift table") + end) + if valid then info.gifts=list;info.giftsComplete=true end + end + info.complete=true;info.rowsRead=count + return rows,info +end + +function RomExtractorGen2:gen2QueueDecorationPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local roles={ornament="DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + big="DecorationDesc_GiantOrnament.BigDollScript",null="DecorationDesc_NullPoster"} + local found={} + for role,name in pairs(roles)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return nil end + found[role]=at + end + local pointers=self:symbol("DecorationDesc_PosterPointers") + if not pointers then return nil end + local ok,posters=pcall(function() + local out={} + for n=0,255 do + local pc=pointers.address+n*3;assert(pc<0x8000,"poster pointers cross bank") + local id=self.rom:byte(pointers.bank,pc) + if id==255 then return out end + assert(pc+2<0x8000,"truncated poster pointer") + local address=self.rom:word(pointers.bank,pc+1) + assert(address>=0x4000 and self:gen2InRom(pointers.bank,address)and not out[id],"invalid poster pointer") + out[id]={bank=pointers.bank,address=address} + end + error("unterminated poster pointers") + end) + if not ok then return nil end + local out={posters={},texts={}} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for role,name in pairs({put="PutAwayTheDecoText",empty="NothingToPutAwayText", + setup="SetUpTheDecoText",replace="PutAwayAndSetUpText",same="AlreadySetUpText", + noChoice="PopulateDecoCategoryMenu.NothingToChooseText", + sideSetup="WhichSidePutOnText",sidePut="WhichSidePutAwayText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then + out.texts[role]=self:gen2RegisterScriptText(at.bank,at.address) + end + end + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + for id,at in pairs(posters)do out.posters[id]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -5522,6 +5740,8 @@ function RomExtractorGen2:gen2MapIndex() -- PALETTE_DARK, the only value ReplaceTimeOfDayPals answers with -- .NeedsFlash (23:$4400) palette = field(paletteAt, 0) % 16, + paletteBits = field(paletteAt,0), + phoneService = math.floor(field(paletteAt,0)/16)%16==0, } byGroupNumber[group * 256 + number] = entry byLabel[label] = byLabel[label] or entry @@ -6521,6 +6741,7 @@ function RomExtractorGen2:extractMapsFromRom() -- is why a Pokemon Center does not go dark at night on hardware -- and -- the Ruins of Alph chambers carry it too. def.mapPalette = header and header.palette or def.mapPalette + if header then def.mapPaletteBits=header.paletteBits;def.phoneService=header.phoneService end -- Byte 8 is the FISHGROUP_* constant, and 0 is FISHGROUP_NONE -- NOT -- FISHGROUP_SHORE, which is 1 (constants/map_data_constants.asm). -- gen2Fishing keys its table by that same constant, so 0 finds nothing @@ -8126,7 +8347,10 @@ function RomExtractorGen2:gen2ResolveScriptMapIds(maps, keys, pool) if slot and type(row[slot]) == "number" and type(row[slot + 1]) == "number" then local entry = mapIndex[row[slot] * 256 + row[slot + 1]] local key = entry and keyByLabel[entry.label] - if key then + if (row[1]=="warp"or row[1]=="warpfacing") and row[slot]==0 and row[slot+1]==0 and + (self.version=="gold"or self.version=="silver"or self.version=="crystal")then + row[slot]="NONE";row[slot+1]=0 + elseif key then row[slot] = key row[slot + 1] = 0 end @@ -8431,7 +8655,6 @@ end -- POKeGEAR had no dialogue at all to show. local GEN2_PHONE_ROW_BYTES = 12 local GEN2_PHONE_MAX = 64 -local GEN2_NONTRAINER_NAMES = 5 -- db class, id | db group, number | db ptime, bank, dw callee script (the one -- that runs when YOU ring them) | db ctime, bank, dw caller script (the one -- that runs when THEY ring you -- ELM's is the same $41E1 @@ -8447,14 +8670,30 @@ function RomExtractorGen2:gen2PhoneContacts(pool) local sym = self:symbol("PhoneContacts") if not (sym and self.rom) then return nil end + -- SpecialPhoneCallList closes the table, including the reserved holes. + local limit = GEN2_PHONE_MAX + local stop = self:symbol("SpecialPhoneCallList") + if stop and stop.bank == sym.bank and stop.address > sym.address then + limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) + end + local rows, rowsRead, lastName = {}, 0, 0 + for id = 0, limit - 1 do + local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, + sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) + if not ok then break end + rows[id], rowsRead = row, rowsRead + 1 + if row[1] == 0 then lastName = math.max(lastName, row[2]) end + end + -- Polished names can contain a Huffman-compressed suffix ($5D). - -- Decode the string stream before applying its charmap. + -- Decode the string stream before applying its charmap. Read every name + -- referenced by the actual table: the Gold-sized four-name bound discarded + -- Crystal's Buena row (non-trainer identity5), including both call scripts. local names = {} local nsym = self:symbol("NonTrainerCallerNames") if nsym then local polished = self.version == "polishedcrystal" - local count = polished and 7 or GEN2_NONTRAINER_NAMES - for id = 1, count - 1 do + for id = 1, lastName do local ok, address = pcall(self.rom.word, self.rom, nsym.bank, nsym.address + id * 2) if ok and self:gen2InRom(nsym.bank, address) then @@ -8470,18 +8709,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end end - -- SpecialPhoneCallList closes the table; sizing off it beats guessing - local limit = GEN2_PHONE_MAX - local stop = self:symbol("SpecialPhoneCallList") - if stop and stop.bank == sym.bank and stop.address > sym.address then - limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) - end - local out = {} - for id = 0, limit - 1 do - local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, - sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) - if not ok then break end + for id = 0, rowsRead - 1 do + local row = rows[id] local class, trainer = row[1], row[2] -- the unused rows are class 0 / id 0, which GetCallerName renders as the -- "----------" placeholder; skip them so the POKeGEAR can never list one @@ -8490,7 +8720,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + -- Keep the non-trainer's native identity; names are localizable. + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + class = 0, contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the @@ -8510,8 +8742,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end out[id] = entry end - if not next(out) then return nil end - return out + local info = { size = limit, rowsRead = rowsRead, nonTrainerNames = names } + if not next(out) then return nil, info end + return out, info end -- `SpecialPhoneCallList` ([36,$45F6] .. PhoneOutOfAreaScript, 48 bytes) is @@ -8891,7 +9124,12 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.momShopping=self:gen2MomShopping(pool) + out.decorations,out.decorationInfo=self:gen2Decorations() + out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() + out.apricornBalls, out.apricornInfo = self:gen2ApricornBalls() + out.apricornPrograms = self:gen2QueueApricornPrograms(pool) out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no -- Fly-warp list of its own, because Fly lands on the SPAWN POINT of the @@ -8900,7 +9138,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts -- drawn in. self._gen2Spawns = out.spawns self._gen2MapDefs = maps - out.phone = self:gen2PhoneContacts(pool) + out.phone, out.phoneInfo = self:gen2PhoneContacts(pool) out.specialCalls = self:gen2SpecialPhoneCalls(pool) -- wVariableSprites as InitializeEventsScript leaves it on a new game, so an -- object_event whose sprite byte is $F0 or more resolves before the map's @@ -11418,7 +11656,10 @@ function RomExtractorGen2:gen2Landmarks() if not (sym and self.rom) then return nil end local charmap = self:readSourceTable("charmap") local out = {} - for id = 0, GEN2_LANDMARK_COUNT - 1 do + local order,why=require("src.world.Gen2Landmarks").order(self.version,self.manifest) + if why then return nil end + local count=#order>0 and #order or self:layout("townMapLandmarkCount",GEN2_LANDMARK_COUNT) + for id = 0, math.min(256,count) - 1 do pcall(function() local row = sym.address + id * GEN2_LANDMARK_BYTES local name = self:decodeGen2TextAt(sym.bank, @@ -11426,6 +11667,7 @@ function RomExtractorGen2:gen2Landmarks() -- $1F is the landmark line break (TownMap_ConvertLineBreakCharacters) name = name:gsub("{BYTE:1F}", "\n") out[id] = { + id = order[id+1], index = id, x = self.rom:byte(sym.bank, row), y = self.rom:byte(sym.bank, row + 1), name = name ~= "" and name or nil, @@ -11436,12 +11678,15 @@ function RomExtractorGen2:gen2Landmarks() end function RomExtractorGen2:gen2TownMap() + local landmarks = self:gen2Landmarks() local johto = self:gen2TownMapImage("JohtoMap", "ui/town_map_johto.png") - if not johto then return nil end + -- Native name scripts do not depend on a successfully decoded map picture. + if not johto and not landmarks then return nil end local out = { johto = johto, kanto = self:gen2TownMapImage("KantoMap", "ui/town_map_kanto.png"), - landmarks = self:gen2Landmarks(), + landmarks = landmarks, + landmarkOrder = require("src.world.Gen2Landmarks").order(self.version,self.manifest), } -- Player "you are here" icon: ChrisSpriteGFX facing-down (4 tiles = 16x16) -- tinted with PAL_OW_RED so it is not a greyscale silhouette on the map. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..b11906638 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1316,7 +1316,10 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-phone-identities-v1:" + revision = revision .. "gen2-apricorn-table-v1:gen2-apricorn-programs-v1:" + revision = revision .. "gen2-landmark-identities-v1:" + revision = revision .. "gen2-mom-shopping-native-v1:gen2-decorations-native-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..8e678aca5 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,222 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local Warp = require("src.mods.Api3Warp") +local Edits = require("src.mods.Api3WorldEdits") +local NPC = require("src.mods.Api3NPC") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):gmatch( + "%S+" + ) +do + SUPPORTED[name] = true +end +local function pack(...) + return { n = select("#", ...), ... } +end +local unpack = table.unpack or unpack +local function block(gen) + return { + tileSize = gen == 3 and 16 or 32, + cellSize = 16, + coordinateBase = 0, + kind = gen == 3 and "metatile" or "block", + } +end +function A.install(api, loader, readContext, overviewContext, engineRequire) + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local warp, licensed, npc + if supportedGeneration then + warp, licensed = Warp.new(loader, api.id, gen, current, native) + npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) + end + local function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + if name == "spawnNpc" or name == "removeNpc" or name == "npc" then + return npc(name, ...) + end + if name == "warpTo" then + return warp(...) + end + local edit = name == "toggleObject" or name == "replaceBlock" + if edit and not licensed() then + return nil, "world facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + if edit then + return Edits.call(game, native(game), gen, engineRequire, name, ...) + end + if name == "setFlag" or name == "getFlag" then + if not game.save then + return nil, "no save" + end + local key, value = ... + if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then + return nil, "unsupported: API 3 native flag mapping" + end + if name == "setFlag" and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local id = key:sub(5) + if #id == 0 or #id > 256 or id:find("%c") then + return nil, "invalid mod flag" + end + local store = game.save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[api.id] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if name == "getFlag" then + return flags ~= nil and flags[id] == true + end + local before = flags ~= nil and flags[id] == true + game.save.modData = store or {} + bucket = bucket or {} + game.save.modData[api.id] = bucket + flags = flags or {} + bucket.worldFlags = flags + flags[id] = value or nil + if before ~= value then + Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) + end + return true + end + if name == "queueScript" then + return scripts:queueScript(...) + end + local raw = native(game) + local result = pack(raw[name](raw, ...)) + if name == "party" and type(result[1]) == "table" then + for _, mon in ipairs(result[1]) do + Records.mon(mon, game.data, gen) + end + elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then + result[1].block = block(gen) + end + return unpack(result, 1, result.n) + end + for name in WORLD:gmatch("%S+") do + local method = name + world[method] = function(_, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local values = pack(pcall(dispatch, method, ...)) + Runtime.currentMod = owner + if not values[1] then + return nil, "world operation failed: " .. tostring(values[2]) + end + return unpack(values, 2, values.n) + end + end + api.world = world + local battle = api.battle + api.battle = { + snapshot = function() + local snapshot, why = battle:snapshot() + if not snapshot then + return nil, why + end + local game = current() + local data = game and game.data + Records.mon(snapshot.player, data, gen) + Records.mon(snapshot.enemy, data, gen) + for _, name in ipairs({ "party", "battlers" }) do + for _, mon in ipairs(snapshot[name] or {}) do + Records.mon(mon, data, gen) + end + end + return snapshot + end, + submit = function(_, ...) + return battle:submit(...) + end, + } + -- Version datasets have no native Gen2Recomped adapter yet. Keeping the + -- handle callable allows the same mod to fall back without engine imports. + api.datasets = { + open = function() + return nil, "unsupported: version datasets" + end, + } + local supports = api.supports + function api:supports(surface, name) + if surface == "world" then + if supportedGeneration and SUPPORTED[name] then + return true + end + return false, "unsupported: API 3 world method " .. tostring(name) + end + return supports(self, surface, name) + end +end +return A diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua new file mode 100644 index 000000000..0549286b9 --- /dev/null +++ b/src/mods/Api3Catalog.lua @@ -0,0 +1,57 @@ +-- Public callback-name snapshot from Gen1Recomp API 3, commit +-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability: +-- Capabilities still requires a native Gen2Recomped call site. +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +local C = {} +C.hooks = names([[ +battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible +battle.charge_required battle.crit battle.damage battle.enemy_action +battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm +battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run +battle.status_hud_visible battle.style battle.turn_order breeding.compatibility +catch.nickname catch.party_full catch.rate core.logic_speed +core.quit_to_launcher core.update encounter.fishing encounter.roll +encounter.species encounter.table evolution.check exp.gain +fieldmove.eligibility gender.roll held_item.trigger input.gamepad +input.key input.pointer input.step input.wheel +intro.oak_speech.build item.use link.fingerprint map.palette +movement.collision movement.speed music.select music.volume +phone.contact_list player.sprite pokemon.icon pokemon.level_visible +pokemon.sprite render.compose render.hud render.letterbox +render.output render.output_enabled render.viewport render.window +render.zones save.new_game save.write screen.render_visible +script.command shiny.roll trainer.before_battle trainer.party +transition.style ui.list_menu ui.naming.grid ui.options.rows +ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items +ui.title_menu.items warp.destination world.follower.spawn world.talk +world.tod zoom.range +]]) +C.events = names([[ +apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched +battle.damage_dealt battle.ended battle.exp_gained battle.fainted +battle.move_used battle.started battle.status_inflicted battle.turn_ended +battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored +clock.day_changed discord.join_requested egg.hatched flag.changed +game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak +intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered +intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended +link.connected link.desync link.ended mail.read +mail.written map.entered map.exited map.reloaded +mod.options_changed mods.loaded music.started music.stopped +phone.call_received player.warped pokemon.before_give pokemon.caught +pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received +pokerus.infected radio.channel roamer.encountered roamer.moved +save.created save.loaded save.loading save.writing +screen.popped screen.pushed script.ended script.started +sound.played trade.completed unown.unlocked world.blacked_out +world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing +world.live_maps_updated world.npc_spawned world.object_toggled world.stepped +world.tod_changed world.trainer_engaged +]]) +return C diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua new file mode 100644 index 000000000..b1a29b51b --- /dev/null +++ b/src/mods/Api3NPC.lua @@ -0,0 +1,546 @@ +-- Public API-3 NPC handles. Engine objects never cross the handle boundary. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Controls = require("src.world.NpcControls") +local copy = require("src.mods.Merge").deepCopy +local N = {} +local services = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local moves = {} +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n, minimum, maximum) + return type(n) == "number" + and n == n + and n >= (minimum or 0) + and n < math.huge + and n % 1 == 0 + and (not maximum or n <= maximum) +end +local function message(value) + local ok, text = pcall(tostring, value) + return ok and text or "unprintable error" +end +local function identifier(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function reference(v) + return identifier(v) or integer(v) +end +-- Bounded data-only copy, including extra generation-native definition fields. +local function definition(value) + local visiting, nodes, bytes = {}, 0, 0 + local function clone(v, depth) + nodes = nodes + 1 + if nodes > 4096 or depth > 16 then + error("NPC definition too large", 0) + end + local t = type(v) + if t == "string" then + bytes = bytes + #v + if #v > 4096 or bytes > 65536 then + error("NPC definition too large", 0) + end + return v + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + error("invalid NPC definition", 0) + end + return v + elseif t == "nil" or t == "boolean" then + return v + elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then + error("invalid NPC definition", 0) + end + visiting[v] = true + local out, count = {}, 0 + for k, item in pairs(v) do + count = count + 1 + if count > 256 or type(k) ~= "string" and not integer(k) then + error("invalid NPC definition", 0) + end + out[clone(k, depth + 1)] = clone(item, depth + 1) + end + visiting[v] = nil + return out + end + if type(value) ~= "table" then + return nil, "invalid NPC definition" + end + local ok, result = pcall(clone, value, 0) + if not ok then + return nil, result + end + if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then + return nil, "invalid NPC coordinates" + end + for _, key in ipairs({ "name", "sprite" }) do + if result[key] ~= nil and not identifier(result[key]) then + return nil, "invalid NPC " .. key + end + end + if result.facing ~= nil and not dirs[result.facing] then + return nil, "invalid NPC facing" + end + return result +end +local function removeFrom(list, predicate) + for i = #(list or {}), 1, -1 do + if predicate(list[i]) then + table.remove(list, i) + end + end +end +local function retire(move) + move.done = true + moves[move] = nil + move.callback = nil + local removed = false + removeFrom(move.world.scriptMoves, function(entry) + if move.entries[entry] then + removed = true + return true + end + end) + if removed then + local foreign = false + for _, entry in ipairs(move.world.scriptMoves or {}) do + if entry.entity == move.actor then + foreign = true + end + end + if not foreign then + move.actor.moving, move.actor.marching = false, false + move.actor.targetX, move.actor.targetY = nil, nil + move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16 + end + end +end +local function removeSpawn(token) + -- Scoped by exact native record identity, even after Game/provider changes. + local rows = token.data.maps and token.data.maps[token.mapId] + local removed = false + removeFrom(rows and rows.objects, function(row) + if row == token.record then + removed = true + return true + end + end) + local function owned(actor) + return actor + and ( + actor == token.actor + or actor.def == token.record + or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index) + ) + end + removeFrom(token.world.npcs, owned) + removeFrom(token.world.entities, owned) + removeFrom(token.world.ghosts, function(g) + return owned(g.npc) + end) + for id, actor in pairs(token.world.npcPool or {}) do + if owned(actor) then + token.world.npcPool[id] = nil + end + end +end +function N.cancelAll(loader, owner) + for service in pairs(services) do + if (not loader or loader == service.loader) and (not owner or owner == service.owner) then + service.closed = true + for move in pairs(moves) do + if move.service == service then + retire(move) + end + end + for _, token in ipairs(service.spawned) do + removeSpawn(token) + end + service.spawned = {} + services[service] = nil + end + end +end +local function inspect(loader) + for move in pairs(moves) do + if move.service.loader == loader then + local ok, valid = pcall(move.valid) + if not ok or not valid then + retire(move) + end + end + end +end +local function observe(loader) + if observers[loader] and observers[loader][1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(moves) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_npc") + for _, event in ipairs({ "map.exited", "map.entered" }) do + loader.events:on(event, function() + for move in pairs(moves) do + if move.service.loader == loader then + retire(move) + end + end + end, math.huge, "engine:api3_npc") + end +end +function N.new(loader, owner, gen, current, native, licensed, req) + local service = { loader = loader, owner = owner, spawned = {} } + services[service] = true + observe(loader) + local function alive() + return not service.closed and licensed() + end + local function resolveGame() + if not alive() then + return nil, nil, "world facade is retired" + end + local game = current() + if not game then + return nil, nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, nil, "no overworld" + end + return game, ow + end + local function mapId(game, id) + if gen == 3 and not (game.data.maps or {})[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if (game.data.maps or {})[prefix .. id] then + return prefix .. id + end + end + return id + end + local function actorAt(ow, id, ref) + local function matches(actor) + local def = actor.def or {} + return actor.id == ref or def.index == ref or def.name == ref + end + if ow.map and ow.map.id == id then + for _, actor in ipairs(ow.npcs or {}) do + if matches(actor) then + return actor, ow.map, ow.cast or ow.entities + end + end + return nil, "no such object: " .. tostring(ref) + end + for _, g in ipairs(ow.ghosts or {}) do + local actor = g.npc + local map = g.map + if map and map.id == id and actor and matches(actor) then + local peers = g.nb and g.nb.cast or g.peers + if not peers then + peers = {} + for _, sibling in ipairs(ow.ghosts) do + if sibling.map == map then + peers[#peers + 1] = sibling.npc + end + end + end + return actor, map, peers, true + end + end + return nil, "map is not live" + end + local function sprite(game, id) + return game.data and game.data.sprites and game.data.sprites[id] + end + local function queue(game, ow, actor, valid, name, a, b, callback) + local hooks, events = Runtime.hooks, Runtime.events + local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback } + local save, data, stack, map, version, fastWorld = + game.save, game.data, game.stack, ow.map, Version.get(), game.overworld + function move.valid() + return alive() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == fastWorld + and ow.map == map + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and valid() + end + local accepted, deferred + local function finish(...) + if move.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local checked, valid = pcall(move.valid) + local fn = accepted and checked and valid and move.callback + retire(move) + if not fn then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local ok, why = pcall(fn, ...) + Runtime.currentMod = previous + if not ok then + Runtime.reportError(owner, "NPC movement callback failed: " .. message(why)) + end + end + local before = {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + moves[move] = true + local called, result, detail + if name == "scriptMove" then + called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish) + else + called, result, detail = pcall(ow.marchInPlace, ow, actor, finish) + end + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + move.entries[entry] = true + end + end + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if accepted then + local checked, valid = pcall(move.valid) + accepted = checked and valid and not move.done + end + if not accepted then + retire(move) + return nil, + called and (detail or "NPC movement failed") or "world operation failed: " .. message(result) + end + local drain = deferred and function() + finish(unpack(deferred, 1, deferred.n)) + end + return true, nil, drain + end + local function handle(game, ow, id, ref) + local actor, map = actorAt(ow, id, ref) + if not actor then + return nil, map + end + local publicId = actor.id + if not identifier(publicId) then + return nil, "NPC identity unavailable" + end + local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld + local function live() + if + not alive() + or current() ~= game + or game.save ~= save + or game.data ~= data + or game.overworld ~= fastWorld + or Version.get() ~= version + or native(game):overworld() ~= ow + then + return + end + local found, activeMap, peers, ghost = actorAt(ow, id, publicId) + if found ~= actor or activeMap ~= map then + return + end + return actor, activeMap, peers, ghost + end + local out = { id = publicId } + local function invoke(name, a, b, c) + if not alive() then + return nil, "world facade is retired" + end + local npc, onMap, peers, ghost = live() + if not npc then + return nil, "NPC handle is stale" + end + if name == "position" then + return npc.cellX, npc.cellY + end + if name == "isMoving" then + return Controls.isMoving(npc) + end + if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then + if not dirs[a] then + return nil, "invalid direction" + end + end + if name == "canStep" then + return Controls.canStep(npc, onMap, peers, a) + end + if + name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") + then + return nil, "invalid movement" + end + if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then + return nil, "invalid callback" + end + if + name == "placeAt" + and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c]) + then + return nil, "invalid coordinates" + end + if name == "setPassable" and type(a) ~= "boolean" then + return nil, "invalid passability" + end + if name == "setAppearance" and not identifier(a) then + return nil, "invalid sprite id" + end + local ok, why = Edits.ready(game, ow) + if not ok then + return nil, why + end + if ghost and (name == "scriptMove" or name == "marchInPlace") then + return nil, "movement tracks require the active map" + end + local drain + local value, detail = Edits.run(game, function() + if name == "scriptMove" or name == "marchInPlace" then + local value, why + local callback = a + if name == "scriptMove" then + callback = c + end + value, why, drain = queue(game, ow, npc, live, name, a, b, callback) + return value, why + elseif name == "face" then + npc.facing = a + elseif name == "stepNow" then + return Controls.stepNow(npc, a) + elseif name == "placeAt" then + return Controls.placeAt(npc, a, b, c) + elseif name == "setPassable" then + return Controls.setPassable(npc, a) + elseif name == "setAppearance" then + return Controls.setAppearance(npc, game, a, req) + end + return true + end) + if drain then + drain() + end + return value, detail + end + for _, name in ipairs({ + "position", + "isMoving", + "canStep", + "scriptMove", + "marchInPlace", + "face", + "stepNow", + "placeAt", + "setPassable", + "setAppearance", + }) do + local method = name + out[method] = function(_, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(invoke, method, ...)) + Runtime.currentMod = previous + if not result[1] then + return nil, "world operation failed: " .. message(result[2]) + end + return unpack(result, 2, result.n) + end + end + return out + end + return function(name, a, b) + local game, ow, why = resolveGame() + if not game then + return nil, why + end + if name == "npc" then + if not identifier(a) or not reference(b) then + return nil, "invalid NPC reference" + end + return handle(game, ow, mapId(game, a), b) + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + return Edits.run(game, function() + if name == "spawnNpc" then + if not identifier(a) or type(b) ~= "table" then + return nil, "invalid NPC definition" + end + local id = mapId(game, a) + local mapDef = game.data.maps and game.data.maps[id] + if not mapDef then + return nil, "unknown map: " .. a + end + local row, err = definition(b) + if not row then + return nil, err + end + if row.sprite and not sprite(game, row.sprite) then + return nil, "unknown sprite: " .. row.sprite + end + local before = {} + for _, item in ipairs(mapDef.objects or {}) do + before[item] = true + end + local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row) + for _, item in ipairs(mapDef.objects or {}) do + if not before[item] and item.runtime and item.owner == owner then + local publicId = id .. "_obj_" .. tostring(item.index) + local actor = actorAt(ow, id, publicId) + local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor } + if not called or not value then + removeSpawn(token) + else + service.spawned[#service.spawned + 1] = token + end + end + end + if not called then + return nil, "world operation failed: " .. message(value) + end + return value, detail + end + if not reference(a) or gen == 3 and not identifier(a) then + return nil, "invalid NPC id" + end + local value, err = native(game):removeNpc(a) + if value then + for i = #service.spawned, 1, -1 do + local token = service.spawned[i] + if token.mapId .. "_obj_" .. tostring(token.record.index) == a then + table.remove(service.spawned, i) + end + end + end + return value, err + end) + end +end +return N diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..22d540b20 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,59 @@ +-- API-3 public Pokemon values. API-2 unified records keep their existing shape. +local R = {} +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local SHORT = {} +for short, long in pairs(LONG) do + SHORT[long], SHORT[short] = short, short +end +function R.status(value) + if value == false or value == "" or value == 0 then + return nil + end + return SHORT[value] or value +end +function R.nativeStatus(value, previous) + if value == nil or value == false then + return nil + end + local short = R.status(value) + assert(LONG[short], "unknown status") + if previous and LONG[SHORT[previous]] == previous then + return LONG[short] + end + return short +end +function R.stats(out, gen) + out.special = gen ~= 1 and out.specialAttack or out.special + return out +end +function R.shiny(raw, gen) + if raw.shiny ~= nil then + return raw.shiny == true + end + if gen ~= 3 then + return Stats.isShiny(raw.dvs) + end + if raw.isShiny ~= nil then + return raw.isShiny == true + end + local p = (tonumber(raw.personality) or 0) % 4294967296 + local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296 + local secret = tonumber(raw.otSecretId) or math.floor(id / 65536) + return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8 +end +function R.mon(out, data, gen) + if type(out) ~= "table" then + return out + end + out.status = R.status(out.status) + out.stats = R.stats(out.stats or {}, gen) + local raw = out.native or {} + local def = ((data or {}).pokemon or {})[raw.species or out.species] or {} + out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species + out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot + out.shiny = R.shiny(raw, gen) + return out +end +return R diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua new file mode 100644 index 000000000..400465d87 --- /dev/null +++ b/src/mods/Api3Warp.lua @@ -0,0 +1,250 @@ +-- API-3 warp admission and callback lifetime over the native overworld path. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Actions = require("src.world.WorldActions") +local W = {} +local contexts = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local pending = setmetatable({}, { __mode = "k" }) +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n) + return type(n) == "number" and n == n and n >= 0 and n < math.huge and n % 1 == 0 +end +local function retire(token) + token.done = true + token.callback = nil + if pending[token.game] == token then + pending[token.game] = nil + end +end +function W.cancelAll(loader, owner) + for candidate, context in pairs(contexts) do + if not loader or candidate == loader then + if owner then + context.owners[owner] = nil + else + contexts[candidate] = nil + end + end + end + for _, token in pairs(pending) do + if (not loader or token.loader == loader) and (not owner or token.owner == owner) then + retire(token) + end + end +end +local function inspect(loader) + for _, token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + local old = observers[loader] + if old and old[1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Installed at facade creation, never spliced into a dispatching hook chain. + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(pending) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_warp") + for _, event in ipairs({ "map.entered", "map.exited" }) do + local name = event + loader.events:on(name, function(payload) + for _, token in pairs(pending) do + if token.loader == loader then + if not token.valid() then + retire(token) + elseif name == "map.entered" then + if token.entered or payload.mapId ~= token.destination then + retire(token) + else + token.entered = true + end + elseif token.entered then + retire(token) + end + end + end + end, math.huge, "engine:api3_warp") + end +end +function W.new(loader, owner, generation, current, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[owner] = lease + local function licensed() + return contexts[loader] == context and context.owners[owner] == lease + end + observe(loader) + local function warp(mapId, x, y, facing, options) + if not licensed() then + return nil, "warp facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local api = native(game) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ready, why = Actions.ready(game, ow) + if not ready then + return nil, why + end + inspect(loader) + if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then + return nil, "invalid map id" + end + local maps = game.data and game.data.maps + local destination = mapId + if generation == 3 and maps and not maps[destination] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. destination] then + destination = prefix .. destination + end + end + local def = maps and maps[destination] + if not def then + return nil, "unknown map: " .. mapId + end + if not integer(x) or not integer(y) then + return nil, "invalid coordinates" + end + local scale = generation == 3 and 1 or 2 + if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then + return nil, "invalid map dimensions" + end + if x >= def.width * scale or y >= def.height * scale then + return nil, "coordinates out of bounds" + end + if facing ~= nil and not dirs[facing] then + return nil, "invalid facing" + end + if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then + return nil, "invalid options" + end + local opts = {} + for key, value in pairs(options or {}) do + if key == "arrive" then + if value ~= "fly" and value ~= "teleport" then + return nil, "invalid arrival" + end + elseif key == "onDone" then + if type(value) ~= "function" then + return nil, "invalid callback" + end + elseif key == "keepMusic" or key == "fade" then + if type(value) ~= "boolean" then + return nil, "invalid options" + end + if key == "fade" and generation ~= 3 then + return nil, "unsupported fade" + end + else + return nil, "unknown warp option: " .. tostring(key) + end + opts[key] = value + end + facing = facing or (generation ~= 1 and ow.player.facing) or "down" + local hooks, events = Runtime.hooks, Runtime.events + local token = + { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } + local save, data, stack, player, version = game.save, game.data, game.stack, ow.player, Version.get() + function token.valid() + return not token.done + and licensed() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and ow.player == player + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and native(game):overworld() == ow + end + local accepted, deferred + local function finish(...) + if token.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local valid = token.valid() and ow.map and ow.map.id == destination + local callback = valid and token.callback + retire(token) + if not accepted or not callback then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local result = pack(pcall(callback, ...)) + Runtime.currentMod = previous + if not result[1] then + Runtime.reportError(owner, "world warp callback failed: " .. tostring(result[2])) + end + end + pending[game] = token + local arrival = ow.arriveWarp + if opts.arrive then + ow.arriveWarp = opts.arrive + end + local called, result, detail = pcall( + ow.startWarpTo, + ow, + destination, + x, + y, + facing, + finish, + { via = "warp", keepMusic = opts.keepMusic, fade = opts.fade } + ) + -- The native controller is a void method. Explicit false/refusal and errors + -- never count as admission; a callback fired before that result is deferred. + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if not accepted then + retire(token) + ow.arriveWarp = arrival + if not called then + return nil, "world operation failed: " .. tostring(result) + end + return nil, detail or "world operation failed" + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end + -- Synchronous API-3 edits share this owner lease and loader lifecycle. + return warp, licensed +end +return W diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua new file mode 100644 index 000000000..c21586e25 --- /dev/null +++ b/src/mods/Api3WorldEdits.lua @@ -0,0 +1,165 @@ +-- Validated API-3 object/block edits over this host's native runtime paths. +local Actions = require("src.world.WorldActions") +local Reads = require("src.mods.WorldReads") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local E = {} +local running = setmetatable({}, { __mode = "k" }) +local function integer(value, maximum) + return type(value) == "number" + and value == value + and value >= 0 + and value < math.huge + and value % 1 == 0 + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function store(value) + return value == nil or type(value) == "table" +end +local function toggle(game, ow, api, gen, req, mapId, ref, visible) + if not identifier(mapId) or not (identifier(ref) or integer(ref)) or type(visible) ~= "boolean" then + return nil, "invalid object toggle" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + local def = maps and maps[id] + if not def then + return nil, "unknown map: " .. mapId + end + local save = game.save + if not store(save.objectToggles) or not store(save.objectToggles and save.objectToggles[id]) then + return nil, "invalid object toggle store" + end + if not store(save.flags) then + return nil, "invalid flag store" + end + if gen == 1 then + -- Gen 1 permits off-map keys and resolves their visibility on map load. + return api:toggleObject(id, ref, visible) + end + if ow.map.id ~= id then + return nil, "map is not active: " .. mapId + end + local objects = ow.map.def and ow.map.def.objects + if type(objects) ~= "table" then + return nil, "map has no objects" + end + local object + for i, candidate in ipairs(objects) do + local numeric = gen == 2 and i or tonumber(candidate.localId or candidate.index) + if numeric == ref or candidate.name == ref or gen == 3 and candidate.id == ref then + object = candidate + break + end + end + if not object then + return nil, "no such object: " .. tostring(ref) + end + if type(ow.syncObjectVisibility) ~= "function" then + return nil, "object runtime unavailable" + end + local ctx = { game = game, save = save, overworld = ow, mapId = id } + if gen == 2 then + if not store(save.g2ObjectToggles) then + return nil, "invalid object toggle store" + end + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + local index = object.index + if not integer(index, 254) or index == 0 then + return nil, "invalid object definition" + end + if + not store(save.gen3Spawned) + or not store(save.gen3Spawned and save.gen3Spawned[id]) + or not store(save.gen3SessionObjects) + then + return nil, "invalid object toggle store" + end + req("src.script.Gen3Commands") + local commands = req("src.script.Commands") + local command = visible and commands.g3_show_object or commands.g3_hide_object + command(ctx, index) + end + Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) + return true +end +local function replace(game, ow, api, gen, req, bx, by, block) + if not integer(block, gen == 3 and 1023 or 255) then + return nil, "invalid block id" + end + local present, metadata = Reads.activeBlockAt(ow.map, ow.map.id, bx, by) + if present == nil then + return nil, metadata + end + local cells = gen == 3 and 1 or 2 + if ow.map.blockCells ~= cells then + return nil, "unsupported block geometry" + end + if gen ~= 3 and not (ow.map.tileset and ow.map.tileset.blocks and ow.map.tileset.blocks[block + 1]) then + return nil, "unknown block id" + end + local renderer = ow.map.renderer + if not renderer or type(renderer.rebuild) ~= "function" then + return nil, "block renderer unavailable" + end + if gen ~= 3 then + return api:replaceBlock(bx, by, block) + end + -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable + -- metatile command; it replaces the old collision override as well. + req("src.script.Gen3Commands") + req("src.script.Commands").g3_set_metatile( + { game = game, save = game.save, overworld = ow }, + bx, + by, + block, + 0 + ) + renderer:rebuild() + ow.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) + return true +end +-- Shared admission/commit guard for synchronous API-3 world mutations. +function E.ready(game, ow) + local ok, reason = Actions.ready(game, ow) + if not ok then + return nil, reason + end + if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + return true +end +function E.run(game, operation, ...) + running[game] = true + local called, value, why = pcall(operation, ...) + running[game] = nil + if not called then + error(value, 0) + end + return value, why +end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = E.ready(game, ow) + if not ok then + return nil, reason + end + local operation = name == "toggleObject" and toggle or replace + return E.run(game, operation, game, ow, api, generation, req, ...) +end +return E diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua new file mode 100644 index 000000000..2146365c6 --- /dev/null +++ b/src/mods/ApricornContent.lua @@ -0,0 +1,236 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +local Merge=require('src.mods.Merge') +local Ids=require('src.mods.UnifiedIds') +local Programs=require('src.world.Gen2ApricornPrograms') +local M={} +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local copy=Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)(ranks[owner] or 0)then owner=other end + problems[#problems+1]={owner=owner,message='apricorns: duplicate '..label..' in '..old..' and '..id} + end + for id,value in registry:each()do + local ok,r=pcall(row,loader,id,value,false) + if not ok then problems[#problems+1]={owner=registry.owners[id],message=tostring(r)} + else + if items[r.apricorn]then conflict(id,items[r.apricorn],'native apricorn item')else items[r.apricorn]=id end + if events[r.event]then conflict(id,events[r.event],'recipe event')else events[r.event]=id end + end + end + return problems +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.map_scripts==history.pool then + local pool=history.pool + if pool.apricornBalls==history.rows then + -- Undo per record, not per whole array: one external edit must not + -- keep every other unchanged recipe owned by the disabled mod alive. + local present,replacements,removed,extras={},{},{},{} + for _,value in ipairs(history.rows)do present[value]=true end + for owned,entry in pairs(history.rowOwners)do + if present[owned]then + if not equal(owned,entry.snapshot)then + if entry.before then replacements[entry.before]=owned + else extras[owned]=true end + end + elseif entry.before then removed[entry.before]=true end + end + for _,value in ipairs(history.rows)do + if not history.rowOwners[value]then + local before=type(value)=='table' and history.beforeByItem[value.apricorn] + if before and not replacements[before]then + replacements[before]=value;removed[before]=nil + else extras[value]=true end + end + end + local restored={} + for _,value in ipairs(history.beforeRows)do + if not removed[value]then restored[#restored+1]=replacements[value] or value end + end + for _,value in ipairs(history.rows)do + if extras[value]then restored[#restored+1]=value end + end + local pristine=#restored==#history.beforeRows + for i,value in ipairs(restored)do if value~=history.beforeRows[i]then pristine=false;break end end + pool.apricornBalls=pristine and history.beforeRows or restored + end + if pool.scripts==history.scripts then + for key,value in pairs(history.changed)do + if pool.scripts[key]==value and equal(value,history.snapshots[key])then + pool.scripts[key]=history.beforeScripts[key] + end + end + local same=true + for key,value in pairs(pool.scripts)do if history.beforeScripts[key]~=value then same=false;break end end + if same then + for key,value in pairs(history.beforeScripts)do if pool.scripts[key]~=value then same=false;break end end + end + if same then pool.scripts=history.beforeScripts end + end + end + if data then previous[data]=nil end + local desc,why + if loader:_generation()==2 then desc,why=Programs.describe(data)end + local state={data=data,description=desc,why=why,base={}} + if desc then + local ids=Ids.new(loader) + for _,r in ipairs(desc.base)do + local id=ids:read('items',r.apricorn) or r.apricorn + if state.base[id]then id=r.apricorn end + state.base[id]=copy(r) + end + end + mounted[loader]=state + loader.content.apricorns.base=function()return state.base end +end +function M.apply(loader,data) + if loader:_generation()~=2 or not data then return end + local registry=loader.content.apricorns + if not next(registry.ops) and not require('src.mods.Runtime').wants('apricorn.converted')then return end + local state=assert(mounted[loader]);local desc=state.description + if not desc and not next(registry.ops)then return end + assert(desc,state.why) + local pool=data.map_scripts + assert(pool and pool.scripts==desc.scripts,'apricorns: native Kurt programs changed during mod load') + local recipes,normalized,ids=ordered(loader) + -- A removed/rekeyed native recipe stops accepting new work, but an old + -- event can still deliver its original job unless an active rule claims it. + local scripts,touched=Programs.project(desc,recipes,desc.base) + local history={pool=pool,beforeRows=pool.apricornBalls,rows=copy(recipes), + beforeByItem={},rowOwners={},beforeScripts=pool.scripts,scripts=scripts,changed={},snapshots={}} + for _,value in ipairs(history.beforeRows)do history.beforeByItem[value.apricorn]=value end + for i,value in ipairs(history.rows)do + local base=state.base[ids[i]] + history.rowOwners[value]={snapshot=copy(value),before=base and history.beforeByItem[base.apricorn]} + end + for key in pairs(touched)do history.changed[key]=scripts[key];history.snapshots[key]=copy(scripts[key])end + -- Keep the folded view in actual native keys after deferred references + -- resolve. The generic post-merge reference audit reads the same view as + -- the native projection; the owner journals remain intact for rollback. + for id,value in pairs(normalized)do registry.cache[id]={value=value}end + -- All validation and IR construction precedes the first native mutation. + pool.apricornBalls=history.rows;pool.scripts=scripts + previous[data]=history +end +function M.api(loader,mod,native,generation) + local available=generation==2 + local reason=not available and 'unsupported: apricorns has no native target in this dataset' or nil + local registry=loader.content.apricorns + local function key(id) + if registry:get(id)~=nil or registry.ops[id]then return id end + local ok,item=pcall(nativeItem,loader,id) + if ok then + for other,value in registry:each()do if value.apricorn==item then return other end end + end + return id + end + local function view(value) + if not value then return nil end + local ids=Ids.new(loader);value=copy(value) + value.apricorn=ids:read('items',value.apricorn) + value.ball=ids:read('items',value.ball) + return value + end + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;return view(native:get(key(id)))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each() + return function()local id,value=nextRow();return id,view(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','apricorns: id is required');id=key(id) + local state=assert(mounted[loader]);assert(state.description,state.why) + if verb=='remove'then native:remove(id);return end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'apricorns: expected recipe record') + for k in pairs(value)do assert(registry.spec.fields[k],'apricorns: unknown field '..tostring(k))end + local payload=copy(value) + local effective=verb=='patch' and Merge.deepMerge(copy(registry:get(id) or{}),payload,'record') or payload + effective=row(loader,id,effective,true) + ordered(loader,id,effective,true) + native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua new file mode 100644 index 000000000..5daa69311 --- /dev/null +++ b/src/mods/BattleIntents.lua @@ -0,0 +1,178 @@ +-- Data-only intent validation and private cross-facade submission state. +local Intents = {} +local states = setmetatable({}, { __mode = "k" }) +local FIELDS = { + menu = { choice = true }, + move = { slot = true }, + target = { target = true }, + back = {}, + safari = { action = true }, + mimic = { index = true }, +} +local MENU = { fight = true, party = true, item = true, run = true } +local SAFARI = { ball = true, bait = true, rock = true, run = true } +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function Intents.normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id, 1, 9007199254740991) then + return nil, "intent id must be a positive safe integer" + end + if not integer(raw.revision, 1, 9007199254740991) then + return nil, "invalid battle revision" + end + local fields = type(raw.kind) == "string" and FIELDS[raw.kind] + if not fields then + return nil, "unknown battle intent" + end + for key in pairs(raw) do + if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then + return nil, "unknown battle intent field" + end + end + local out = { id = raw.id, revision = raw.revision, kind = raw.kind } + if raw.kind == "menu" then + if type(raw.choice) ~= "string" or not MENU[raw.choice] then + return nil, "unknown battle menu choice" + end + out.choice = raw.choice + elseif raw.kind == "move" then + if not integer(raw.slot, 1, 4) then + return nil, "invalid move slot" + end + out.slot = raw.slot + elseif raw.kind == "target" then + if not integer(raw.target, 0, 3) then + return nil, "target must be a battler id 0-3" + end + out.target = raw.target + elseif raw.kind == "safari" then + if type(raw.action) ~= "string" or not SAFARI[raw.action] then + return nil, "invalid safari action" + end + out.action = raw.action + elseif raw.kind == "mimic" then + if not integer(raw.index, 1, 4) then + return nil, "invalid mimic index" + end + out.index = raw.index + end + return out +end +function Intents.state(game) + local state = states[game] + if not state then + state = { epoch = 0, busy = false } + states[game] = state + end + return state +end + +local BATTLE_FLAGS = { + "waitingUI", + "dead", + "demo", + "dudeDemo", + "pokedudeDemo", + "palace", + "arena", + "spectating", + "bugContest", + "noItems", + "gen4Safari", +} +local BATTLER_FLAGS = { + "disabledSlot", + "tauntTurns", + "tormented", + "lastMove", + "mustRecharge", + "charging", + "thrashTurns", + "thrashMove", + "rageMove", + "trappingTurns", + "bideTurns", + "encoreTurns", + "encoreMove", + "bwSkyDropped", + "bwChoiceLock", + "bwImprison", + "bwHealBlock", + "bwEmbargo", +} +-- Reference identity matters as well as record values: an identical replacement +-- Pokemon, move slot, selector or covering screen must invalidate old commands. +-- refs are compared shallowly; values are copied by the snapshot owner. +function Intents.context(game, b, top, at) + local refs = { + game = game, + battle = b, + top = top, + stack = game.stack, + queue = b.queue, + current = b.current, + targets = b.targetChoices, + mimic = b.mimicCtx, + mimicMoves = b.mimicMoves, + pendingMove = b.pendingMove, + pending = b.pendingActions, + chosen = b.chosenTargets, + safari = b.safari, + } + local values = { queueLength = #(b.queue or {}), flags = {}, battlers = {} } + for _, key in ipairs(BATTLE_FLAGS) do + values.flags[key] = type(b[key]) == "table" and true or b[key] + end + values.bw = type(b.bw) == "table" + and { gravity = b.bw.field and b.bw.field.gravity, magicRoom = b.bw.field and b.bw.field.magicRoom } + or b.bw + if b.mimicCtx then + refs.mimicUser, refs.mimicTarget, refs.mimicMove = b.mimicCtx.user, b.mimicCtx.target, b.mimicCtx.moveInst + end + for i, move in ipairs(b.mimicMoves or {}) do + refs["mimicOption" .. i] = move + end + for pos = 0, 3 do + local actor = at(b, pos) + refs["b" .. pos] = actor + if actor then + refs["mon" .. pos] = actor.mon + refs["moves" .. pos] = actor.curMoves + local flags = {} + for _, key in ipairs(BATTLER_FLAGS) do + flags[key] = type(actor[key]) == "table" and true or actor[key] + end + values.battlers[pos] = flags + for slot, mv in ipairs(actor.curMoves or {}) do + refs["move" .. pos .. ":" .. slot] = mv + end + end + refs["pending" .. pos] = b.pendingActions and b.pendingActions[pos] + refs["chosen" .. pos] = b.chosenTargets and b.chosenTargets[pos] + end + for i, target in ipairs(b.targetChoices or {}) do + refs["target" .. i] = target + end + return refs, values +end +function Intents.sameRefs(a, b) + if not a or not b then + return a == b + end + for key, value in pairs(a) do + if b[key] ~= value then + return false + end + end + for key, value in pairs(b) do + if a[key] ~= value then + return false + end + end + return true +end +return Intents diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua new file mode 100644 index 000000000..1fce0a816 --- /dev/null +++ b/src/mods/BattleSnapshots.lua @@ -0,0 +1,583 @@ +-- Detached Gen 1-5 snapshots and revision-bound native command admission. +-- Snapshot building stays data-only; submit validates before native dispatch. +local Ids = require("src.mods.UnifiedIds") +local Intents = require("src.mods.BattleIntents") +local Commands = require("src.battle.BattleCommands") +local Runtime = require("src.mods.Runtime") +-- Native work requested through a facade remains engine work. The buses keep +-- their existing callback/error attribution; lazy requires must not be blamed +-- on the requesting mod by the dev-mode permission shim. +local function enginePcall(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b +end +local Snapshots = {} + +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STATS = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "string" or kind == "boolean" or kind == "number" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, v in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(v, seen) + end + end + return out +end + +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +local function stats(raw) + local out = {} + for public, native in pairs(STATS) do + out[public] = (raw or {})[native] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out +end + +local function at(battle, pos) + -- Constructors fill these aliases before enter()/syncSides populates the + -- positional arrays. A cleared alias also truthfully represents a faint. + if pos == 0 then + return battle.player + end + if pos == 1 then + return battle.enemy + end + local side = battle.sides and battle.sides[pos % 2 + 1] + return side and side.battlers and side.battlers[math.floor(pos / 2) + 1] +end + +local function activeSlot(battle) + if battle.phase == "mimicSelect" and battle.mimicCtx then + for _, pos in ipairs({ 0, 2 }) do + if at(battle, pos) == battle.mimicCtx.user then + return pos + end + end + end + if not battle.double then + return 0 + end + local pos = battle.choosingSlot + if (pos == 0 or pos == 2) and at(battle, pos) then + return pos + end + for _, id in ipairs({ 0, 2 }) do + local b = at(battle, id) + if b and not b.ally then + return id + end + end + return 0 +end + +local function moveView(move, slot, data, ids) + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] or {} + local ups = type(move) == "table" and math.min(3, math.max(0, math.floor(tonumber(move.ppUps) or 0))) or 0 + return { + slot = slot, + id = ids:read("moves", id), + name = def.name, + pp = type(move) == "table" and move.pp or nil, + maxPp = def.pp and (def.pp + ups * math.floor(def.pp / 5)) or nil, + ppUps = ups, + type = def.type, + power = def.power, + accuracy = def.accuracy, + } +end + +local function monView(raw, data, ids) + if not raw then + return nil + end + local out = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name or raw.nickname or ((data.pokemon or {})[raw.species] or {}).name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats(raw.stats), + moves = {}, + native = copy(raw), + } + for slot, move in ipairs(raw.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +local function battlerView(b, pos, party, data, ids) + if not (b and b.mon) then + return nil + end + local out = monView(b.mon, data, ids) + out.name = b.name or out.name + out.id, out.side, out.active, out.absent = pos, pos % 2 == 0 and "player" or "enemy", true, false + out.displaySpecies = ids:read("pokemon", b.species or b.mon.species) + out.battleStats, out.types, out.stages = + stats(b.curStats or b.mon.stats), copy(b.curTypes), copy(b.stages or {}) + out.ally = b.ally == true + for index, mon in ipairs(party) do + if mon == b.mon then + out.partyIndex = index + break + end + end + out.moves = {} + for slot, move in ipairs(b.curMoves or b.mon.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +-- Prompt describes the battle screen only. Covered screens and automatic +-- controllers stay locked; no attempt is made to inspect or drive their UI. +local function prompt(b, covered, actor) + if + covered + or b.result + or b.waitingUI + or b.demo + or b.pokedudeDemo + or b.palace + or b.kind == "link" + or (b.arena and b.phase == "menu") + then + return "locked" + end + if b.phase == "menu" then + if b.safari then + return (b.safari.balls or 0) > 0 and "safari" or "locked" + end + if + not (actor and actor.mon and (actor.mon.hp or 0) > 0) + or actor.ally + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + then + return "locked" + end + -- Sky Drop's controller in BW owns the menu; unknown native locks are + -- not advertised as actionable (this API never submits input). + if b.bw and actor.bwSkyDropped then + return "locked" + end + return "menu" + end + if b.phase == "moveSelect" then + return "moves" + end + if b.phase == "targetSelect" then + return "target" + end + if b.phase == "mimicSelect" then + return "mimic" + end + if + b.phase == "messages" + and b.current + and not b.current.choice + and ((b.msgWaiting and (b.msgPreWait or 0) <= 0) or (b.msgPrompt and (b.msgPromptWait or 0) <= 0)) + then + return "advance" + end + return "locked" +end + +-- Decode only glyphs already revealed by the native message clock. Unknown +-- glyphs leave the optional visible message unavailable, never reveal ahead. +local function visibleMessage(battle, covered, data) + if covered or battle.phase ~= "messages" or not battle.shown then + return nil + end + local reverse = {} + local function add(rows) + for _, row in ipairs(rows or {}) do + if type(row.code) == "number" and type(row.seq) == "string" and reverse[row.code] == nil then + reverse[row.code] = row.seq + end + end + end + add(data.font and data.font.charmap) + local pages, names = data.font and data.font.pages or {}, {} + for name in pairs(pages) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + add(type(pages[name]) == "table" and pages[name].charmap) + end + local unicode = package.loaded["src.translation.UnicodeFont"] + local chars = type(unicode) == "table" and unicode.chars or {} + local scaffold = data.font + and type(data.font.source) == "string" + and data.font.source:find("Gen2 scaffold", 1, true) + local out = {} + for i, codes in ipairs(battle.shown) do + local line = {} + for _, code in ipairs(codes) do + local seq = chars[code] or reverse[code] + if scaffold and type(code) == "number" and code >= 0 and code <= 255 then + seq = string.char(code) + end + if not seq then + return nil + end + line[#line + 1] = seq + end + out[i] = table.concat(line) + end + return out +end + +local function kind(battle) + -- The same variants as BattleState:battleKind(), without a method call. + if battle.ghost then + return "ghost" + end + if battle.safari then + return "safari" + end + if battle.demo and not battle.dudeDemo then + return "oldman" + end + return battle.kind +end + +local function build(game, battle, covered, ids) + local data, save = battle.data or game.data or {}, game.save or {} + local party, enemyParty = battle.playerParty or save.party or {}, battle.enemyParty or {} + local active = activeSlot(battle) + local actor = at(battle, active) + local out = { + kind = kind(battle), + phase = battle.phase, + double = battle.double == true, + active = active, + turn = battle.turnCount or 0, + prompt = prompt(battle, covered, actor), + message = visibleMessage(battle, covered, data), + messageSource = battle.current and battle.current.text, + safariBalls = battle.safari and battle.safari.balls, + menuIndex = battle.menuIndex, + moveIndex = battle.moveIndex, + moveSwapIndex = battle.moveSwapIndex, + result = battle.result, + party = {}, + battlers = {}, + moves = {}, + items = {}, + targets = {}, + } + local foes = 0 + local rows = {} + for pos = 0, (out.double and 3 or 1) do + local b = at(battle, pos) + local row = battlerView( + b, + pos, + pos % 2 == 0 and (b and b.ally and battle.ally and battle.ally.party or party) or enemyParty, + data, + ids + ) + rows[pos] = row + if row then + out.battlers[#out.battlers + 1] = row + if pos % 2 == 1 and (row.hp or 0) > 0 then + foes = foes + 1 + end + end + end + out.player, out.enemy = rows[active], rows[1] + out.catchable = battle.kind == "wild" + and not battle.ghost + and not battle.noCatch + and not battle.result + and foes == 1 + for slot, raw in ipairs(party) do + local row = monView(raw, data, ids) + row.slot, row.active = slot, false + for _, pos in ipairs({ 0, 2 }) do + local b = at(battle, pos) + if b and not b.ally and b.mon == raw then + row.active = true + end + end + out.party[slot] = row + end + for slot, move in ipairs(actor and (actor.curMoves or actor.mon.moves) or {}) do + local row = moveView(move, slot, data, ids) + local def = (data.moves or {})[move.id] or {} + -- The three explicit guards used by BattleState's move selector. This + -- is a local menu restriction, not a guarantee a move will execute. + row.disabled = actor.disabledSlot == slot + or (move.pp or 0) <= 0 + or (actor.tauntTurns ~= nil and (def.power or 0) == 0) + or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove) + or false + -- BW adds Choice/Imprison/Gravity/Heal Block checks through native + -- controllers. Do not report an unverified false for those moves. + if battle.bw and not row.disabled then + row.disabled = nil + end + out.moves[slot] = row + end + -- Inventory quantities, not a use-eligibility result. Keep acquisition + -- order, then append positive unlisted IDs deterministically. + local inventory, seen, remaining = save.inventory or {}, {}, {} + local function item(id) + if seen[id] then + return + end + seen[id] = true + local count = inventory[id] + if type(count) ~= "number" or count <= 0 then + return + end + local def = (data.items or {})[id] or {} + local balls = data.balls or {} + local ball = def.ball ~= nil and def.ball ~= false or balls[id] ~= nil or balls[tostring(id)] ~= nil + out.items[#out.items + 1] = { + id = ids:read("items", id), + name = def.name, + count = count, + ball = ball, + catchable = ball and out.catchable, + pocket = def.pocket, + } + end + for _, id in ipairs(save.bagOrder or {}) do + item(id) + end + for id in pairs(inventory) do + if not seen[id] then + remaining[#remaining + 1] = id + end + end + table.sort(remaining, function(a, b) + return type(a) == type(b) and a < b or type(a) < type(b) + end) + for _, id in ipairs(remaining) do + item(id) + end + if battle.phase == "targetSelect" then + for _, target in ipairs(battle.targetChoices or {}) do + for pos = 0, 3 do + if at(battle, pos) == target then + out.targets[#out.targets + 1] = pos + break + end + end + end + out.targetIndex = battle.targetIndex + end + if battle.phase == "mimicSelect" then + out.mimicMoves, out.mimicIndex = {}, battle.mimicIndex + for index, move in ipairs(battle.mimicMoves or {}) do + out.mimicMoves[index] = moveView(move, move.slot or index, data, ids) + end + end + return out +end + +function Snapshots.new(gameProvider, loader) + -- Ids keeps alias metadata on the loader, never on gameplay records. + local initial = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + initial:entries(kind) + end + local facade = {} + local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 + local lastRefs, lastControl, lastEpoch, offeredRevision, lastIntentId = nil, nil, nil, nil, 0 + local function refresh() + local game = gameProvider() + local states = game and game.stack and game.stack.states or {} + local battle, index + for i = #states, 1, -1 do + if states[i].isBattleState == true then + battle, index = states[i], i + break + end + end + if not battle then + lastGame, lastBattle, previous, lastRefs, lastControl, offeredRevision = nil, nil, nil, nil, nil, nil + return nil, nil, nil, "no battle" + end + local state = Intents.state(game) + local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) + local refs, control = Intents.context(game, battle, states[#states], at) + control = copy(control) + if + game ~= lastGame + or battle ~= lastBattle + or not equal(out, previous) + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + or state.epoch ~= lastEpoch + then + revision = revision + 1 + end + lastGame, lastBattle, previous = game, battle, out + lastRefs, lastControl, lastEpoch = refs, control, state.epoch + return game, battle, index, state + end + function facade:snapshot() + local game, battle, _, state = refresh() + if not battle then + return nil, "no battle" + end + local result = copy(previous) + result.revision = revision + if state.busy then + result.prompt = "locked" + end + offeredRevision = revision + return result + end + function facade:submit(raw) + local intent, reason = Intents.normalize(raw) + if not intent then + return nil, reason + end + if intent.id <= lastIntentId then + return nil, "replayed intent" + end + local game, battle, index, state = refresh() + if not battle then + return nil, "no battle" + end + if state.busy then + return nil, "battle action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale battle context" + end + if offeredRevision ~= revision then + return nil, "fresh battle snapshot required" + end + if battle.game ~= game then + return nil, "battle does not belong to current Game" + end + if index ~= #game.stack.states then + return nil, "battle menu is covered" + end + -- The native admission helpers are read-only. Lock during validation too, + -- so a mod callback/overridden helper cannot recursively enter submission. + local refs, control = lastRefs, lastControl + state.busy = true + local checked, refusal = enginePcall(Commands.reason, battle, intent) + if not checked or refusal then + state.busy = false + return nil, checked and refusal or "battle validation failed" + end + local fresh, currentGame, currentBattle = pcall(refresh) + if not fresh then + state.busy = false + return nil, "battle validation failed" + end + if + game ~= currentGame + or battle ~= currentBattle + or revision ~= intent.revision + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + then + state.busy = false + return nil, "stale battle context" + end + -- Consume BEFORE native code: a callback or partial native failure cannot + -- replay this ID. Both boundaries invalidate snapshots held by other mods, + -- including snapshots observed halfway through an accepted action. + lastIntentId, offeredRevision = intent.id, nil + state.epoch = state.epoch + 1 + local ok, accepted, err = enginePcall(Commands.dispatch, battle, intent) + state.epoch = state.epoch + 1 + state.busy = false + if not ok then + local printable, message = pcall(tostring, accepted) + return nil, "battle action failed; intent consumed" .. (printable and ": " .. message or "") + end + if not accepted then + return nil, err or "battle action did not complete; intent consumed" + end + return true + end + return facade +end +-- Run after construction so an optional capability provider and its other +-- surface wrappers are already installed. Standalone main has no provider. +function Snapshots.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "battle" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown battle capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end + return api +end +return Snapshots diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua new file mode 100644 index 000000000..4ce1679c5 --- /dev/null +++ b/src/mods/CallbackRecords.lua @@ -0,0 +1,731 @@ +local Api3Records = require("src.mods.Api3Records") +-- A call-local transaction. Only known record roles cross this boundary; +-- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. +local Ids = require("src.mods.UnifiedIds") +local R = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function shallow(t) + local out = {} + for k, v in pairs(t or {}) do + out[k] = v + end + return out +end +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "function" then + return nil + end + return v + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, x in pairs(v) do + out[k] = copy(x, seen) + end + return out +end +local function equal(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function integer(v, lo, hi, label) + assert(type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi, label .. " out of range") + return v +end +local status = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local short = {} +for k, v in pairs(status) do + short[v] = k +end +local stats = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} +local typeAliases = { + PSYCHIC = "PSYCHIC_TYPE", + PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", + FIGHT = "FIGHTING", + ELECTR = "ELECTRIC", +} +local function typeRead(id) + return typeAliases[id] or id +end +local monFields = { + species = true, + item = true, + nickname = true, + name = true, + level = true, + hp = true, + maxHp = true, + status = true, + isEgg = true, + stats = true, + moves = true, + native = true, +} +local idRoles = { + species = "pokemon", + fromSpecies = "pokemon", + toSpecies = "pokemon", + evolveTo = "pokemon", + moveId = "moves", + item = "items", + itemId = "items", + ball = "items", +} +local recordRoles = { + mon = "mon", + pokemon = "mon", + user = "battler", + target = "battler", + attacker = "battler", + defender = "battler", + battler = "battler", + incoming = "battler", + previous = "battler", + move = "move", + defeatedDef = "species", + ballDef = "item", + opts = "context", + alive = "party", + sent = "mon", + received = "mon", + playerAction = "action", + enemyAction = "action", +} +local function array(t, label, max) + assert(type(t) == "table", label .. " must be a list") + local n, count = #t, 0 + assert(not max or n <= max, label .. " too long") + for k in pairs(t) do + count = count + 1 + assert(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= n, label .. " must be dense") + end + assert(count == n, label .. " must be dense") +end + +function R.new(loader, name, generation, api3) + local ids = Ids.new(loader) + local portable = api3 and Api3Records or nil + local function readStatus(value) + if portable then + return portable.status(value) + end + return status[value] or value + end + local function writeStatus(value, old) + if portable then + return portable.nativeStatus(value, old) + end + assert(value == nil or value == false or short[value], "unknown status") + return short[value] + end + local byRaw, byView, entries = {}, {}, {} + local view, restore, commit, refresh + local open = true + local function read(kind, id) + return ids:read(kind, id) + end + local function native(kind, id, old) + if id == read(kind, old) then + return old + end + if id == nil or id == false then + return nil + end + assert(type(id) == "string", kind .. " ID must be a string") + local entry, why = ids:resolve(kind, id) + assert(entry, why) + -- Keep each host's existing numeric/string representation. + if type(old) == "string" then + for _, key in ipairs(entry.keys) do + if type(key) == "string" then + return key + end + end + end + return entry.key + end + local function moveDef(id) + local group = ids:resolve("moves", read("moves", id)) + return group and loader.content.moves:get(group.key) or nil + end + local function moveViews(raw) + local out = {} + for i, m in ipairs(raw or {}) do + local id = type(m) == "table" and m.id or m + local def = moveDef(id) + local ups = type(m) == "table" and (m.ppUps or 0) or 0 + out[i] = { + id = read("moves", id), + pp = type(m) == "table" and m.pp or nil, + maxPp = def and math.floor((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + return out + end + local function statView(raw) + local out = {} + for k, n in pairs(stats) do + out[k] = (raw or {})[n] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + if portable then + portable.stats(out, generation) + end + return out + end + local function fields(raw, kind) + if kind == "mon" then + return { + species = read("pokemon", raw.species), + item = read("items", raw.item), + nickname = raw.nickname, + name = portable and raw.nickname and raw.nickname ~= "" and raw.nickname or raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = readStatus(raw.status), + isEgg = raw.isEgg == true, + dvs = portable and generation ~= 3 and type(raw.dvs) == "table" and copy(raw.dvs) or nil, + shiny = portable and portable.shiny(raw, generation), + stats = statView(raw.stats), + moves = moveViews(raw.moves), + native = copy(raw), + } + elseif kind == "battler" then + return { + mon = view(raw.mon, "mon"), + id = raw.id, + side = raw.isPlayer == true and "player" or raw.isPlayer == false and "enemy" or nil, + native = copy({ name = raw.name, species = raw.species, stages = raw.stages }), + } + elseif kind == "move" or kind == "species" or kind == "item" then + local out = { native = copy(raw), name = raw.name } + local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items" + local key = raw.id or raw.index or raw.numId + if key == nil then + for id, row in loader.content[registryKind]:each() do + if row == raw then + key = id + break + end + end + end + out.id = read(registryKind, key) + if kind == "species" then + out.baseStats = statView(raw.baseStats) + out.types = {} + for i, t in ipairs(raw.types or {}) do + out.types[i] = typeRead(t) + end + out.baseExp = raw.baseExp or raw.expYield + out.catchRate = raw.catchRate + out.growthRate = raw.growthRate + elseif kind == "move" then + for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do + out[key] = raw[key] + end + out.type = typeRead(out.type) + out.category = raw.category or raw.class + out.effect = type(raw.effect) == "string" and raw.effect or nil + else + for _, key in ipairs({ "price", "description", "consumable" }) do + out[key] = raw[key] + end + end + return out + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(raw) do + out[i] = view(v, role) + end + return out + elseif kind == "trainerMon" then + local out = copy(raw) + out.species = read("pokemon", raw.species) + out.item = read("items", raw.item) + out.moves = {} + for i, id in ipairs(raw.moves or {}) do + out.moves[i] = read("moves", id) + end + out.native = copy(raw) + return out + end + local out = shallow(raw) + for k, v in pairs(raw) do + local role = recordRoles[k] + if name == "world.interacted" and k == "target" then + role = nil + end + if name == "trade.completed" and k == "evolution" then + role = "context" + end + if name == "pokemon.before_give" and k == "ctx" then + role = nil + end + if k == "status" then + out[k] = readStatus(v) + elseif k == "move" and type(v) ~= "table" then + out[k] = read("moves", v) + elseif kind == "action" and k == "id" and not raw.special then + out[k] = read("moves", v) + elseif idRoles[k] and type(v) ~= "table" then + out[k] = read(idRoles[k], v) + elseif role and type(v) == "table" then + out[k] = view(v, role) + elseif k == "learnable" and name == "pokemon.level_up" then + out[k] = {} + for i, id in ipairs(v) do + out[k][i] = read("moves", id) + end + elseif k == "applyShare" and name == "battle.exp_award" and type(v) == "function" then + out[k] = function(mon, ...) + local bound = byView[mon] + assert(bound and bound.kind == "mon", "applyShare requires a bound Pokemon") + commit() + local result = pack(pcall(v, bound.raw, ...)) + if not result[1] then + error(result[2], 0) + end + refresh() + return unpack(result, 2, result.n) + end + end + end + return out + end + view = function(raw, kind) + if type(raw) ~= "table" then + return raw + end + byRaw[raw] = byRaw[raw] or {} + if byRaw[raw][kind] then + return byRaw[raw][kind].public + end + local e = { raw = raw, kind = kind, public = {} } + byRaw[raw][kind] = e + byView[e.public] = e + entries[#entries + 1] = e + for k, v in pairs(fields(raw, kind)) do + e.public[k] = v + end + e.before = ( + kind == "action" + or kind == "gift" + or kind == "context" + or kind == "encounter" + or kind == "party" + or kind == "trainerParty" + or kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + return e.public + end + local function statChanges(public, old, baseline, ops) + assert(type(old) == "table", "native stats are missing") + assert(type(public) == "table", "stats must be a record") + for k in pairs(public) do + assert(stats[k], "unknown stat " .. tostring(k)) + end + if portable then + local common = public.special ~= baseline.special and public.special or nil + if generation == 1 then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] then + assert(common == nil or common == public[k], "conflicting special stats") + common = public[k] + end + end + elseif common ~= nil then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + assert(public[k] == baseline[k] or public[k] == common, "conflicting special stats") + end + end + for k, n in pairs(stats) do + if k ~= "special" or generation == 1 then + local value = public[k] + if common ~= nil and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = common + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + if generation == 1 and (k == "specialAttack" or k == "specialDefense") then + n = "special" + end + local target = n + ops[#ops + 1] = function() + old[target] = value + if generation > 1 and target == "spatk" and old.special ~= nil then + old.special = value + end + end + end + end + end + return + end + local specialValue + for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then + assert(not specialValue or specialValue == public[k], "conflicting special stats") + specialValue = public[k] + end + end + for k, n in pairs(stats) do + local value = public[k] + if generation == 1 and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = specialValue or baseline[k] + n = "special" + elseif specialValue and (k == "special" or k == "specialAttack") then + value = specialValue + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + ops[#ops + 1] = function() + old[n] = value + end + end + end + end + local pending, visiting + local function stage(raw, key, value) + assert(type(raw) == "table", "native record is missing") + pending[#pending + 1] = function() + raw[key] = value + end + end + local function movesNative(public, old, baseline) + array(public, "moves", 4) + local out = {} + for i, m in ipairs(public) do + assert(type(m) == "table", "move must be a record") + for k in pairs(m) do + assert(k == "id" or k == "pp" or k == "maxPp" or k == "ppUps", "unknown move field") + end + local prev = (old or {})[i] + local prior = (baseline or {})[i] or {} + assert(m.maxPp == prior.maxPp, "maxPp is read-only") + local row = type(prev) == "table" and shallow(prev) or {} + row.id = native("moves", m.id, row.id) + assert(row.id, "move ID is required") + row.ppUps = integer(m.ppUps or 0, 0, 3, "ppUps") + local def = moveDef(row.id) + local max = def and math.floor((def.pp or 0) + row.ppUps * math.floor((def.pp or 0) / 5)) or 0 + row.pp = integer(m.pp, 0, max, "pp") + out[i] = row + end + return out + end + restore = function(value, kind) + if type(value) ~= "table" then + assert( + value == nil or (kind == "encounter" and value == false), + "expected " .. tostring(kind) .. " record" + ) + return value + end + local e = byView[value] + if not e then + assert( + kind == "action" + or kind == "encounter" + or kind == "trainerMon" + or kind == "candidates" + or kind == "trainerParty", + "replacement " .. tostring(kind) .. " must be a bound record" + ) + e = { public = value, kind = kind, raw = {}, before = {} } + end + if visiting[e] then + return e.raw + end + visiting[e] = true + local raw, before = e.raw, e.before + kind = e.kind + if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then + if kind == "battler" then + assert( + value.id == before.id and value.side == before.side and equal(value.native, before.native), + "battler metadata is read-only" + ) + assert(byView[value.mon] and byView[value.mon].raw == raw.mon, "battler.mon identity is read-only") + for k in pairs(value) do + assert(k == "mon" or k == "id" or k == "side" or k == "native", "unknown battler field") + end + restore(value.mon, "mon") + else + assert(equal(value, before), kind .. " definition is read-only; use mod.content") + end + elseif kind == "mon" then + for k in pairs(value) do + assert( + monFields[k] or (portable and (k == "dvs" or k == "shiny")), + "unknown Pokemon field " .. tostring(k) + ) + end + assert(equal(value.native, before.native), "native snapshot is read-only") + assert(value.name == before.name, "name is read-only; set nickname") + if portable then + assert(equal(value.dvs, before.dvs) and value.shiny == before.shiny, "DVs and shiny are read-only") + end + for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do + local v = value[key] + if not equal(v, before[key]) then + if key == "species" or key == "item" then + v = native(key == "species" and "pokemon" or "items", v, raw[key]) + assert(key ~= "species" or v, "species is required") + elseif key == "level" then + v = integer(v, 1, 100, key) + elseif key == "hp" then + v = integer(v, 0, 65535, key) + elseif key == "maxHp" then + v = integer(v, 1, 65535, key) + elseif key == "status" then + v = writeStatus(v, raw.status) + elseif key == "nickname" then + assert(v == nil or type(v) == "string", "nickname must be a string") + elseif key == "isEgg" then + assert(type(v) == "boolean", "isEgg must be boolean") + end + if key == "maxHp" then + stage(raw.stats, "hp", v) + else + stage(raw, key, v) + end + end + end + if value.maxHp ~= before.maxHp and value.stats.hp ~= before.stats.hp then + assert(value.maxHp == value.stats.hp, "conflicting maxHp and stats.hp") + end + local limit = value.stats.hp ~= before.stats.hp and value.stats.hp or value.maxHp + if value.hp ~= before.hp or limit ~= before.maxHp then + assert( + type(value.hp) == "number" and type(value.maxHp) == "number" and value.hp <= limit, + "hp exceeds maxHp" + ) + end + if not equal(value.stats, before.stats) then + statChanges(value.stats, raw.stats, before.stats, pending) + end + if not equal(value.moves, before.moves) then + local new = movesNative(value.moves, raw.moves, before.moves) + local target = raw.moves or {} + stage(raw, "moves", target) + pending[#pending + 1] = function() + local previous = shallow(target) + for i in pairs(target) do + target[i] = nil + end + for i, row in ipairs(new) do + local slot = type(previous[i]) == "table" and previous[i] or {} + for k in pairs(slot) do + slot[k] = nil + end + for k, v in pairs(row) do + slot[k] = v + end + target[i] = slot + end + end + end + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + array(value, kind, kind ~= "candidates" and 6 or nil) + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(value) do + out[i] = restore(v, role) + end + if kind == "party" then + assert(#value == #raw, "live party membership is read-only") + for i, v in ipairs(out) do + assert(v == raw[i], "live party order is read-only") + end + else + pending[#pending + 1] = function() + for k in pairs(raw) do + raw[k] = nil + end + for i, v in ipairs(out) do + raw[i] = v + end + end + end + elseif kind == "gift" then + assert(value.ctx == before.ctx, "gift context is read-only") + for k in pairs(value) do + assert(k == "ctx" or k == "species" or k == "level" or k == "nickname", "unknown gift field") + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "level", integer(value.level, 1, 100, "level")) + assert(value.nickname == nil or type(value.nickname) == "string", "nickname must be a string") + stage(raw, "nickname", value.nickname) + elseif kind == "trainerMon" then + assert(value.native == nil or equal(value.native, before.native), "native snapshot is read-only") + local allowed = { species = true, item = true, level = true, moves = true, native = true } + for k, v in pairs(value) do + if not allowed[k] then + assert(equal(v, before[k]), "trainer metadata is read-only") + end + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "item", native("items", value.item, raw.item)) + stage(raw, "level", integer(value.level, 1, 100, "level")) + if not equal(value.moves, before.moves) then + array(value.moves, "trainer moves", 4) + local out = {} + for i, id in ipairs(value.moves) do + out[i] = assert(native("moves", id, (raw.moves or {})[i]), "move is required") + end + stage(raw, "moves", out) + end + else + if kind == "encounter" then + assert(value.species ~= nil, "encounter species is required") + if value.level ~= nil then + integer(value.level, 1, 100, "encounter level") + end + for _, k in ipairs({ "min", "max" }) do + if value[k] ~= nil then + integer(value[k], 1, 100, k) + end + end + assert( + value.level ~= nil or (value.min and value.max and value.min <= value.max), + "encounter level is required" + ) + end + local keys = shallow(before) + for k in pairs(value) do + keys[k] = true + end + for k in pairs(keys) do + local v = value[k] + local old = raw[k] + if type(v) == "table" and byView[v] then + local restored = restore(v) + if v ~= before[k] then + stage(raw, k, restored) + end + elseif k == "move" and type(v) ~= "table" and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif k == "status" and v ~= before[k] then + stage(raw, k, writeStatus(v, old)) + elseif idRoles[k] and v ~= before[k] then + stage(raw, k, native(idRoles[k], v, old)) + elseif k == "applyShare" and name == "battle.exp_award" then + assert(v == before[k], "applyShare is read-only") + elseif k == "learnable" and name == "pokemon.level_up" then + assert(equal(v, before[k]), "learnable is read-only") + elseif v ~= before[k] then + local role = recordRoles[k] + if kind == "encounter" and k == "level" then + integer(v, 1, 100, "encounter level") + end + if role and type(v) == "table" then + v = restore(v, role) + end + stage(raw, k, v) + end + end + end + return raw + end + commit = function(extra) + assert(open, "callback transaction is closed") + pending, visiting = {}, {} + for _, e in ipairs(entries) do + restore(e.public, e.kind) + end + local result + if extra then + result = extra() + end + for _, op in ipairs(pending) do + op() + end + return result + end + refresh = function() + -- Reuse public identities, but refresh after vanilla mutates native data. + for _, e in ipairs(entries) do + local nextValue = fields(e.raw, e.kind) + for k in pairs(e.public) do + e.public[k] = nil + end + for k, v in pairs(nextValue) do + e.public[k] = v + end + end + for _, e in ipairs(entries) do + e.before = ( + e.kind == "action" + or e.kind == "gift" + or e.kind == "context" + or e.kind == "encounter" + or e.kind == "party" + or e.kind == "trainerParty" + or e.kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + end + end + return { + close = function() + open = false + end, + view = view, + restore = function(v, kind) + return restore(v, kind) + end, + commit = commit, + refresh = refresh, + read = read, + native = native, + copy = copy, + } +end +return R diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua new file mode 100644 index 000000000..4a6f182f9 --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,110 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "12", "intro.oak_speech.build") +add("hooks", "2", "phone.contact_list") + +add("events", "12345", [[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt + battle.ended battle.exp_gained battle.fainted battle.move_used battle.started + battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested + flag.changed game.ready link.connected link.desync link.ended map.entered map.exited + map.reloaded mod.option_action mod.options_changed mods.loaded music.started + music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved + pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded + save.loading save.writing screen.popped screen.pushed script.ended script.started + sound.played trade.completed world.blacked_out world.block_replaced + world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +add("events", "2345", "pokemon.egg_hatched") +add("events", "2", "apricorn.converted phone.call_received radio.channel") +add("events", "12", [[ + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step +]]) +add("events", "3", "intro.birch_speech.started intro.birch_speech.finished") + +-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on +-- FireRed, and no OakSpeech on Prism. DS imports select their own intros. +local function nativeVersion(name, version) + if name:match("^intro%.birch_speech%.") then return version == "emerald" end + if name:match("^intro%.oak_speech%.") then return version ~= "prism" end + return true +end + +-- Public WorldAPI methods only; constructors, internal helpers and methods +-- on returned NPC handles are not mod.world methods. These remain callable +-- at the title screen, returning their normal "no overworld"/"no save" error. +-- Elevation readers can return "map carries no elevation" on any dataset. +local world = {} +for name in ([[ + overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc + removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt + elevationGrid npc queueScript invalidateMap +]]):gmatch("%S+") do world[name] = true end + +function Capabilities.supports(surface, name, version) + if surface ~= "content" and surface ~= "world" + and surface ~= "hooks" and surface ~= "events" then + return false, "unsupported capability surface: " .. tostring(surface) + end + if type(name) ~= "string" or name == "" then + return false, "capability name must be a non-empty string" + end + local info = GameVersion.VERSIONS[version] + if not info then return false, "unknown game version: " .. tostring(version) end + if surface == "content" then + -- Every loader builds the same API-2 registries from Schemas. This says + -- the registration API exists, not that record shapes/IDs are portable. + if Schemas.REGISTRIES[name] then return true end + elseif surface == "world" then + if world[name] then return true end + else + -- A mod may listen to another mod's namespace without that mod installed; + -- this only promises the bus supports it, never that a producer is present. + if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end + local gens = callbacks[surface][name] + if gens then + if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then + return true + end + return false, ("%s capability %s has no native call site in %s") + :format(surface, name, version) + end + end + return false, ("unknown %s capability: %s"):format(surface, name) +end + +return Capabilities diff --git a/src/mods/DecodedCallbacks.lua b/src/mods/DecodedCallbacks.lua new file mode 100644 index 000000000..c2b56f8bd --- /dev/null +++ b/src/mods/DecodedCallbacks.lua @@ -0,0 +1,312 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Selected by the host per mod; imported mod bytes and native buses stay intact. +local M = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local fields = { + setvar = { "var", "value" }, + addvar = { "var", "value" }, + subvar = { "var", "value" }, + copyvar = { "var", "src" }, + setorcopyvar = { "var", "value" }, + compare_var_to_value = { "var", "value" }, + loadword = { "dest", "value" }, + setflag = { "flag" }, + clearflag = { "flag" }, + checkflag = { "flag" }, + callstd = { "id" }, + jumpstd = { "id" }, + gotostd = { "std" }, + ["goto"] = { "target" }, + call = { "target" }, + goto_if = { "cond", "target" }, + call_if = { "cond", "target" }, + setval = { "value" }, + addval = { "value" }, + writebyte = { "value" }, + setevent = { "event" }, + clearevent = { "event" }, + checkevent = { "event" }, + scall = { "script" }, + farscall = { "script" }, + sjump = { "script" }, + farsjump = { "script" }, + iftrue = { "script" }, + iffalse = { "script" }, + ifequal = { "value", "script" }, + ifnotequal = { "value", "script" }, + ifgreater = { "value", "script" }, + ifless = { "value", "script" }, + writetext = { "text" }, + jumptext = { "text" }, + farwritetext = { "text" }, + givepoke = { "species", "level", "item", "trainer", "name", "otName" }, + giveegg = { "species", "level" }, + givemon = { "species", "level", "item" }, + sdefer = { "script" }, + stopandsjump = { "script" }, + jumptextfaceplayer = { "text" }, + farjumptext = { "text" }, + pause = { "frames" }, + setscene = { "scene" }, + setmapscene = { "group", "map", "scene" }, + turnobject = { "object", "facing" }, + applymovement = { "object", "movement" }, + applymovementlasttalked = { "movement" }, + special = { "id" }, + playmusic = { "id" }, + playsound = { "id" }, + cry = { "id" }, + getmonname = { "species", "buffer" }, + getitemname = { "item", "buffer" }, + gettrainername = { "group", "trainer", "buffer" }, + loadtrainer = { "class", "member" }, + loadwildmon = { "species", "level" }, + winlosstext = { "winText", "lossText" }, + trainertext = { "index" }, + trainerflagaction = { "action" }, + setlasttalked = { "object" }, + showemote = { "emote", "object", "frames" }, + giveitem = { "item", "quantity" }, + verbosegiveitem = { "item", "quantity" }, + addcellnum = { "phone" }, + delcellnum = { "phone" }, + checkcellnum = { "phone" }, + readvar = { "var" }, + writevar = { "var" }, + follow = { "a", "b" }, + faceobject = { "a", "b" }, + follownotexact = { "a", "b" }, + trade = { "trade" }, + describedecoration = { "decoration" }, +} +local function copy(t) + local out = {} + for k, v in pairs(t) do + out[k] = v + end + return out +end +-- Only lossless numeric byte layouts can be reconstructed from native IR. +-- Decoded pointers/text use named fields; normalized hack layouts are not bytes. +local function rawOperands(op, ir) + local version = require("src.core.GameVersion").get() + if version ~= "gold" and version ~= "silver" and version ~= "crystal" then + return {}, nil + end + local spec + for _, command in ipairs(require("src.import.Gen2ScriptOps").commandsFor(version)) do + if command[1] == op then + spec = command[2] + break + end + end + if not spec or spec:find("[^bwm]") or #spec ~= #ir - 1 then + return {}, nil + end + local out = {} + for i = 1, #spec do + local value, width = ir[i + 1], ({ b = 1, w = 2, m = 3 })[spec:sub(i, i)] + if type(value) ~= "number" or value % 1 ~= 0 or value < 0 or value >= 256 ^ width then + return {}, nil + end + for _ = 1, width do + out[#out + 1] = value % 256 + value = math.floor(value / 256) + end + end + return out, spec +end +local function context(ctx) + local world, map = ctx.overworld, ctx.overworld and ctx.overworld.map + if not map then + return ctx + end + local data = ctx.game and ctx.game.data + local maps = data and data.maps + local version = require("src.core.GameVersion").get() + local id = map.id + if maps and (version == "firered" or version == "leafgreen") then + local def = maps[map.id] + local verified = def and map.def == def and def.group == 4 and def.number == 3 + if verified then + for _, other in pairs(maps) do + if type(other) == "table" and other ~= def and other.group == 4 and other.number == 3 then + verified = false + break + end + end + end + -- One explicit callback alias, qualified against this world's native + -- definition. Similar spellings or an unbound slot are not evidence. + if verified then + id = "OAKS_LAB" + end + end + local publicMap = setmetatable({ id = id }, { __index = map }) + local publicWorld = setmetatable({ map = publicMap }, { + __index = world, + __newindex = function(_, k, v) + world[k] = v + end, + }) + return setmetatable({ overworld = publicWorld }, { + __index = ctx, + __newindex = function(_, k, v) + ctx[k] = v + end, + }) +end +function M.install(loader, api, owner, generation) + if generation ~= 2 and generation ~= 3 then + return + end + local aliases = copy(fields) + if generation == 3 then + aliases.callstd = { "std" } + aliases.special = { "id" } + aliases.applymovement = { "localId", "movement" } + end + local wrap, on, once = api.hooks.wrap, api.events.on, api.events.once + local ids = require("src.mods.UnifiedIds").new(loader) + ids:entries("pokemon") + api.hooks.wrap = function(self, name, callback, priority) + if name ~= "script.command" then + return wrap(self, name, callback, priority) + end + return loader._decodedHooks:wrap(name, function(nextFn, ctx, op, ir) + local row = { op = op } + for i = 2, #ir do + row[i - 1] = ir[i] + end + for i, field in ipairs(aliases[op] or {}) do + row[field] = row[i] + end + local original = copy(row) + local public = context(ctx) + local args, byteSpec = {}, nil + if generation == 2 then + args, byteSpec = rawOperands(op, ir) + row.args = args + end + local originalArgs = copy(args) + local active = true + local function nextPublic(hctx, hname, hrow, hcmd) + assert(active, "decoded next is only valid during its callback") + assert(hctx == nil or hctx == public, "decoded next must retain its script context") + local edited = generation == 2 and (hcmd or row) or (hrow or row) + local raw = copy(ir) + for i = 1, math.max(#row, #edited) do + raw[i + 1] = edited[i] + end + for i, field in ipairs(aliases[op] or {}) do + if edited[field] ~= original[field] then + raw[i + 1] = edited[field] + end + end + if generation == 2 then + local bytes = hrow or edited.args or args + local changed = #bytes ~= #originalArgs + for k, v in pairs(bytes) do + if originalArgs[k] ~= v then + changed = true + end + end + if changed then + assert( + byteSpec, + "raw Gen-2 byte rewrite is unavailable for this normalized command; use decoded fields" + ) + assert(#bytes == #originalArgs, "Gen-2 operand byte width changed") + local at = 1 + for i = 1, #byteSpec do + local value, width = 0, ({ b = 1, w = 2, m = 3 })[byteSpec:sub(i, i)] + for n = 0, width - 1 do + local byte = bytes[at] + at = at + 1 + assert( + type(byte) == "number" and byte % 1 == 0 and byte >= 0 and byte < 256, + "invalid Gen-2 operand byte" + ) + value = value + byte * 256 ^ n + end + raw[i + 1] = value + end + end + end + raw[1] = hname or edited.op or op + return nextFn(ctx, raw[1], raw) + end + local result + if generation == 2 then + result = pack(pcall(callback, nextPublic, public, op, args, row)) + else + result = pack(pcall(callback, nextPublic, public, op, row)) + end + active = false + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, priority, owner) + end + local function subscribe(register, self, name, callback, priority) + if name ~= "pokemon.before_give" then + return register(self, name, callback, priority) + end + return register(self, name, function(gift) + local view = copy(gift) + view.ctx = context(gift.ctx) + view.species = ids:read("pokemon", gift.species) + local order = gift.ctx.game and gift.ctx.game.data.constants and gift.ctx.game.data.constants.speciesOrder + or {} + local function numberFor(species) + for number, id in pairs(order) do + if id == species then + return number + end + end + end + view.speciesId = numberFor(gift.species) + local before = view.species + callback(view) + local species = gift.species + if view.species ~= before then + if type(view.species) == "number" then + species = assert(order[view.species], "unknown decoded gift species number") + else + species = assert(ids:resolve("pokemon", view.species)).key + end + end + local level = tonumber(view.level) + level = level and math.max(1, math.min(100, math.floor(level))) or gift.level + local nickname = type(view.nickname) == "string" and view.nickname ~= "" and view.nickname or gift.nickname + -- A variable-backed Gen-3 gift keeps the requested species in that same + -- native variable for the rest of the starter/story script. + local ctx, number = gift.ctx, numberFor(species) + local command = ctx.script and ctx.script[ctx.pc] + if + generation == 3 + and species ~= gift.species + and number + and command + and command[1] == "g3_give_pokemon" + and type(command[2]) == "number" + and command[2] >= 0x4000 + then + ctx.save.gen3Vars = ctx.save.gen3Vars or {} + ctx.save.gen3Vars[command[2]] = number + end + gift.species, gift.level, gift.nickname = species, level, nickname + end, priority) + end + api.events.on = function(self, ...) + return subscribe(on, self, ...) + end + api.events.once = function(self, ...) + return subscribe(once, self, ...) + end +end +return M diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua new file mode 100644 index 000000000..059e0f790 --- /dev/null +++ b/src/mods/DecorationContent.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- A DECO byte is identity, not the sorted position of a mod record. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Decorations') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table'or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function byte(id) + local digits=type(id)=='string'and id:match('^deco:(%d+)$') + local n=digits and tonumber(digits) + assert(n and n>=0 and n<=255 and 'deco:'..n==id,'decorations: expected canonical deco: ID') + return n +end +local function finite(value,seen) + if type(value)=='number'then assert(value==value and math.abs(value)=0 and n<=255 and type(row)=='table'then + state.base['deco:'..n]=copy(data.map_scripts.decorations[n]or row) + end + end + end + mounted[loader]=state + loader.content.decorations.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.decorations;local state=mounted[loader] + if not(state and state.available)or not next(registry.ops)then return end + local pool=data.map_scripts;local before=pool.decorations;local out={} + for key,row in pairs(before)do if tonumber(key)==nil then out[key]=row end end + for id,value in registry:each()do out[byte(id)]=registry.ops[id]and record(registry,id,value)or copy(value)end + local changed={};local keys={} + for key in pairs(before)do keys[key]=true end;for key in pairs(out)do keys[key]=true end + for key in pairs(keys)do + if before[key]~=out[key]then changed[key]={before=before[key],value=out[key],snapshot=copy(out[key])}end + end + pool.decorations=out;previous[data]={pool=pool,before=before,after=out,changed=changed} +end +function M.api(loader,mod,native,generation) + local state=mounted[loader] + local available=generation==2 and state and state.available or false + local reason=not available and 'unsupported: decorations requires an imported native GSC attribute table'or nil + local registry=loader.content.decorations + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;byte(id);return copy(native:get(id))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each();return function()local id,value=nextRow();return id,copy(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + byte(id) + if verb=='remove'then return native:remove(id)end + finite(value);assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') + for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end + local effective=verb=='patch'and Merge.deepMerge(copy(registry:get(id)or{}),copy(value),'record')or value + effective=record(registry,id,effective);native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua new file mode 100644 index 000000000..fd427e7db --- /dev/null +++ b/src/mods/Gen1Compat.lua @@ -0,0 +1,33 @@ +-- Compatibility spellings, kept separate from the loader's execution policy. +-- Only interchangeable native objects belong in ALIASES. Unsupported modules +-- fail at import with an actionable diagnostic instead of a misleading facade. +local C = {} +C.ALIASES = { + ['src.world.gen2.WorldAPI'] = 'src.world.WorldAPI', + ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', +} +C.UNAVAILABLE = { + ['src.import.pmd.Sprite'] = { 'mystery_dungeon_follower' }, + ['src.world.game3.Follower'] = { 'mystery_dungeon_follower' }, + ['src.core.gen2.Unown'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, +} +-- Concrete host modules and registered providers always take precedence. +function C.hasProvider(name) + if type(name) ~= 'string' then return false end + if type(package.loaded[name]) == 'table' or type(package.preload[name]) == 'function' then return true end + if package.searchpath then + if package.searchpath(name, package.path or '') or package.searchpath(name, package.cpath or '') then return true end + end + local path = name:gsub('%.', '/') .. '.lua' + return love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo(path, 'file') ~= nil or false +end +function C.unavailable(name) + local consumers = C.UNAVAILABLE[name] + if not consumers or C.hasProvider(name) then return nil end + return ('Compatibility module %s is unavailable in this engine; affected mods: %s') + :format(name, table.concat(consumers, ', ')) +end +return C diff --git a/src/mods/LandmarkContent.lua b/src/mods/LandmarkContent.lua new file mode 100644 index 000000000..eb18aa634 --- /dev/null +++ b/src/mods/LandmarkContent.lua @@ -0,0 +1,135 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Semantic landmark records project into the native town-map number space. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Landmarks') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local previous=setmetatable({},{__mode='k'}) +local mounted=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)=0 and n<=255 and n or nil +end +local function record(registry,id,value) + assert(type(value)=='table' and value~=Merge.DELETE,'landmarks: expected record') + finite(value) + for key in pairs(value)do assert(registry.spec.fields[key],'landmarks: unknown field '..tostring(key))end + local ok,why=Schemas.check(registry.spec,'landmarks',id,value,'register');assert(ok,why) + assert(index(value.index),'landmarks: native index must be a byte') + return copy(value) +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.field==history.field and history.field.townMap==history.town and + history.town.landmarks==history.after then + local rows=history.after + for key,entry in pairs(history.changed)do + if rows[key]==entry.value and equal(rows[key],entry.snapshot)then rows[key]=entry.before end + end + local pristine=true + for key,value in pairs(rows)do if history.before[key]~=value then pristine=false;break end end + if pristine then + for key,value in pairs(history.before)do if rows[key]~=value then pristine=false;break end end + end + if pristine then + if history.createdRows then history.town.landmarks=nil else history.town.landmarks=history.before end + end + if history.createdTown and not next(history.town)then history.field.townMap=nil end + if history.createdField and not next(history.field)then data.field=nil end + end + if data then previous[data]=nil end + local state={base={},order=Native.dataOrder(data)} + if loader:_generation()==2 then + local town=Native.town(data);local rows=town and town.landmarks or{} + local indices={} + for key in pairs(rows)do local n=index(key);if n then indices[n]=true end end + local ordered={};for n in pairs(indices)do ordered[#ordered+1]=n end;table.sort(ordered) + for _,n in ipairs(ordered)do + local native=rows[n] or rows[tostring(n)] + if type(native)=='table'then + local id=native.landmarkId or native.id or state.order[n+1] or 'landmark:'..n + if type(id)~='string' or id=='' or state.base[id]then id='landmark:'..n end + state.base[id]={id=native.id,name=native.name,x=native.x,y=native.y,index=n} + end + end + end + mounted[loader]=state + loader.content.landmarks.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.landmarks + if loader:_generation()~=2 or not data or not next(registry.ops)then return end + local state=assert(mounted[loader]);local values,chosen={},{} + for id,value in registry:each()do + value=registry.ops[id] and record(registry,id,value) or copy(value) + values[id]=value + local n=index(value.index) + assert(n,'landmarks: native index must be a byte') + local old=chosen[n] + -- Match the reference's index arbitration: a native identity keeps its + -- original slot; newcomers use lexical ID order, independent of pairs(). + if not old or state.order[n+1]==id or state.order[n+1]~=old and id= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +761,23 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "radio_channels" then + return require("src.mods.RadioContent").api(loader,mod,api,loader:_generation()) + end + if registry.name == "phone_contacts" then + return require("src.mods.PhoneContent").api(loader,mod,api,loader:_generation()) + end + if registry.name == "apricorns" then + return require("src.mods.ApricornContent").api(loader,mod,api,loader:_generation()) + end + if registry.name == "landmarks" then + return require("src.mods.LandmarkContent").api(loader,mod,api,loader:_generation()) + end + if registry.name=="decorations"then + return require("src.mods.DecorationContent").api(loader,mod,api,loader:_generation()) + end + return api + end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -831,7 +859,7 @@ function Loader:_api(mod) -- new game and a new load. local hostGen, hostId, hostInfo = 1, nil, nil do - local okV, GameVersion = pcall(require, "src.core.GameVersion") + local okV, GameVersion = pcall(engineRequire, "src.core.GameVersion") if okV and GameVersion then local okG, g = pcall(GameVersion.generation) if okG and tonumber(g) then hostGen = tonumber(g) end @@ -841,9 +869,11 @@ function Loader:_api(mod) if okN then hostInfo = info end end end + -- Capture pristine ID aliases before mod merges; the facade resolves Game only on a read. + local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") engineVersion = okE and V and V.engine or nil end local api = { @@ -868,6 +898,7 @@ function Loader:_api(mod) -- returns the same module table the engine holds, so a mod that mutates -- one is changing the engine, exactly as it always was. engineRequire = engineRequire, + battle = battle, -- 1 to 5. The one field worth reaching for before anything else. generation = hostGen, -- the version id the launcher started: "red", "crystal", "emerald", @@ -884,13 +915,14 @@ function Loader:_api(mod) label = hostInfo and hostInfo.label or hostId, engine = engineVersion, modApi = (function() - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") return okE and V and V.modApi or nil end)(), developer = loader.dev == true, }, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), + callbackFormat = mod.manifest.callback_format or "native", content = {}, exports = {}, DELETE = Registry.DELETE, @@ -914,9 +946,9 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, - -- the widget toolkit facade (12 4.5) is one shared surface, not - -- per-mod state; each widget inside it loads on first touch - ui = ModUI, + -- Per-mod action/revision state, with the existing lazy widget builders. + -- Reading the facade early must not construct Game or a native screen. + ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -999,6 +1031,16 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + local callbackProfile = self._callbackProfiles and self._callbackProfiles[modId] + assert(callbackProfile == nil or callbackProfile == "native" or callbackProfile == "decoded", + "unknown callback profile for " .. modId) + if callbackProfile == "decoded" then + assert(api.callbackFormat == "native", "decoded profile cannot be combined with unified callbacks") + engineRequire("src.mods.DecodedCallbacks").install(loader, api, modId, hostGen) + end + if api.callbackFormat == "unified" then + engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) + end -- a handle, not the mod object: {id, version, exports} or nil when the -- other mod is absent, disabled, failed, or has not run yet. Tolerates -- mod:find(id) as well as the documented mod.find(id). @@ -1020,6 +1062,10 @@ function Loader:_api(mod) api.content[alias] = self:_contentApi(mod, self.content[canonical], ("the %s registry is deprecated; use %s"):format(alias, canonical)) end + api.contentFormat = mod.manifest.content_format or "native" + if api.contentFormat == "unified" then + api.content = engineRequire("src.mods.UnifiedContent").new(loader, mod, api.content, hostGen) + end -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve -- A RELATIVE PATH STAYS RELATIVE. @@ -1119,13 +1165,41 @@ function Loader:_api(mod) end return data end) + -- Capture the host selected for this API, as with generation/gameVersion. + -- Probing must not instantiate mod.world/mod.game or register callbacks. + function api:supports(surface, name) + return Capabilities.supports(surface, name, hostId) + end -- mod.world / mod.game / mod.storage all materialize on first touch, for - -- the same reason: a headless load must not drag the world stack in, and - -- the Game the facade acts on is still being wired when the entry chunk + -- the same reason: a headless load must not drag the world stack in. + -- Battle capability queries can also be installed before Game exists. + BattleSnapshots.withCapabilities(api) + UIActions.withCapabilities(api) + -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. + -- When capability queries are installed, these additive methods exist on + -- every host; individual actions still validate native version and state. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldActions.supports(name) then return true end + return supports(self, surface, name) + end + end local world, storage - setmetatable(api, { __index = function(_, key) + local overviewContext = WorldOverview.install(api, loader) + local readContext = WorldReads.context(loader) + -- Capability queries are optional on older API-2 hosts. When present, + -- report the additive readers without constructing Game or the world. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldReads.supports(name) then return true end + return supports(self, surface, name) + end + end + setmetatable(api, PortableScripts.wrap({ __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then -- Do not pass loader.fs when it is love.filesystem: that always writes @@ -1139,17 +1213,41 @@ function Loader:_api(mod) local game = loader:_game() local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end - world = module.new(game, modId) + world = module.new(game, modId, readContext) + world._npcAlive = function() + return loader.mods[modId] == mod and loader.apis[modId] == api + and loader.exports[modId] ~= nil and isActive(mod) + end + world.overviewContext = overviewContext return world - end }) + end }, api, loader, mod.manifest)) + -- When capability queries are installed, report the selected content + -- format's actual availability. Other surfaces retain their host query. + if api.contentFormat == "unified" and api.supports then + local supports, content = api.supports, api.content + api.supports = function(self, surface, name) + local handle = type(name) == "string" and content[name] + if surface == "content" and handle and handle.supports then return handle:supports() end + return supports(self, surface, name) + end + end + if (mod.manifest.api or 1) >= 3 then + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire) + end return api end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. -function Loader:_game() - return self.game or engineRequire("src.core.Game") +function Loader:_game(loadedOnly) + if self.game then return self.game end + -- Observational facades must not bootstrap Game from an early/headless read. + if loadedOnly then + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil + end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) @@ -1196,11 +1294,15 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3NPC.cancelAll(self, modId) + Api3Warp.cancelAll(self, modId) + PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end self.events:removeOwner(modId) self.hooks:removeOwner(modId) + self._decodedHooks:removeOwner(modId) self.exports[modId] = nil self.optionSchemas[modId] = nil self.optionStatus[modId] = nil @@ -1310,12 +1412,16 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3NPC.cancelAll() + Api3Warp.cancelAll() + PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.compat.gen2.Decorations").useRegistry(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1326,8 +1432,13 @@ function Loader:load(data) end end end + require("src.mods.RadioContent").prepare(self, data) + require("src.mods.ApricornContent").prepare(self, data) + require("src.mods.LandmarkContent").prepare(self, data) + require("src.mods.DecorationContent").prepare(self,data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override + require("src.mods.PhoneContent").prepare(self, data) require("src.mods.Builtins").install(self.content, data) self:_loadState() self:_discover() @@ -1450,6 +1561,7 @@ function Loader:load(data) end) -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on + self.hooks._decodedHooks = self._decodedHooks Runtime.install(self.events, self.hooks, self.errors) self:_validate() local ordered = self:_resolve() @@ -1490,6 +1602,27 @@ function Loader:load(data) -- the commands registry is final once every entry chunk has run, so -- each map_scripts contribution's rows can be judged before they merge self:_validateScripts() + -- Resolve recipe/item forward references against all surviving entries, + -- before any native script projection or data merge. Repeat after owner + -- rollback so another recipe cannot retain an item supplied by a failed mod. + for _=1,#self.order+1 do + local problems=require("src.mods.ApricornContent").validate(self) + local failed=false + for _,problem in ipairs(problems)do + local mod=problem.owner and self.mods[problem.owner] + if mod and not mod.failed then self:_fail(mod,"failed",problem.message);failed=true end + end + if not failed then break end + self:_enforceDependencies() + for i=#self.loaded,1,-1 do + local mod=self.loaded[i] + if mod.failed then self:_rollback(mod.manifest.id);table.remove(self.loaded,i)end + end + for i=#self.order,1,-1 do + local mod=self.mods[self.order[i]] + if mod and mod.failed then table.remove(self.order,i)end + end + end -- merge: fold every touched id from its pristine base value and write it -- home, creating the Data namespace when the base modules never shipped -- one. A registry nobody wrote to -- engine included -- is skipped, so @@ -1551,6 +1684,10 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.PhoneContent").apply(self, data) + require("src.mods.ApricornContent").apply(self, data) + require("src.mods.LandmarkContent").apply(self, data) + require("src.mods.DecorationContent").apply(self,data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then @@ -1653,6 +1790,10 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3NPC.cancelAll() + Api3Warp.cancelAll() + PortableScripts.cancelAll(nil, nil, "mods unloaded") + require("src.compat.gen2.Decorations").useRegistry(nil) local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..3267b2c54 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,16 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -240,6 +250,10 @@ function Manifest.validate(raw, path) end end + local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format + assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") + assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -279,6 +293,10 @@ function Manifest.validate(raw, path) return value end + assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified", + "callback_format must be native or unified") + assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2") + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a @@ -305,6 +323,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, + content_format = raw.content_format or defaultFormat, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), @@ -316,6 +335,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + callback_format = raw.callback_format or defaultFormat, github = github, support = support, updateCheck = updateCheck, @@ -325,6 +345,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/PhoneContent.lua b/src/mods/PhoneContent.lua new file mode 100644 index 000000000..b1a6372d5 --- /dev/null +++ b/src/mods/PhoneContent.lua @@ -0,0 +1,210 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +-- Semantic contacts project onto the native numeric book, never onto saves. +local Merge = require('src.mods.Merge') +local M = {} +local previous = setmetatable({}, {__mode='k'}) +local mounted = setmetatable({}, {__mode='k'}) +local copy = Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table' then return false end + for k,v in pairs(a) do if not equal(v,b[k]) then return false end end + for k in pairs(b) do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number' then assert(v==v and math.abs(v)=1 and index<=255,'phone_contacts: invalid or exhausted index') + assert(not used[index] or used[index]==id,'phone_contacts: index collision') + assert(old or index>state.floor,'phone_contacts: native rows and holes are reserved') + return index +end +local function nativeRow(loader,id,row) + local state=assert(mounted[loader]);local data=state.data + local old=state.native[id] or {} + local out=copy(old) + out.name=row.name;out.contactNumber=row.number;out.rematchEvent=row.rematchEvent + if row.class or row.member then + assert(row.class and row.member,'phone_contacts: class and member are required together') + assert(row.number==nil,'phone_contacts: trainer and non-trainer identities are exclusive') + local c=data.constants or {} + out.class=assert(position(c.trainerClassOrder,row.class),'phone_contacts: unknown trainer class') + out.trainer=assert(position((c.trainerClassMembers or {})[row.class],row.member),'phone_contacts: unknown trainer member') + out.contactNumber=nil + elseif not (old.class and state.base[id] and not state.base[id].class) then + out.class=nil;out.trainer=nil + end + if row.map then + local def=loader.content.maps:get(row.map) + assert(type(def)=='table' and type(def.group)=='number' and type(def.number)=='number', + 'phone_contacts: map requires native group/number metadata') + out.group,out.number=def.group,def.number + elseif state.base[id] and state.base[id].map then + out.group,out.number=nil,nil + end + out.call=row.calleeKey or row.callee + out.receive=row.callerKey or row.caller + out.callTime,out.receiveTime=row.calleeTime or 0,row.callerTime or 0 + for _,field in ipairs({'call','receive'}) do + local key=out[field];local mask=out[field..'Time'] + assert(mask==0 or type(key)=='string' and key~='','phone_contacts: enabled direction needs a script key') + -- This version delegates existing imported scripts to the real native VM. + -- It does not interpret Gen1 bytecode or fabricate missing script labels. + if key then assert(data.map_scripts and data.map_scripts.scripts and data.map_scripts.scripts[key], + 'phone_contacts: script key is not in the native script pool: '..tostring(key)) end + end + assert(type(row.name)=='string' and row.name~='' or out.class, + 'phone_contacts: contact needs a name or trainer identity') + return out +end +function M.apply(loader,data) + local registry=loader.content.phone_contacts + if loader:_generation()~=2 or not data or not next(registry.ops) then return end + local state=assert(mounted[loader]);local plan={} + for _,id in ipairs(registry.order) do + if registry.ops[id] then + local row=registry:get(id);local old=state.base[id] + local index=row and row.index or old and old.index + if index then plan[#plan+1]={index=index,value=row and nativeRow(loader,id,row)} end + end + end + -- Validate every conversion before the first native write. + local createdPool=data.map_scripts==nil + data.map_scripts=data.map_scripts or {} + local createdPhone=data.map_scripts.phone==nil + data.map_scripts.phone=data.map_scripts.phone or {} + local target=data.map_scripts.phone;local undo={createdPool=createdPool,createdPhone=createdPhone,pool=data.map_scripts,target=target} + for _,entry in ipairs(plan) do + undo[#undo+1]={target=target,index=entry.index,before=target[entry.index], + value=entry.value,snapshot=copy(entry.value)} + target[entry.index]=entry.value + end + previous[data]=undo +end +function M.api(loader,mod,native,generation) + local registry=loader.content.phone_contacts;local available=generation==2 + local reason=not available and 'unsupported: phone_contacts has no native target in this dataset' or nil + local out={available=available,supports=function() return available,reason end} + local function view(row) + row=copy(row) + if row then row.callee=row.calleeKey or row.callee;row.caller=row.callerKey or row.caller end + return row + end + function out:get(id) if not available then return nil,reason end;return view(native:get(id)) end + function out:has(id) return self:get(id)~=nil end + function out:each() + if not available then return function() end end + local nextRow=native:each() + return function() local id,row=nextRow();return id,view(row) end + end + for _,verb in ipairs({'register','override','patch','remove'}) do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','phone_contacts: id is required') + if verb=='remove' then native:remove(id);return nil end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'phone_contacts: expected record') + for key in pairs(value) do assert(registry.spec.fields[key],'phone_contacts: unknown field '..tostring(key)) end + local ok,err=require('src.mods.Schemas').check(registry.spec,'phone_contacts',id,value,verb) + assert(ok,err) + local payload=copy(value) + for _,direction in ipairs({'callee','caller'}) do + if payload[direction]~=nil then + local key=direction..'Key' + if payload[key]==nil then payload[key]=payload[direction] end + payload[direction]=nil + end + end + payload.index=indexFor(registry,assert(mounted[loader]),id,value) + local effective=Merge.deepMerge(verb=='patch' and copy(registry:get(id) or {}) or {},payload,'record') + nativeRow(loader,id,effective) + if verb~='patch' then payload=effective end + native[verb](native,id,payload) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..bfa6c6181 --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,543 @@ +-- Per-mod portable dialogue programs. An isolated native runner owns the +-- foreground until completion, then returns the idle runner it borrowed. +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Scripts = {} +local runs = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local contexts = setmetatable({}, { __mode = "k" }) +local draining = false +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(fn, ...)) + Runtime.currentMod = owner + return unpack(result, 1, result.n) +end +local function current(loader) + if loader.game then + return loader.game + end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil +end +local function message(err) + local ok, text = pcall(tostring, err) + return ok and text or "unprintable error" +end +local function contains(stack, state) + if type(stack.states) ~= "table" then + return false + end + for _, value in ipairs(stack.states) do + if value == state then + return true + end + end + return false +end +local function remove(stack, state) + if type(stack.states) ~= "table" then + return + end + for i = #stack.states, 1, -1 do + if stack.states[i] == state then + table.remove(stack.states, i) + if Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end + return + end + end +end +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", +} +local function ready(game) + if not (game and game.save) then + return nil, "no save" + end + local stack = game.stack + if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then + return nil, "no overworld" + end + local ow = stack:top() + if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then + return nil, "world is busy" + end + if + runs[game] + or game.overworld and game.overworld ~= ow + or game.linkSession + or game.linkNet and not game.linkNet.closed + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + if ow.player[key] then + return nil, "world is busy" + end + end + if + ow.runner:isRunning() + or #(ow.scriptMoves or {}) > 0 + or #(ow.pendingScripts or {}) > 0 + or #(ow.parallelRunners or {}) > 0 + or #(ow.parallelQueue or {}) > 0 + then + return nil, "world is busy" + end + return ow +end +local function observe(loader) + local installed = observers[loader] + if installed and installed[1] == loader.hooks then + return + end + -- Weak values too: the hook closure captures its loader, so retaining the + -- hooks here would otherwise keep every unloaded loader alive on LuaJIT. + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Keep one observer per loader: retiring a hook while its chain dispatches + -- would shift the following mod's index. Per-run cleanup never edits it. + loader.hooks:wrap("input.step", function(nextFn, game, dt) + if next(runs) == nil then + return nextFn(game, dt) + end + local pending = {} + for _, token in pairs(runs) do + if token.loader == loader then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + local ok, live, why = engine(token.valid) + if not ok or not live then + token.finish(false, ok and why or "script context failed") + elseif token.waitingUI and not contains(token.stack, token.waitingUI) then + token.finish(false, "script UI was removed") + end + end + return nextFn(game, dt) + end, math.huge, "engine:portable_scripts") +end +function Scripts.cancelAll(loader, modId, reason) + if loader and modId then + if contexts[loader] then + contexts[loader].owners[modId] = nil + end + elseif loader then + contexts[loader] = nil + else + for owner in pairs(contexts) do + contexts[owner] = nil + end + end + local wasDraining = draining + draining = true + local pending = {} + for _, token in pairs(runs) do + if (not loader or token.loader == loader) and (not modId or token.modId == modId) then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + token.finish(false, reason or "script session ended") + end + draining = wasDraining +end +local function facade(loader, modId, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[modId] = lease + local function licensed() + return not draining and contexts[loader] == context and context.owners[modId] == lease + end + -- Install before entry code can subscribe/queue from input.step. Sorting + -- a new observer into a live hook chain could replay its current wrapper. + observe(loader) + local world, active = {}, nil + local function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile(rows) + if not program then + return nil, why + end + if options ~= nil then + if type(options) ~= "table" or getmetatable(options) ~= nil then + return nil, "options must be a plain table" + end + for key, value in pairs(options) do + if key ~= "onDone" or type(value) ~= "function" then + return nil, "only onDone callback is supported" + end + end + end + local onDone = options and options.onDone + local game = current(loader) + local ow + ow, why = ready(game) + if not ow then + return nil, why + end + local Runner = require("src.script.ScriptRunner") + local runner = Runner.new(game, ow) + local token = { + loader = loader, + modId = modId, + game = game, + world = ow, + runner = runner, + original = ow.runner, + stack = game.stack, + states = game.stack.states, + mapId = ow.map.id, + x = ow.player.cellX, + y = ow.player.cellY, + save = game.save, + data = game.data, + map = ow.map, + player = ow.player, + version = GameVersion.get(), + ui = {}, + steps = 0, + } + function token.valid() + if token.done then + return nil, "script is finished" + end + if + not licensed() + or current(loader) ~= game + or game.save ~= token.save + or game.data ~= token.data + or game.stack ~= token.stack + or game.stack.states ~= token.states + or not contains(token.stack, ow) + or game.overworld and game.overworld ~= ow + or ow.map ~= token.map + or ow.map.id ~= token.mapId + or ow.player ~= token.player + or ow.player.cellX ~= token.x + or ow.player.cellY ~= token.y + or ow.runner ~= runner + or GameVersion.get() ~= token.version + or Runtime.hooks ~= loader.hooks + or Runtime.events ~= loader.events + or game.linkSession + or game.linkNet and not game.linkNet.closed + or ow.transitioning + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil + -- Remove only this run's exact native screens, preserving foreign + -- overlays and a replacement world's runner/locks. + for i = #token.ui, 1, -1 do + remove(token.stack, token.ui[i]) + end + if ow.runner == runner then + ow.runner = token.original + end + if runs[game] == token then + runs[game] = nil + end + if active == token then + active = nil + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok }) + end + if onDone then + local called, err = pcall(onDone, ok, reason) + if not called then + Runtime.reportError(modId, "portable script completion failed: " .. message(err)) + end + end + end + local function check() + local valid, reason = token.valid() + if not valid then + token.finish(false, reason) + end + return valid + end + local function owned(state, step) + token.ui[#token.ui + 1] = state + local update = state.update + function state:update(...) + if not check() or token.step ~= step or game.stack:top() ~= self then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + return state + end + local function dialog(ctx, text, asking, step) + local TextBox = require("src.render.TextBox") + local box, answered + local function answer(value) + if answered or token.step ~= step or not check() then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck = value + runner:resume() + end + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end) + if asking then + local update, pushed, selected = box.update, false, nil + box.choice = function(yes) + if token.step ~= step or not check() or not selected or contains(token.stack, selected) then + return + end + remove(token.stack, box) + answer(yes) + end + function box:update(dt) + if self.done and (self.holdFrames or 0) <= 0 and not pushed then + pushed, self.choicePushed = true, true + local Choice = require("src.ui.ChoiceBox") + local geometry = self.choiceBox or {} + selected = owned( + Choice.new( + game, + self.choice, + { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th } + ), + step + ) + token.waitingUI = selected + game.stack:push(selected) + else + update(self, dt) + end + end + end + owned(box, step) + token.waitingUI = box + game.stack:push(box) + runner:yield() + end + local function choice(ctx, labels, opts, step) + local Menu = require("src.ui.Menu") + local items, menu, answered = {}, nil, false + local function pick(index) + if answered or token.step ~= step or not check() or contains(token.stack, menu) then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] } + runner:resume() + end + for i, label in ipairs(labels) do + items[i] = { + label = label, + onSelect = function() + pick(i) + end, + } + end + menu = owned( + Menu.new(game, items, { + onCancel = function() + pick(opts.cancel) + end, + }), + step + ) + menu.index = opts.default + token.waitingUI = menu + game.stack:push(menu) + runner:yield() + end + function runner:exec(compiled, ctx) + local pc, slice = 1, 0 + while pc <= #compiled do + if not check() then + return + end + if slice >= Program.SLICE then + self.waitingFrames = 1 + self:yield() + slice = 0 + if not check() then + return + end + end + token.steps, slice = token.steps + 1, slice + 1 + if token.steps > Program.MAX_STEPS then + error("portable script instruction budget exceeded", 0) + end + local row = compiled[pc] + local verb = row[1] + token.step = {} + self.lastRow = verb .. " (portable row " .. pc .. ")" + ctx.script, ctx.pc = compiled, pc + if verb == "wait" then + self.waitingFrames = row[2] + self:yield() + elseif verb == "show_text" or verb == "ask" then + dialog(ctx, row[2], verb == "ask", token.step) + elseif verb == "choice" then + choice(ctx, row[2], row[3], token.step) + elseif verb == "end" then + return + elseif + verb == "jump" + or verb == "jump_if_true" and ctx.lastCheck + or verb == "jump_if_false" and not ctx.lastCheck + then + pc = row[2] - 1 + end + pc = pc + 1 + end + end + local update = Runner.update + function runner:resume(...) + if not check() or not self.co or coroutine.status(self.co) == "running" then + return + end + local ok, err = coroutine.resume(self.co, ...) + if not ok then + Runtime.reportError(modId, "portable script failed: " .. message(err)) + token.finish(false, "script failed: " .. message(err)) + elseif not token.done and coroutine.status(self.co) == "dead" then + token.finish(true) + end + end + function runner:update(...) + if not check() or game.stack:top() ~= ow then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "script update failed: " .. message(err)) + end + end + function runner:run() + error("portable script owns this runner", 0) + end + -- Session/argument checks above do not mutate gameplay. Admission starts + -- here; subsequent native failures complete through onDone(false, reason). + active, runs[game], ow.runner = token, token, runner + runner.ctx = runner:makeContext({ + source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true }, + }) + runner.co = coroutine.create(function() + runner:exec(program, runner.ctx) + end) + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = runner.ctx }) + end + local ok, err = engine(runner.resume, runner) + if not ok then + token.finish(false, "script start failed: " .. message(err)) + end + return true + end + function world:queueScript(rows, options) + local ok, result, reason = engine(queue, rows, options) + if not ok then + return nil, "portable script validation failed" + end + return result, reason + end + function world:cancelScript() + if not active or active.done then + return false, "no portable script" + end + active.finish(false, "script cancelled") + return true + end + return setmetatable(world, { + __index = function(_, key) + local value = native() + local member = value and value[key] + if type(member) == "function" then + return function(_, ...) + return member(value, ...) + end + end + return member + end, + }) +end +function Scripts.wrap(mt, api, loader, manifest) + local format = manifest.script_format or "native" + api.scriptFormat = format + if format ~= "unified" then + return mt + end + local index = mt.__index + local world = facade(loader, manifest.id, function() + return index(api, "world") + end) + mt.__index = function(self, key) + if key == "world" then + return world + end + return index(self, key) + end + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "scripts" then + if Program.supports(name) then + return true + end + return false, "unsupported portable script command" + end + if surface == "world" and (name == "queueScript" or name == "cancelScript") then + return true + end + return supports(self, surface, name) + end + end + return mt +end +return Scripts diff --git a/src/mods/RadioContent.lua b/src/mods/RadioContent.lua new file mode 100644 index 000000000..083a13941 --- /dev/null +++ b/src/mods/RadioContent.lua @@ -0,0 +1,115 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Virtual native station records; failed entries never mutate a live dial. +local Merge=require("src.mods.Merge") +local Radio=require("src.world.Gen2Radio") +local M={} +local previous=setmetatable({},{__mode="k"}) +local pending=setmetatable({},{__mode="k"}) +local function equal(a,b,seen) + if a==b then return true end + if type(a)~="table" or type(b)~="table" then return false end + seen=seen or {};if seen[a]==b then return true end;seen[a]=b + for k,v in pairs(a)do if not equal(v,b[k],seen)then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(value,seen) + if type(value)=="number" then assert(value==value and math.abs(value)= 1 and id < math.huge and id % 1 == 0 +end + local function append(self, id, op, value, owner) if self.frozen then error(self.name .. ": content is frozen after load") end - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") local list = self.ops[id] if not list then list = {} @@ -94,6 +99,14 @@ local function fold(self, value, opList, id) if type(entry.value) == "table" then value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), entry.value, self.spec.semantics) + -- Adapter-produced list patches include empty replacements. Native + -- patch semantics keep {} as a dictionary/no-op, so this internal + -- path list explicitly distinguishes clearing a list from omitting it. + for _, path in ipairs(entry.replaceLists or {}) do + local into, from = value, entry.value + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = Merge.deepCopy(from[path[#path]]) + end else value = entry.value end @@ -120,12 +133,14 @@ function Registry:override(id, value, owner) return append(self, id, "override", value, owner) end -function Registry:patch(id, partial, owner) +function Registry:patch(id, partial, owner, replaceLists) assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) if self.spec.semantics == "compose" then error(self.name .. ": patch is not supported on compose registries") end - return append(self, id, "patch", partial, owner) + local result = append(self, id, "patch", partial, owner) + if replaceLists then self.ops[id][#self.ops[id]].replaceLists = Merge.deepCopy(replaceLists) end + return result end -- tombstone: consumers treat the id as absent after the merge @@ -196,7 +211,7 @@ end -- Works after the loader has frozen content, because it appends nothing -- the -- ops are untouched and only the reading of them changes. function Registry:preferOwner(id, owner) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..f5387d823 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -268,13 +268,14 @@ end -- ------- cross-reference pass +local NUMERIC_REFS = { pokemon = true, moves = true, items = true, trainers = true } local collectRefs collectRefs = function(t, value, path, out) if value == nil or value == Merge.DELETE then return end local kind = t.kind if kind == "opt" then return collectRefs(t.inner, value, path, out) end if kind == "id" then - if type(value) == "string" then + if type(value) == "string" or NUMERIC_REFS[t.registry] and type(value) == "number" and value > 0 then out[#out + 1] = { registry = t.registry, ref = value, path = path } end return @@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + local spec = registry.referenceSpec or registry.spec for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then @@ -1257,10 +1258,52 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +R.decorations = { + semantics="record",generations="2", + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0),sprite=f.int(0,255)}, + example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', + notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", +} + -- ------- persistence +R.phone_contacts = { + semantics = "record", generations = "2", + fields = { + index=f.opt(f.int(0,255)), number=f.opt(f.int(0,255)), + name=f.opt(f.str), class=f.opt(f.str), member=f.opt(f.str), + map=f.opt(f.id("maps")), rematchEvent=f.opt(f.int(0,2047)), + calleeTime=f.opt(f.int(0,7)), callerTime=f.opt(f.int(0,7)), + callee=f.opt(f.str), caller=f.opt(f.str), calleeKey=f.opt(f.str), callerKey=f.opt(f.str), + }, + example='mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map="ROUTE_31" })', + notes="Gen2 native numeric phone book. Existing indices and reserved holes remain fixed; new contacts append at 1..255. Caller/callee keys must name existing native imported scripts, not Gen1 bytecode. New authored scripts, forward script/map references and the legacy Phone facade are separate work.", +} + +R.apricorns = { + semantics="record", generations="2", + fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0),index=f.int(1)}, + example='mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"})', + notes="Native Gen2 recipe records bind real Kurt item selection, event flags and ball delivery. A current native Kurt program import and indexed GSC item IDs are required. New recipes preserve the selected native item byte; generated native branches retain original dialogue/day flags/refusal behavior.", +} + +R.landmarks = { + semantics="record",generations="2", + fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0)}, + example='mod.content.landmarks:patch("LANDMARK_ROUTE_29", {x=80})', + notes="Native Gen2 town-map records and script landmark names. Coordinates retain native OAM byte units, not tile cells. The index is the map-header byte. Native identities win index collisions; otherwise lexical ID order is deterministic. Imported identity metadata is independent of display names.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save +R.radio_channels = { + semantics = "record", target = "gen2RadioChannels", generations = "2", + fields = { channel=f.opt(f.int(0,255)), name=f.opt(f.str) }, + write=function(target,registry)require("src.mods.RadioContent").write(target,registry)end, + example='mod.content.radio_channels:register("PIRATE_RADIO", { channel=9, name="PIRATE RADIO" })', + notes="Gen2 only. Station identities are shared by native wall radios and Pokegear. Channel 0 remains regional/time-dependent; positive channel numbers must be unique. Names are authored display text. Full ROM show-state/audio reproduction is separate.", +} + R.migrations = { semantics = "compose", value = f.rec{ since = f.str, run = f.fn }, diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua new file mode 100644 index 000000000..4bfc0a813 --- /dev/null +++ b/src/mods/ScriptProgram.lua @@ -0,0 +1,160 @@ +-- Pure preflight for the bounded portable dialogue/control-flow language. +local Program = {} +Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 +local ARITY = { + show_text = 2, + ask = 2, + choice = 3, + wait = 2, + label = 2, + jump = 2, + jump_if_true = 2, + jump_if_false = 2, + ["end"] = 1, +} +function Program.supports(verb) + return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") +end +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function array(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil + end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, max) then + return nil + end + n = n + 1 + end + for i = 1, n do + if rawget(t, i) == nil then + return nil + end + end + return n +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 64 and s:match("^[%w_:%-]+$") ~= nil +end +local function text(value, max) + if type(value) ~= "string" or #value > max or value:find("%z") then + return false + end + local i = 1 + while i <= #value do + local b = value:byte(i) + local n = b < 128 and 1 + or b >= 194 and b <= 223 and 2 + or b >= 224 and b <= 239 and 3 + or b >= 240 and b <= 244 and 4 + if not n or i + n - 1 > #value then + return false + end + for j = i + 1, i + n - 1 do + local c = value:byte(j) + if c < 128 or c > 191 then + return false + end + end + if n > 1 then + local c = value:byte(i + 1) + if b == 224 and c < 160 or b == 237 and c > 159 or b == 240 and c < 144 or b == 244 and c > 143 then + return false + end + end + i = i + n + end + return true +end +function Program.compile(raw) + local n = array(raw, Program.MAX_ROWS) + if not n then + return nil, "script must be a dense plain array of at most 1024 rows" + end + local rows, labels, bytes = {}, {}, 0 + local function fail(i, message) + return nil, "row " .. i .. ": " .. message + end + for i = 1, n do + local source, count = raw[i], array(raw[i], 3) + if not count or count == 0 then + return fail(i, "expected a dense plain command row") + end + local verb = source[1] == "text" and "show_text" or source[1] + if type(verb) ~= "string" or not ARITY[verb] then + return fail(i, "unsupported portable command") + end + if count > ARITY[verb] or count < (verb == "choice" and 2 or ARITY[verb]) then + return fail(i, "invalid command arity") + end + local row = { verb, source[2] } + if verb == "show_text" or verb == "ask" then + if not text(source[2], 4096) then + return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes") + end + bytes = bytes + #source[2] + elseif verb == "choice" then + local size = array(source[2], 16) + if not size or size == 0 then + return fail(i, "choice needs 1-16 labels") + end + row[2] = {} + for j = 1, size do + local value = source[2][j] + if not text(value, 128) or #value == 0 then + return fail(i, "invalid choice label") + end + row[2][j], bytes = value, bytes + #value + end + local opts = source[3] + row[3] = { default = 1, cancel = size } + if opts ~= nil then + if type(opts) ~= "table" or getmetatable(opts) ~= nil then + return fail(i, "choice options must be a plain table") + end + for key, value in pairs(opts) do + if (key ~= "default" and key ~= "cancel") or not integer(value, 1, size) then + return fail(i, "invalid choice option") + end + row[3][key] = value + end + end + elseif verb == "wait" then + if not integer(source[2], 0, 36000) then + return fail(i, "wait must be 0-36000 native logic frames") + end + elseif verb == "label" then + if not label(source[2]) or source[2] == "end" or labels[source[2]] then + return fail(i, "invalid or duplicate label") + end + labels[source[2]] = i + elseif verb ~= "end" then + if not label(source[2]) and not integer(source[2], 1, n) then + return fail(i, "invalid jump target") + end + end + if bytes > 131072 then + return fail(i, "script text exceeds 128 KiB") + end + rows[i] = row + end + for i, row in ipairs(rows) do + if row[1] == "jump" or row[1] == "jump_if_true" or row[1] == "jump_if_false" then + local target = row[2] + if type(target) == "string" then + if target == "end" then + row[2] = n + 1 + elseif labels[target] then + row[2] = labels[target] + else + return fail(i, "jump to missing label") + end + end + end + end + return rows +end +return Program diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua new file mode 100644 index 000000000..533730961 --- /dev/null +++ b/src/mods/UIActions.lua @@ -0,0 +1,241 @@ +-- Per-mod, detached UI snapshots and revision-bound native actions. +local Commands = require("src.ui.UICommands") +local Intents = require("src.mods.BattleIntents") +local Ids = require("src.mods.UnifiedIds") +local Runtime = require("src.mods.Runtime") +local Actions = {} +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then + return v + end + return nil + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, value in pairs(v) do + if type(k) == "string" or type(k) == "number" then + out[k] = copy(value, seen) + end + end + return out +end +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b, c = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b, c +end +local function integer(n) + return type(n) == "number" and n >= 1 and n <= 9007199254740991 and n % 1 == 0 +end +local function normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id) or not integer(raw.revision) then + return nil, "invalid UI id or revision" + end + local fields = ({ select = { index = true }, back = {}, advance = {}, pocket = { direction = true } })[raw.kind] + if not fields then + return nil, "unknown UI intent" + end + for k in pairs(raw) do + if k ~= "id" and k ~= "revision" and k ~= "kind" and not fields[k] then + return nil, "unknown UI intent field" + end + end + if raw.kind == "select" and not integer(raw.index) then + return nil, "invalid UI row" + end + if raw.kind == "pocket" and raw.direction ~= -1 and raw.direction ~= 1 then + return nil, "invalid pocket direction" + end + return { + id = raw.id, + revision = raw.revision, + kind = raw.kind, + index = raw.index, + direction = raw.direction, + } +end +function Actions.new(provider, loader, builders) + local ids = Ids.new(loader) + ids:entries("pokemon") + ids:entries("items") + local facade = setmetatable({}, { __index = builders }) + local revision, consumed = 0, 0 + local previous, oldRefs, oldControls, oldEpoch, offered + local function refresh() + local game = provider() + local states = game and game.stack and game.stack.states + local top = states and states[#states] + if not top then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, "no UI screen" + end + local ok, view, refs, kind = engine(Commands.describe, game, top, Ids.new(loader)) + if not ok or not view then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, ok and refs or "UI snapshot failed" + end + refs.game = game + -- Capture live eligibility, target and inventory changes even when the + -- native screen has not rebuilt its labels yet. Never return this data. + local controls = copy({ + party = type(top.party) == "table" and top.party or game.save and game.save.party, + inventory = game.save and game.save.inventory, + bagOrder = game.save and game.save.bagOrder, + select = top.select, + menu = top.menu, + submenu = top.submenu, + swapFrom = top.swapFrom, + switchFrom = top.switchFrom, + softboiledFrom = top.softboiledFrom, + hpTransfer = top.hpTransfer, + hpTransferFrom = top.hpTransferFrom, + forceSwitch = top.forceSwitch, + pickOnly = top.pickOnly, + keepOpen = top.keepOpen, + pick = top.pick, + store = top.store, + sell = top.sell, + registeredItem = game.save and game.save.registeredItem, + cur = top.cur, + mem = top.mem, + page = top.page, + top = top.top, + done = top.done, + waiting = top.waiting, + shown = top.shown, + contAdvance = top.contAdvance, + lineIndex = top.lineIndex, + pageIndex = top.pageIndex, + pending = top.pending, + trash = top.trash, + moving = top.moving, + lockPocket = top.lockPocket, + resultMessage = top.resultMessage, + message = top.message, + selectNotice = top.selectNotice, + }) + local state = Intents.state(game) + if + not equal(view, previous) + or not Intents.sameRefs(refs, oldRefs) + or not equal(controls, oldControls) + or state.epoch ~= oldEpoch + then + revision = revision + 1 + end + previous, oldRefs, oldControls, oldEpoch = view, refs, controls, state.epoch + return { game = game, top = top, view = view, kind = kind, state = state } + end + function facade:snapshot() + local ctx, err = refresh() + if not ctx then + return nil, err + end + local out = copy(ctx.view) + out.revision = revision + if ctx.state.busy then + out.locked, out.canBack, out.canAdvance, out.canPocket = true, false, false, false + end + offered = revision + return out + end + function facade:submit(raw) + local intent, err = normalize(raw) + if not intent then + return nil, err + end + if intent.id <= consumed then + return nil, "replayed UI intent" + end + local ctx + ctx, err = refresh() + if not ctx then + return nil, err + end + local state, view = ctx.state, ctx.view + if state.busy then + return nil, "UI action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale UI context" + end + if offered ~= revision then + return nil, "fresh UI snapshot required" + end + if view.locked then + return nil, "UI is locked" + end + if intent.kind == "select" then + if not view.rows[intent.index] or not view.rows[intent.index].enabled then + return nil, "unavailable UI row" + end + elseif intent.kind == "back" and not view.canBack then + return nil, "UI cannot go back" + elseif intent.kind == "advance" and not view.canAdvance then + return nil, "UI cannot advance" + elseif intent.kind == "pocket" and not view.canPocket then + return nil, "UI cannot change pocket" + end + -- Native work can raise after partial effects. Consume before dispatch; + -- the shared epoch also expires snapshots held by battle/other UI facades. + consumed, offered = intent.id, nil + state.busy, state.epoch = true, state.epoch + 1 + local ok, failure = engine(Commands.dispatch, ctx.top, ctx.kind, view, intent) + state.busy, state.epoch = false, state.epoch + 1 + if not ok then + local printable, message = pcall(tostring, failure) + return nil, "UI action failed; intent consumed" .. (printable and ": " .. message or "") + end + return true + end + return facade +end +function Actions.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "ui" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown UI capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end +end +return Actions diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua new file mode 100644 index 000000000..52acde317 --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,224 @@ +local Api3Catalog = require("src.mods.Api3Catalog") +local Records = require("src.mods.CallbackRecords") +local Ids = require("src.mods.UnifiedIds") +local C = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +-- This is an audited boundary catalog, not a claim that every game executes +-- every seam. The source-catalog test requires a decision for each new seam. +C.hooks = names([[ + phone.contact_list + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items + ui.title_menu.items warp.destination world.tod zoom.range +]]) +C.events = names([[ + apricorn.converted phone.call_received radio.channel + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended + battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended + battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished + intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded + mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped + pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up + pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped + screen.pushed script.ended script.started sound.played trade.completed world.blacked_out + world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +local positions = { + ["battle.accuracy"] = { "context" }, + ["battle.damage"] = { "context" }, + ["battle.crit"] = { "context" }, + ["battle.run"] = { "context" }, + ["battle.catch_exp"] = { "context" }, + ["battle.low_health_alarm"] = { "context" }, + ["battle.exp_award"] = { "context" }, + ["exp.gain"] = { "context" }, + ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" }, + ["battle.enemy_action"] = { false, "battler" }, + ["catch.rate"] = { "items", "mon", "species", "context" }, + ["trainer.party"] = { false, false, "trainerParty" }, + ["evolution.check"] = { false, "mon", "context", "context" }, + ["fieldmove.eligibility"] = { "moves", false }, + ["encounter.fishing"] = { "items", false, "candidates" }, + ["encounter.roll"] = { false, "context" }, + ["encounter.species"] = { "encounter", "context" }, + ["ui.party.submenu"] = { false, false, "mon", false }, + ["pokemon.sprite"] = { false, "context" }, + ["pokemon.icon"] = { false, "context" }, +} +local results = { + ["fieldmove.eligibility"] = "mon", + ["battle.enemy_action"] = "action", + ["trainer.party"] = "trainerParty", + ["battle.turn_order"] = "battler", + ["encounter.fishing"] = "encounter", + ["encounter.roll"] = "encounter", + ["encounter.species"] = "encounter", +} +-- These payloads are engine handles, repair flows or UI objects, not record +-- notifications. In particular, game.ready has a forwarding metatable. +local nativeEvents = names([[ + game.ready save.created save.loaded save.loading save.writing mods.loaded + screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step + mod.option_action mod.options_changed +]]) +local function custom(name) + return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil +end +function C.install(loader, api, owner, generation) + -- Capture pristine alias identities before Loader merges them into separate + -- records. Each call still builds a fresh index, including later mods' IDs. + local ids = Ids.new(loader) + local api3 = (api.manifest.api or 1) >= 3 + local extra = api3 and Api3Catalog + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + local function check(catalog, name) + assert( + type(name) == "string" + and ( + catalog[name] + or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name])) + or (catalog == C.events and custom(name)) + ), + "unknown unified callback: " .. tostring(name) + ) + end + local function subscribe(once, name, callback, priority) + check(C.events, name) + assert(type(callback) == "function", "event callback must be a function") + local wrapped = callback + if not custom(name) and not nativeEvents[name] then + wrapped = function(payload) + local boundary = Records.new(loader, name, generation, api3) + local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") + local values = pack(callback(public)) + if name == "pokemon.before_give" then + boundary.commit() + end + return unpack(values, 1, values.n) + end + end + if once then + return loader.events:once(name, wrapped, priority, owner) + end + return loader.events:on(name, wrapped, priority, owner) + end + api.events = { + on = function(_, ...) + return subscribe(false, ...) + end, + once = function(_, ...) + return subscribe(true, ...) + end, + emit = function(_, name, payload) + local prefix = "mod." .. owner .. "." + assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events") + return loader.events:emit(name, payload) + end, + } + api.hooks = { + wrap = function(_, name, callback, priority) + check(C.hooks, name) + assert(type(callback) == "function", "hook callback must be a function") + return loader.hooks:wrap(name, function(nextFn, ...) + local boundary = Records.new(loader, name, generation, api3) + local roles = positions[name] or {} + local function translate(args) + local out = { n = args.n } + for i = 1, args.n do + local role = roles[i] + local v = args[i] + if role == "items" or role == "moves" then + v = boundary.read(role, v) + elseif role then + v = boundary.view(v, role) + end + out[i] = v + end + return out + end + local original = pack(...) + local function rawArgs(args) + local out = { n = args.n } + for i = 1, args.n do + local role, v = roles[i], args[i] + if role == "items" or role == "moves" then + v = boundary.native(role, v, original[i]) + elseif role then + v = boundary.restore(v, role) + end + out[i] = v + end + return out + end + local public = translate(original) + local called, downstream, publicResult = false, nil, nil + local active = true + local function nextPublic(...) + assert(active, "next is only valid during its hook callback") + assert(not called or downstream, "next is already running") + if not called then + local args = select("#", ...) == 0 and public or pack(...) + local raw = boundary.commit(function() + return rawArgs(args) + end) + called = true + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + if downstream[1] then + boundary.refresh() + publicResult = pack(unpack(downstream, 2, downstream.n)) + if results[name] then + publicResult[1] = boundary.view(publicResult[1], results[name]) + end + end + end + if not downstream[1] then + error(downstream[2], 0) + end + return unpack(publicResult, 1, publicResult.n) + end + local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n))) + active = false + if not invoked[1] then + boundary.close() + error(invoked[2], 0) + end + local values = pack(unpack(invoked, 2, invoked.n)) + if called and not downstream[1] then + boundary.close() + error(downstream[2], 0) + end + local ok, err = pcall(boundary.commit, function() + if results[name] then + values[1] = boundary.restore(values[1], results[name]) + end + end) + boundary.close() + if not ok then + error(err, 0) + end + return unpack(values, 1, values.n) + end, priority, owner) + end, + } +end +return C diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua new file mode 100644 index 000000000..dde1870a8 --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,518 @@ +-- Explicit opt-in definition-record boundary. Native registries and gameplay +-- stay authoritative; no save, callback, world or battle data is rewritten. +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Shapes = require("src.mods.UnifiedSchemas") +local Ids = require("src.mods.UnifiedIds") +local U = {} +local copy, DELETE = Merge.deepCopy, Merge.DELETE +local function unsupported(reason) error({ unsupported = reason }, 0) end +local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end +local function equal(a, b, seen) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + seen = seen or {}; if seen[a] == b then return true end; seen[a] = b + for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function finite(v, seen) + if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end + if type(v) ~= "table" or v == DELETE then return end + seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true + for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end + seen[v] = nil +end +local function list(value, old, convert) + if value == DELETE then return {} end + check(type(value) == "table", "expected list") + if Merge.isWrapper(value) then + for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end + local out = {} + for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do + for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end + end + return list(out, nil, convert) + end + local count = 0 + for k in pairs(value) do + check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list") + count = count + 1 + end + check(count == #value, "expected contiguous list") + local out = {} + for i, row in ipairs(value) do out[i] = convert(row, i) end + return out +end +local function resolved(ctx, kind, id) + check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID") + local found, why = ctx.ids:resolve(kind, id) + if not found then unsupported(why) end + return found.key +end +local function readIds(ctx, kind, values) + local out = {} + for _, id in ipairs(values or {}) do + if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end + end + return out +end +local function writeIds(ctx, kind, values, old) + return list(values, old, function(id) return resolved(ctx, kind, id) end) +end +-- Gen 3's seven-byte names also produce these spellings (TypeChart's +-- registrant deliberately keeps them authoritative rather than renaming Data). +local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" } +local function typeRead(id) return TYPE_ALIASES[id] or id end +local function typeWrite(ctx, id) + local registry = ctx.loader.content.type_chart + local found = registry:get(id) ~= nil and id or nil + for native, canonical in pairs(TYPE_ALIASES) do + if canonical == id and registry:get(native) ~= nil then + if found then unsupported("ambiguous type ID: " .. tostring(id)) end + found = native + end + end + if found then return found end + unsupported("unknown type ID: " .. tostring(id)) +end +local function reference(ctx, kind, id) + if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end + return id +end + +local EVO = { + level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE", + friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP", + HAPPINESS = "FRIENDSHIP", STAT = "LEVEL", + levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL", +} +local function evolutionRead(ctx, row) + local method = EVO[row.method] or row.method + local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) } + if method == "LEVEL" then out.level = row.level or row.param end + if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end + if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end + if method == "FRIENDSHIP" then + out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY") + or (row.method == "friendshipNight" and "NITE") + end + out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF", + levelAtkEqual = "ATK_EQ_DEF" })[row.method] + return out +end +local function evolutionWrite(ctx, row, old) + if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end + local method = row.method + if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then + unsupported("evolution method has no unified writer: " .. tostring(method)) + end + if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE" + or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end + if ctx.gen >= 4 then + -- This engine implements the native lowercase level method. Other DS + -- evolution conditions retain cartridge parameters and lack a safe writer. + if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + return { method = "level", param = row.level, paramKind = "level", methodId = 4, + target = resolved(ctx, "pokemon", row.species) } + end + local out = { method = method, species = resolved(ctx, "pokemon", row.species) } + if method == "LEVEL" then + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + out.level = row.level + if row.comparison then out.method, out.compare = "STAT", row.comparison end + elseif method == "ITEM" then + check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition") + out.item = resolved(ctx, "items", row.item) + elseif method == "TRADE" then + check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition") + if row.item then out.heldItem = resolved(ctx, "items", row.item) end + else + check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition") + out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil + end + return out +end + +local readRecord +readRecord = function(ctx, kind, id, raw) + local out = { id = id, native = copy(raw) } + for key in pairs(Shapes[kind].fields) do + if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end + end + if kind == "pokemon" then + local stats = raw.baseStats or {} + out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + special = stats.special or stats.spatk or stats.spAttack, + specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil, + specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil } + out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end + end + out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {} + for i, row in ipairs(raw.learnset or {}) do + out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) } + end + for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end + elseif kind == "moves" then + out.type = typeRead(raw.type) + out.category = raw.category or raw.class + if type(raw.effect) ~= "string" then out.effect = nil end + elseif kind == "items" then + if type(raw.effect) ~= "string" then out.effect = nil end + if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end + elseif kind == "trainers" then + out.parties = {} + for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do + out.parties[i] = {} + for j, mon in ipairs(party) do + out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level, + item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) } + end + end + elseif kind == "encounters" then + local function areaRead(area) + if not area then return nil end + local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} } + for i, slot in ipairs(area.slots or {}) do + row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level, + minLevel = slot.min, maxLevel = slot.max } + end + if area.byTime then + row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } } + for key, native in pairs({ MORN = "morn", NITE = "nite" }) do + if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end + end + end + return row + end + out.grass, out.water = areaRead(raw.grass), areaRead(raw.water) + end + return out +end + +local function writeRecord(ctx, kind, id, value, old, mode) + local previous = old and readRecord(ctx, kind, id, old) or {} + if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end + if value.id ~= nil then check(value.id == id, "record id differs from operation id") end + if value.native ~= nil and old then + -- Detached read/modify/write records carry inherited fields. Convert only + -- actual edits, so round trips retain native selectors and absent aliases. + value = copy(value) + for key, v in pairs(value) do + if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end + end + end + local out, replacements = {}, {} + local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end + for key, v in pairs(value) do + if key ~= "native" and key ~= "id" then out[key] = copy(v) end + end + if not old then out.id = id end + if kind == "pokemon" then + if value.baseStats and value.baseStats ~= DELETE then + local stats, native = value.baseStats, {} + for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end + for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end + local a, b = stats.specialAttack, stats.specialDefense + if ctx.gen == 1 then + a, b = a or stats.special or (previous.baseStats or {}).special, + b or stats.special or (previous.baseStats or {}).special + if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end + if stats.special and a ~= stats.special then check(false, "conflicting Special values") end + native.special = stats.special or stats.specialAttack or stats.specialDefense + else + -- A patch to special intentionally writes both halves. On detached + -- read/modify/write records, unchanged split fields are inherited. + if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then + if value.native ~= nil then + if a == (previous.baseStats or {}).specialAttack then a = nil end + if b == (previous.baseStats or {}).specialDefense then b = nil end + end + check(a == nil or a == stats.special, "conflicting Special Attack values") + a, b = a or stats.special, b or stats.special + elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end + native.spatk, native.spdef = a, b + if a ~= nil then native.special = a end + if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end + if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end + end + out.baseStats = native + end + check(value.types ~= DELETE, "species types cannot be deleted") + if value.types then + local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end) + check(#types >= 1 and #types <= 2, "species needs one or two types") + replace("types", types) + out.type1, out.type2 = types[1], types[2] or types[1] + end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end + end + if value.learnset ~= nil then + replace("learnset", list(value.learnset, previous.learnset, function(row) + return { level = row.level, move = resolved(ctx, "moves", row.move) } + end)) + end + if value.evolutions ~= nil then + replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i) + return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i]) + end)) + end + if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end + if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then + unsupported("breeding fields have no Gen 1 representation") + end + if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end + elseif kind == "moves" then + if value.type and value.type ~= DELETE then + out.type = typeWrite(ctx, value.type) + if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end + end + if value.effect and value.effect ~= DELETE then + reference(ctx, "move_effects", value.effect) + if ctx.gen > 1 and (not old or value.effect ~= old.effect) then unsupported("changing move effects requires a native effect adapter") end + end + if value.category ~= nil and old and old.class ~= nil then + check(value.category ~= DELETE, "native move class cannot be deleted") + out.class = value.category + if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end + end + elseif kind == "items" then + if value.machine and value.machine ~= DELETE then + out.machine.move = resolved(ctx, "moves", value.machine.move) + end + for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do + if value[key] and value[key] ~= DELETE then + reference(ctx, registry, value[key]) + if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end + end + end + elseif kind == "trainers" and value.parties ~= nil then + local prior = old and (old.parties or (old.party and { old.party })) or {} + local parties = list(value.parties, previous.parties, function(party, i) + return list(party, nil, function(mon, j) + local native = copy(prior[i] and prior[i][j] or {}) + native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level + if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end + native.item = mon.item and resolved(ctx, "items", mon.item) or nil + native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil + return native + end) + end) + replace("parties", parties) + if old and old.party then + check(#parties == 1, "native trainer has exactly one party") + replace("party", parties[1]) + end + elseif kind == "encounters" then + local function areaWrite(input, native, before, path) + if input == DELETE then return DELETE end + native, before = native or {}, before or {} + check(input.times ~= DELETE, "native time tables cannot be deleted") + if input.times then + input = copy(input) + if equal(input.rate, before.rate) then input.rate = nil end + if equal(input.slots, before.slots) then input.slots = nil end + end + local result = {} + if input.rate ~= nil then + check(input.rate ~= DELETE, "encounter rate cannot be deleted") + local rate = input.rate * (native.rateMax or 256) / 256 + if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end + result.rate = math.floor(rate + 0.5) + end + if input.slots ~= nil then + local rows = list(input.slots, before.slots, function(slot, i) + local row = copy((native.slots or {})[i] or {}) + row.species = resolved(ctx, "pokemon", slot.species) + if slot.level then + check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels") + row.level, row.min, row.max = slot.level, nil, nil + else + check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range") + row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel + end + if slot.chance then unsupported("changing encounter weights requires native buckets") end + return row + end) + if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end + result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p + end + if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end + if native.byTime then + result.byTime = {} + for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do + local shared = { rate = input.rate, slots = input.slots } + -- A full detached record contains inherited times: only explicit + -- changes override a shared rate/slot patch. + local specific = input.times and input.times[time] + if specific and before.times and equal(specific, before.times[time]) then specific = nil end + if specific then + for k, v in pairs(specific) do + if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end + end + end + if nativeTime == "day" then + local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path) + if d.rate ~= nil then result.rate = d.rate end + if d.slots then result.slots = d.slots end + elseif native.byTime[nativeTime] then + local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime + result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p) + end + end + end + return result + end + for _, key in ipairs({ "grass", "water" }) do + if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end + end + end + return out, replacements +end + +local function supported(kind, gen) + if kind == "encounters" and gen >= 4 then + return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content" + end + return true +end +local function protect(fn) + local result = { pcall(fn) } + if not result[1] then + if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end + error(result[2], 0) + end + return unpack(result, 2) +end +function U.new(loader, mod, native, gen) + local out = {}; for name, handle in pairs(native) do out[name] = handle end + for kind, shape in pairs(Shapes) do + local registry = loader.content[kind] + if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end + local f = Schemas.f + registry.referenceSpec = copy(registry.spec) + if kind == "pokemon" then + registry.referenceSpec.fields.evolutions = f.list(f.rec { + species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")), + item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")), + }) + elseif kind == "trainers" then + local parties = f.list(f.list(f.rec { species = f.id("pokemon"), + item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) })) + registry.referenceSpec.fields.parties = parties + registry.referenceSpec.fields.party = parties.inner + elseif kind == "encounters" then + local slots = f.list(f.rec { species = f.id("pokemon") }) + local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) } + registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area + if gen >= 4 then + -- The DS writer is unavailable, but removing a numeric species must + -- still see native encounter references, including seasonal tables. + local fields = {} + for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end + for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do + fields[key] = f.rec { slots = slots } + end + fields.timed = f.map(f.str, f.list(f.id("pokemon"))) + fields.radar = f.list(f.id("pokemon")) + fields.dualSlot = f.map(f.str, f.list(f.id("pokemon"))) + fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) } + fields.seasons = f.map(f.str, f.rec(fields)) + registry.referenceSpec.fields = fields + end + end + local available, why = supported(kind, gen) + local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end + local function read(id) + if not available then return nil, why end + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found then return nil, err end + local raw = registry:get(found.key) + return raw and readRecord(ctx, kind, id, raw) or nil + end + local handle = { available = available, supports = function() return available, why end, + get = function(_, id) return read(id) end, + has = function(_, id) return read(id) ~= nil end, + each = function() + if not available then return function() end, why end + local ctx, keys = context(), {} + for id, found in pairs(ctx.ids:entries(kind)) do + if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end + keys[#keys + 1] = id + end + table.sort(keys) + local i = 0 + return function() + i = i + 1; local id = keys[i] + if id == nil then return end + local found = ctx.ids:resolve(kind, id) + return id, readRecord(ctx, kind, id, registry:get(found.key)) + end + end, + } + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + handle[verb] = function(_, id, value) + if not available then return nil, why end + return protect(function() + check(type(id) == "string" and id ~= "", "operation id must be a non-empty string") + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found and err:match("^ambiguous") then unsupported(err) end + if not found then + if gen >= 4 then unsupported("new numeric records require native storage allocation") end + found = { key = id, keys = { id } } + end + local old = registry:get(found.key) + if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end + if verb == "remove" then + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end + return true + end + finite(value) + check(type(value) == "table" and value ~= DELETE, "expected record") + for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end + local function deletes(fields, record) + for key, entry in pairs(record) do + local field = fields[key] + if field and field.kind ~= "opt" then + check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key) + if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end + end + end + end + deletes(shape.fields, value) + local valid, validation = Schemas.check(shape, kind, id, value, verb) + check(valid, validation or "invalid record") + local payload, replacements = writeRecord(ctx, kind, id, value, old, verb) + if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do + if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements) + else + local record = Merge.deepMerge(copy(old or {}), payload) + for _, path in ipairs(replacements) do + local into, from = record, payload + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = copy(from[path[#path]]) + end + registry[verb](registry, key, record, mod.manifest.id) + end + end + return true + end) + end + end + out[kind] = handle + end + return out +end +return U diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua new file mode 100644 index 000000000..10d2f6bb2 --- /dev/null +++ b/src/mods/UnifiedSchemas.lua @@ -0,0 +1,60 @@ +-- The opt-in content format is separate from the legacy/native schemas. +-- These are definition records, not saved Pokemon or callback payloads. +local Schemas = require("src.mods.Schemas") +local f = Schemas.f +local id = f.str +local ids = f.list(id) +local level = f.int(1, 100) +local stats = f.rec { + hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255), + speed = f.int(1, 255), special = f.int(1, 255), + specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)), +} +local evolution = f.rec { + method = id, species = id, level = f.opt(level), item = f.opt(id), + time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }), + comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }), +} +local slot = f.rec { + species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level), + chance = f.opt(f.numRange(0, 100)), +} +local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot), + times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" }, + f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) } +local U = { + pokemon = { fields = { + id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats, + catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id, + level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }), + evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)), + trueColor = f.opt(f.bool), + } }, + moves = { fields = { + id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), + pp = f.int(0, 64), effect = f.opt(id), + category = f.opt(f.enum { "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), + } }, + items = { fields = { + id = id, name = f.str, price = f.int(0), + machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }), + effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), description = f.opt(f.str), + } }, + trainers = { fields = { + id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)), + battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)), + parties = f.list(f.list(f.rec { + species = id, level = level, item = f.opt(id), moves = f.opt(ids), + })), + } }, + encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } }, +} +for _, spec in pairs(U) do + spec.fields.native = f.opt(f.any) + spec.semantics = "record" +end +return U diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua new file mode 100644 index 000000000..491963d48 --- /dev/null +++ b/src/mods/WorldOverview.lua @@ -0,0 +1,153 @@ +-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, +-- or object visibility refreshes here: each can change the live simulation. +local Ids = require("src.mods.UnifiedIds") +local GameVersion = require("src.core.GameVersion") +local Overview = {} + +function Overview.context(loader) + Ids.new(loader):entries("pokemon") + return function() + return Ids.new(loader) + end +end + +function Overview.install(api, loader) + local context = Overview.context(loader) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then + return true + end + return supports(self, surface, name) + end + end + return context +end + +local function dataIds(data) + local rows = data.pokemon or {} + return Ids.new({ + content = { + pokemon = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + }, + }, + }) +end + +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local hours = { MORNING = 6, DAY = 12, NITE = 22 } + +function Overview.encounters(game, ow, context, mapId, terrain, opts) + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then + return nil, "invalid terrain: " .. tostring(terrain) + end + if opts ~= nil and type(opts) ~= "table" then + return nil, "opts must be a table" + end + local daytime = opts and opts.daytime + if daytime ~= nil then + daytime = type(daytime) == "string" and periods[daytime:upper()] + if not daytime then + return nil, "invalid daytime" + end + end + local data = game and game.data + if not (data and type(data.encounters) == "table") then + return nil, "encounters data not loaded" + end + local def = data.maps and data.maps[mapId] + if not def then + return nil, "unknown map: " .. tostring(mapId) + end + -- HGSS's Safari table depends on the player's position in a six-area matrix. + -- Encounter.forMap resolves it through the Game singleton and seeds Safari + -- save state. Refuse this exceptional table instead of querying another game. + if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then + return nil, "position-dependent Safari encounters unavailable" + end + if GameVersion.get() == "emerald" then + if + require("src.script.Gen3BattlePike").inWildRoom(mapId) + or require("src.script.Gen3BattlePyramid").onFloor(mapId) + then + return nil, "facility encounters unavailable" + end + end + local Encounter = require("src.world.Encounter") + local hour = daytime and hours[daytime] + local enc = Encounter.forMap(data, def, mapId, hour, game.save) + if not daytime then + -- A world.tod hook's most recent effective period is already on the world. + -- Calling it again would execute mod code and can emit/change world state. + local Runtime = require("src.mods.Runtime") + if Runtime.wantsHook("world.tod") and ow then + daytime = ow.tod + end + if GameVersion.isGen2() then + if not daytime then + hour = tonumber(os.date("%H")) or 12 + daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" + end + else + daytime = daytime or (ow and ow.tod) or "DAY" + end + end + local slots = enc and enc[terrain == "indoor" and "grass" or terrain] + if terrain ~= "water" then + slots = Encounter.atTime(slots, daytime) + end + if not slots then + return { chance = 0, dist = {} } + end + local maximum = tonumber(slots.rateMax) or 256 + local rate = tonumber(slots.rate) or 0 + local span = tonumber(slots.bucketSpan) or 256 + if + maximum <= 0 + or maximum ~= math.floor(maximum) + or maximum == math.huge + or rate ~= rate + or rate == math.huge + or rate == -math.huge + or span <= 0 + or span ~= math.floor(span) + or span == math.huge + then + return nil, "invalid encounter rate/span" + end + local dist, prev = {}, 0 + local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets") + if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then + return nil, "invalid encounter buckets/slots" + end + local ids = context and context() or dataIds(data) + for i, threshold in ipairs(weights or {}) do + if + type(threshold) ~= "number" + or threshold ~= math.floor(threshold) + or threshold < prev + or threshold > span + then + return nil, "invalid encounter buckets" + end + local slot = slots.slots and slots.slots[i] + if rate > 0 and slot and slot.species and threshold > prev then + local id = ids:read("pokemon", slot.species) + if id then + dist[id] = (dist[id] or 0) + threshold - prev + end + end + prev = threshold + end + -- Same independent integer rate gate as rollTable, without drawing a slot. + return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist } +end + +return Overview diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua new file mode 100644 index 000000000..2ca8f2504 --- /dev/null +++ b/src/mods/WorldReads.lua @@ -0,0 +1,227 @@ +-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon, +-- loads a map, calls gameplay methods or advances either random generator. +local Ids = require("src.mods.UnifiedIds") +local Reads = {} +local METHODS = { party = true, activeBlockAt = true, liveMaps = true } + +function Reads.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +-- Capture native alias identity/name metadata before the loader splits alias +-- rows during merging. Fresh indexes at read time still include later mods. +function Reads.context(loader) + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + return { + ids = function() + return Ids.new(loader) + end, + } +end + +-- Engine callers can construct WorldAPI without a mod loader too. The +-- private registry facade only reads data; Ids' bookkeeping stays on it. +local function dataIds(data) + local loader = { content = {} } + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + local rows = type(data[kind]) == "table" and data[kind] or {} + loader.content[kind] = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + } + end + return Ids.new(loader) +end + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "number" or kind == "string" or kind == "boolean" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, sub in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(sub, seen) + end + end + return out +end +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STAT = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +-- The same common live-Pokemon shape as the unified callback boundary. +-- Native diagnostics are data-only copies, including unknown save extensions. +function Reads.party(game, context) + local save = game and game.save + if not save then + return nil, "no save" + end + local data = game.data or {} + local ids = context and context.ids() or dataIds(data) + local out = {} + for i, raw in ipairs(save.party or {}) do + local stats, moves = {}, {} + for key, native in pairs(STAT) do + stats[key] = (raw.stats or {})[native] + end + stats.specialAttack = stats.specialAttack or stats.special + stats.specialDefense = stats.specialDefense or stats.special + for slot, move in ipairs(raw.moves or {}) do + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] + local ups = type(move) == "table" and (move.ppUps or 0) or 0 + moves[slot] = { + id = ids:read("moves", id), + pp = type(move) == "table" and move.pp or nil, + maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + out[i] = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats, + moves = moves, + native = copy(raw), + } + end + return out +end + +local function integer(value, low) + return type(value) == "number" + and value == value + and value < math.huge + and value > -math.huge + and value % 1 == 0 + and value >= low +end + +-- Public block coordinates are zero-based, not walk-cell coordinates. Derive +-- geometry from this map's runtime tileset, not the selected game's number. +function Reads.metadata(map) + local def = map and map.def + if + not def + or not integer(def.width, 1) + or not integer(def.height, 1) + or not integer(map.blockCells, 1) + or not integer(map.blockTiles, 1) + or map.blockTiles * 8 ~= map.blockCells * 16 + then + return nil, "block geometry unavailable" + end + return { + tileSize = map.blockTiles * 8, + cellSize = 16, + coordinateBase = 0, + kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell" + or map.blockCells == 1 and "metatile" + or "block", + width = def.width, + height = def.height, + } +end + +function Reads.activeBlockAt(map, mapId, bx, by) + if map.id ~= mapId then + return nil, "map is not active" + end + if not integer(bx, 0) or not integer(by, 0) then + return nil, "invalid block coordinates" + end + local metadata, reason = Reads.metadata(map) + if not metadata then + return nil, reason + end + if bx >= metadata.width or by >= metadata.height then + return nil, "block coordinates out of bounds" + end + local index = by * metadata.width + bx + 1 + local block = map.blockPatch and map.blockPatch[index] + if block == nil then + local blocks = map.def.blocks + if type(blocks) == "table" then + block = blocks[index] + elseif type(blocks) == "string" then + -- Respect a cache already used by Map:blockAt, without populating it. + if map.def._blockArray then + block = map.def._blockArray[index] + else + local lo, hi = blocks:byte(index * 2 - 1, index * 2) + if hi then + block = (lo + hi * 256) % 1024 + end + end + end + end + if not integer(block, 0) then + return nil, "block unavailable" + end + return block, metadata +end + +function Reads.liveMaps(world) + local active = world.map + local out = {} + local seen = {} + local function add(map, x, y, isActive) + if not map then + return + end + -- A connected layout can show one map at several different offsets. + -- Collapse duplicate placements only, retaining the spatial instances. + local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y) + if seen[key] then + return + end + seen[key] = true + local metadata = Reads.metadata(map) + out[#out + 1] = { + mapId = map.id, + x = x, + y = y, + active = isActive, + width = map.widthCells, + height = map.heightCells, + block = metadata, + } + end + add(active, 0, 0, true) + for _, neighbor in ipairs(world.neighbors or {}) do + add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false) + end + return out +end +return Reads diff --git a/src/pokemon/Gen45Hatch.lua b/src/pokemon/Gen45Hatch.lua index d03ff8bf1..edc23cab0 100644 --- a/src/pokemon/Gen45Hatch.lua +++ b/src/pokemon/Gen45Hatch.lua @@ -6,6 +6,7 @@ -- bank (Pt 357, HGSS 187). Black / White keep all three in one bank, 260. -- Each falls back to the engine's English when the cache lacks the line. +local Strings = require("src.core.Strings") local Gen45Hatch = {} Gen45Hatch.LINES = { @@ -37,9 +38,9 @@ end -- The three lines, with `name` (the species) filled in. function Gen45Hatch.lines(game, name) local rows = Gen45Hatch.LINES[family() or "platinum"] - return line(game, rows.oh, name, "Oh?"), - line(game, rows.hatched, name, ("%s hatched from the Egg!"):format(name)), - line(game, rows.ask, name, ("Would you like to nickname the newly\nhatched %s?"):format(name)) + return line(game, rows.oh, name, Strings("Oh?")), + line(game, rows.hatched, name, Strings("%s hatched from the Egg!", name)), + line(game, rows.ask, name, Strings("Would you like to nickname the newly\nhatched %s?", name)) end return Gen45Hatch diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..4ca2a5bea --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,54 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..359964018 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,6 +420,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +557,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end + self:advance() end return end @@ -534,25 +570,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.waiting = false - if self.contAdvance then - -- ContText / ManualTextScroll: keep the box, scroll one line - self.contAdvance = false - self.lineIndex = self.lineIndex + 1 - self:beginLine() - -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment - -- says, is "always called twice in a row" (home/text.asm:280-305) - self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR - else - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() - -- ClearScreenArea then DelayFrames 20: the box sits empty before the - -- next page starts typing (home/text.asm:236-240) - self.holdFrames = Timing.TEXT_PAGE_CLEAR - end + self:advance() end return end diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 79a875bd7..d313bddef 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,8 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + momMoney=0xD576,momSaving=0xD579,momWhich=0xD961,momSet=0xD962,momTrigger=0xD963, + decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, partyCount = 0xDA22, partySpecies = 0xDA23, partyMons = 0xDA2A, @@ -268,6 +270,8 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + momMoney=0xD851,momSaving=0xD854,momWhich=0xDC17,momSet=0xDC18,momTrigger=0xDC19, + decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, partyCount = 0xDCD7, partySpecies = 0xDCD8, partyMons = 0xDCDF, @@ -365,6 +369,12 @@ end function Gen2Save.setLayout(version) applyLayout((type(version) == "table" and version) or LAYOUTS[tostring(version)] or LAYOUT_GEN2) + -- An unreviewed hack's legacy fallback layout does not prove where its + -- decoration bytes live. Explicit custom layouts may name their own run. + if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then + O.decorations=nil + O.momMoney=nil;O.momSaving=nil;O.momWhich=nil;O.momSet=nil;O.momTrigger=nil + end end applyLayout(LAYOUT_GEN2) @@ -1024,6 +1034,9 @@ end -- decode: raw 32768-byte SRAM string -> save.lua-shaped table -- ------------------------------------------------------------------ +-- Physical WRAM order differs from the public semantic slot order. +local DECO_SLOTS={"bed","carpet","plant","poster","console","leftOrnament","rightOrnament","bigDoll"} + function Gen2Save.decode(bytes, data) assert(#bytes == Gen2Save.SAVE_SIZE, "expected a 32768-byte save") assert(charmap, "Gen2Save.setCharmap must be called before decode") @@ -1060,6 +1073,11 @@ function Gen2Save.decode(bytes, data) currentBox = 1, } + if O.decorations then + save.decorations={} + for i,key in ipairs(DECO_SLOTS)do save.decorations[key]=u8(bytes,O.decorations+i-1)end + end + -- bag: four pockets folded into one flat inventory local bagOrder = {} decodePairList(bytes, O.items, MAX_ITEMS, cw, save.inventory, bagOrder) @@ -1115,6 +1133,18 @@ function Gen2Save.decode(bytes, data) end end + if O.momMoney then + save.g2MomMoney=u24be(bytes,O.momMoney) + local mode=u8(bytes,O.momSaving) + save.g2MomSavingMode=mode%8 + save.g2MomSaving=save.g2MomSavingMode~=0 + save.g2MomBankOpen=hasBit(mode,7)or nil + save.flags.ENGINE_MOM_SAVING_MONEY=hasBit(mode,0) + save.flags.ENGINE_MOM_ACTIVE=hasBit(mode,7) + save.g2MomShopping={whichItem=u8(bytes,O.momWhich),whichSet=u8(bytes,O.momSet), + triggerBalance=u24be(bytes,O.momTrigger)} + end + -- wVisitedSpawns -> the FLY list. HasVisitedSpawn tests bit [spawn id]; -- this port spells the same set save.visited[mapId] (src/ui/TownMap.lua), -- so without this an imported save could not fly anywhere. @@ -1240,6 +1270,15 @@ function Gen2Save.encode(save, data, template) for i = 1, Gen2Save.SAVE_SIZE do buf[i] = zero end end + if O.decorations and type(save.decorations)=="table"then + for i,key in ipairs(DECO_SLOTS)do + local value=save.decorations[key]or 0 + assert(type(value)=="number"and value>=0 and value<=255 and value%1==0, + "invalid native decoration byte: "..key) + setByte(buf,O.decorations+i-1,value) + end + end + local player = save.player or {} local playerName = player.name or "GOLD" encodeName(buf, O.playerName, NAME_LENGTH, playerName) @@ -1296,6 +1335,30 @@ function Gen2Save.encode(save, data, template) end end + if O.momMoney then + local function nativeNumber(value,limit,label) + assert(type(value)=="number"and value%1==0 and value>=0 and value<=limit,"invalid native Mom "..label) + return value + end + if save.g2MomMoney~=nil then setU24be(buf,O.momMoney,nativeNumber(save.g2MomMoney,999999,"balance"))end + local value=buf[O.momSaving+1]:byte() + if save.g2MomSavingMode~=nil or save.g2MomSaving~=nil then + local mode=save.g2MomSavingMode~=nil and nativeNumber(save.g2MomSavingMode,7,"saving mode")or(save.g2MomSaving and 1 or 0) + if save.flags and save.flags.ENGINE_MOM_SAVING_MONEY~=nil then + mode=mode-mode%2+(save.flags.ENGINE_MOM_SAVING_MONEY and 1 or 0) + end + value=value-value%8+mode + end + if save.g2MomBankOpen~=nil then value=withBit(value,7,save.g2MomBankOpen==true)end + setByte(buf,O.momSaving,value) + if type(save.g2MomShopping)=="table"then + local state=save.g2MomShopping + setByte(buf,O.momWhich,nativeNumber(state.whichItem,255,"ladder index")) + setByte(buf,O.momSet,nativeNumber(state.whichSet or 0,255,"random row")) + setU24be(buf,O.momTrigger,nativeNumber(state.triggerBalance,999999,"random threshold")) + end + end + -- wVisitedSpawns: the FLY destinations this save has reached local spawnFlags = data and data.field and data.field.spawnFlags if type(spawnFlags) == "table" then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..6fc66e4ef 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1285,7 +1285,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) -- This is intentionally an event rather than a special-case starter hook: -- mods can use the same seam for story gifts, fossils, or custom scripts. local gift = { ctx = ctx, species = species, level = level } - if ctx.game.mods then + local giftEvents = ctx.game.mods and ctx.game.mods.events + if giftEvents and (not giftEvents.listeners or giftEvents.listeners["pokemon.before_give"]) then ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end diff --git a/src/script/DecodedScript.lua b/src/script/DecodedScript.lua new file mode 100644 index 000000000..12f3f190d --- /dev/null +++ b/src/script/DecodedScript.lua @@ -0,0 +1,155 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Compiler provenance lives outside public command rows and engine contexts. +local M = {} +local origins = setmetatable({}, { __mode = "k" }) + +function M.origin(rows, data, entry, compile, generation) + origins[rows] = { + data = setmetatable({ data, data.map_scripts.scripts }, { __mode = "v" }), + entry = entry, + compile = compile, + generation = generation, + } +end +function M.trace() + return { commands = {}, byKey = {}, owners = {}, labels = {}, returns = {}, returnKeys = {} } +end +function M.begin(t, label, index, ir) + local c = { key = label .. ":" .. index, label = label, index = index, ir = ir, returns = 0 } + t.commands[#t.commands + 1], t.byKey[c.key], t.current = c, c, c +end +function M.emit(t, row) + t.owners[row] = t.current +end +function M.returnLabel(t, label) + local c = t.current + if not c then + return + end + c.returns = c.returns + 1 + local key = c.key .. ":return:" .. c.returns + t.returns[label], t.returnKeys[key] = key, label +end +function M.finish(rows, t) + local positions = {} + for pc, row in ipairs(rows) do + positions[row] = pc + local c = t.owners[row] + if c and not c.pc then + c.pc = pc + end + end + t.starts = {} + local nextPC + for i = #t.commands, 1, -1 do + local c = t.commands[i] + local finish = assert(positions[t.labels[c.label]], "missing decoded script boundary") + if not nextPC or c.label ~= t.commands[i + 1].label then + nextPC = finish + end + c.after = nextPC + c.pc = c.pc or nextPC + nextPC = c.pc + end + for _, c in ipairs(t.commands) do + t.starts[c.pc] = t.starts[c.pc] or {} + t.starts[c.pc][#t.starts[c.pc] + 1] = c + end +end +function M.prepare(rows) + local stored = origins[rows] + if not stored then + return + end + local data = assert(stored.data[1], "decoded script source dataset has been retired") + assert( + data.map_scripts and data.map_scripts.scripts == stored.data[2], + "decoded script source cohort has been retired" + ) + -- Only an executing script borrows a strong source reference. The weak-key + -- compiler cache and this provenance table must not keep each other alive. + local origin = { data = data, entry = stored.entry, compile = stored.compile, generation = stored.generation } + local trace = M.trace() + local result = origin.compile(origin.data, origin.entry, { trace = trace }) + assert(M.same(result, rows), "decoded profile requires an unmodified native compiled script") + return rows, trace, origin +end +-- Recompile a private overlay; never change imported IR or the ordinary cache. +function M.rewrite(origin, oldTrace, overrides, command, ir, ctx) + overrides[command.label] = overrides[command.label] or {} + overrides[command.label][command.index] = ir + local scripts = {} + for label, rows in pairs(origin.data.map_scripts.scripts) do + if overrides[label] then + local copy = {} + for i, row in ipairs(rows) do + copy[i] = overrides[label][i] or row + end + scripts[label] = copy + else + scripts[label] = rows + end + end + local data, pool = {}, {} + for k, v in pairs(origin.data) do + data[k] = v + end + for k, v in pairs(origin.data.map_scripts) do + pool[k] = v + end + pool.scripts, data.map_scripts = scripts, pool + local trace = M.trace() + local rows = origin.compile(data, origin.entry, { trace = trace, keepLabels = oldTrace.labels }) + for _, field in ipairs({ "g2Stack", "g3Stack" }) do + for i, label in ipairs(ctx[field] or {}) do + local key = oldTrace.returns[label] + if key then + ctx[field][i] = assert(trace.returnKeys[key], "rewritten command removed an active return") + end + end + end + return rows, trace, assert(trace.byKey[command.key]) +end +function M.copy(value, seen) + if type(value) ~= "table" then + return value + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for k, v in pairs(value) do + out[k] = M.copy(v, seen) + end + return out +end +function M.same(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not M.same(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +function M.source(origin, command) + return origin.data.map_scripts.scripts[command.label][command.index] +end +return M diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..4302cb11f 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,27 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_apricorn_converted(ctx,apricorn,ball,event) + local Runtime=require("src.mods.Runtime") + if Runtime.wants("apricorn.converted")then + Runtime.emit("apricorn.converted",{apricorn=apricorn,ball=ball,event=event}) + end +end + +function Commands.g2_deco_visibility(ctx) + require('src.world.Gen2Decorations').visibility(ctx) +end +function Commands.g2_deco_tiles(ctx) + require('src.world.Gen2Decorations').tiles(ctx) +end +function Commands.g2_deco_description(ctx,kind,allowed) + local target,name=require('src.world.Gen2Decorations').description(ctx.game.data,ctx.save,kind) + if target and allowed and allowed[target]then + if name then setBuffer(ctx.game,3,name)end + return target + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here @@ -1098,6 +1119,12 @@ function Commands.g2_object(ctx, index, visible) obj, mapId = objectByIndex(ctx, index) end if not obj or not mapId then return end + return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) +end + +-- Shared native effect after resolving a map object. Public API adapters can +-- supply the resolved template without depending on cached script-ID numbering. +function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) local OverworldState = require("src.world.OverworldController") local name = OverworldState.objectToggleKey(obj) -- The PERSISTENT half, which this used to leave to a `setevent` the ROM @@ -1779,6 +1806,19 @@ end -- --------------------------------------------------------------------------- function Commands.g2_warp(ctx, mapId, x, y, facing) + if mapId=="NONE" then + local ow=ctx.overworld + if ow and ow.map and type(ow.reloadMap)=="function"then + local map=ow.map.id + ctx.afterScript=ctx.afterScript or{} + ctx.afterScript[#ctx.afterScript+1]=function() + if ow.player and DIRS[facing]then ow.player.facing=DIRS[facing]end + ow:reloadMap(map,"native-script") + end + return math.huge + end + return + end if type(mapId) ~= "string" or mapId == "" then return end Commands.warp(ctx, mapId, x, y, facing) end @@ -2054,13 +2094,33 @@ end function Gen2Commands.phoneName(data, id) local entry = phoneContact(data, id) if not entry then return nil end - if entry.name then return entry.name, entry.role end local best = phoneTrainerDef(data, entry) + if entry.name then return entry.name, entry.role or (best and best.name) end if not best then return nil end local names = best.partyNames return (names and names[entry.trainer]) or best.name, best.name end +-- Observation of an admitted native incoming call, never of a model-side +-- candidate. Keep the payload detached from the native row and queued IR. +function Gen2Commands.announceIncomingCall(data, id, special) + local Runtime = require("src.mods.Runtime") + if not Runtime.wants("phone.call_received") then return end + local row = phoneContact(data, id) or {} + local pool = data and data.map_scripts or {} + local specialRow = special and pool.specialCalls and pool.specialCalls[special] + local key = specialRow and specialRow.script or row.receive + local name, className = Gen2Commands.phoneName(data, id) + local constants = data and data.constants or {} + local class = (constants.trainerClassOrder or {})[row.class] + local member = class and (constants.trainerClassMembers or {})[class] + local call = {kind="call", direction="incoming", contact=id, + class=class, member=member and member[row.trainer], number=row.contactNumber, + script=key, scriptKey=key, special=special} + Runtime.emit("phone.call_received", {call=call, contact=id, + name=name, className=className, special=special, scriptKey=key}) +end + -- The row's second `dba` is the script PhoneCall (36:$4298) runs when the -- player rings the contact; the first is the one they run when they ring in. -- Both are walked into map_scripts.scripts by the extractor, so hand back the @@ -2122,14 +2182,14 @@ function Gen2Commands.phoneCallDelay(save) return GEN2_CALL_DELAYS[math.min(cycles, #GEN2_CALL_DELAYS - 1) + 1] end --- The sample GetAvailableCallers builds, in PHONE_* order. +-- The sample GetAvailableCallers builds, in saved phone-slot order. function Gen2Commands.availableCallers(data, save, mapDef, tod) local bit = GEN2_TOD_BIT[tod] or 2 local out = {} local group = mapDef and tonumber(mapDef.group) local number = mapDef and tonumber(mapDef.number) - for id, registered in pairs(phoneList(save)) do - if registered and type(id) == "number" then + for _, id in ipairs(require("src.world.Gen2PhoneBook").contacts(save)) do + if id ~= 0 then local entry = phoneContact(data, id) local mask = entry and tonumber(entry.receiveTime) or 0 -- `and [hl]` on the mask, then the map compare @@ -2139,10 +2199,19 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) end end end - table.sort(out) return out end +-- ChooseRandomCaller swaps one Random byte's nibbles, masks to five bits, +-- then takes the remainder against the eligible count. Preserve that exact +-- mapping and byte consumption rather than drawing a uniform list index. +function Gen2Commands.chooseRandomCaller(callers, rng) + if not callers or #callers == 0 then return nil end + local byte = (rng and rng() or math.random(0, 255)) % 256 + local swapped = (byte % 16) * 16 + math.floor(byte / 16) + return callers[(swapped % 32) % #callers + 1] +end + -- Returns the caller id and its compiled receive script, or nil. `minutes` is -- the play clock in minutes, which stands in for the ROM's day/hour/minute -- countdown; `entrance` is CheckStandingOnEntrance. @@ -2190,7 +2259,7 @@ function Gen2Commands.rollIncomingCall(data, save, mapDef, tod, minutes, entranc if math.floor(palette / 16) % 16 ~= 0 then return nil end local callers = Gen2Commands.availableCallers(data, save, mapDef, tod) if #callers == 0 then return nil end - local id = callers[math.random(#callers)] + local id = Gen2Commands.chooseRandomCaller(callers) local script = Gen2Commands.phoneReceiveScript(data, id) if not script then return nil end -- InitCallReceiveDelay at the tail of Script_ReceivePhoneCall (36:$42B7): @@ -2290,16 +2359,18 @@ end function Commands.g2_bank_of_mom(ctx) local save = ctx.save + local Mom=require("src.world.Gen2MomShopping") + Mom.prepareBank(save) -- CheckIfBankInitialized: the first visit opens the account and asks if save.g2MomBankOpen == nil then - save.g2MomBankOpen = true + Mom.openBank(save) Commands.ask(ctx, momLine(ctx, "saveMoney", "Should I save money for you?")) if ctx.lastCheck then - save.g2MomSaving = true + Mom.setSaving(save,true) return momSay(ctx, "startSavingMoney", "OK, I'll save your money.") end - save.g2MomSaving = false + Mom.setSaving(save,false) return momSay(ctx, "justDoWhatYouCan", "Just do what you can.") end @@ -2364,7 +2435,7 @@ function Commands.g2_bank_of_mom(ctx) momSay(ctx, "takenMoney", "Took ¥" .. amount .. ".") end) elseif choice == 3 then - save.g2MomSaving = not save.g2MomSaving + Mom.setSaving(save,not save.g2MomSaving) if save.g2MomSaving then momSay(ctx, "startSavingMoney", "OK, I'll save your money.") else @@ -2720,39 +2791,25 @@ end -- Phone_FindOpenSlot (36:$402D) fails when none of them is free. That cap is -- the whole point of the ".PhoneFull" branch every phone trainer carries -- an -- uncapped list can never reach it. -local GEN2_PHONE_SLOTS = 10 - -local function phoneListCount(save) - local n = 0 - for _, registered in pairs(phoneList(save)) do - if registered then n = n + 1 end - end - return n -end - -- AddPhoneNumber (36:$4000): carry means "not added" -- either the id is --- already in the list or there is no free slot. -local function addPhoneNumber(save, id) - local list = phoneList(save) - if list[id] then return false end - if phoneListCount(save) >= GEN2_PHONE_SLOTS then return false end - list[id] = true - return true +-- already in the list or there is no unreserved slot. Keep the same ordered +-- native book for the script verbs and Pokegear; the set remains compatible. +local function addPhoneNumber(ctx, id) + return require("src.world.Gen2PhoneBook").add( + ctx.data or (ctx.game and ctx.game.data), ctx.save, id) end function Commands.g2_cellnum(ctx, id, add) if type(id) ~= "number" then return end if add then - ctx.lastCheck = addPhoneNumber(ctx.save, id) + ctx.lastCheck = addPhoneNumber(ctx, id) else - local list = phoneList(ctx.save) - ctx.lastCheck = list[id] == true - list[id] = nil + ctx.lastCheck = require("src.world.Gen2PhoneBook").remove(ctx.save, id) end end function Commands.g2_check_cellnum(ctx, id) - ctx.lastCheck = phoneList(ctx.save)[id] == true + ctx.lastCheck = require("src.world.Gen2PhoneBook").has(ctx.save, id) ctx.g2Var = ctx.lastCheck and 1 or 0 end @@ -2783,7 +2840,7 @@ function Commands.g2_ask_cellnum(ctx, id, textId) ctx.g2Var = 2 return end - if addPhoneNumber(ctx.save, id) then + if addPhoneNumber(ctx, id) then ctx.g2Var = 0 else ctx.g2Var = 1 @@ -3002,72 +3059,60 @@ end -- run of `ifequal` rows that decide which ball Kurt starts on. -- --------------------------------------------------------------------------- --- RED, BLU, BLK, WHT, PNK, GRN, YLW -- the same ids Kurt1 `checkitem`s before --- it bothers to ask -local APRICORNS = { 85, 89, 92, 93, 97, 99, 101 } - function Commands.g2_select_apricorn(ctx) - local game, runner, save = ctx.game, ctx.runner, ctx.save - local Bag = require("src.inventory.Bag") - local Menu = require("src.ui.Menu") - local picked, held, items = 0, 0, {} - -- SelectApricornForKurt zeroes wKurtApricornQuantity on entry, so a - -- cancelled pick cannot leave the previous count standing. - save.g2KurtApricorns = 0 - for _, id in ipairs(APRICORNS) do - local key = string.format("ITEM_%03d", id) - local qty = save.inventory and save.inventory[key] or 0 - if qty > 0 then - local def = game.data.items and game.data.items[key] - items[#items + 1] = { - label = string.format("%s x%d", (def and def.name) or key, qty), - onSelect = function() picked, held = id, qty runner:resume() end, - } + local game,runner,save=ctx.game,ctx.runner,ctx.save + local Bag=require("src.inventory.Bag") + local Menu=require("src.ui.Menu") + local Apricorns=require("src.world.Gen2Apricorns") + local version=require("src.core.GameVersion").get() + local stock=version=="gold" or version=="silver" or version=="crystal" + save.g2KurtApricorns=0 + local selected=1 + while true do + local picked,held,key=0,0,nil + local items={} + for _,row in ipairs(Apricorns.inBag(game.data,save))do + local id,quantity,item=row.item,row.quantity,row.apricorn + local def=game.data.items and game.data.items[item] + items[#items+1]={label=string.format("%s x%d",(def and def.name) or item,quantity), + onSelect=function()picked,held,key=id,quantity,item;runner:resume()end} end - end - if #items == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - game.stack:push(Menu.new(game, items, { - onCancel = function() runner:resume() end, - })) - runner:yield() - if picked == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - - -- "How many should I make?" -- Kurt_SelectQuantity (engine/events/kurt.asm) - -- loads wItemQuantity with how many of that apricorn you HAVE and lets you - -- scroll 1..that, then Kurt_GiveUpSelectedQuantityOfSelectedApricorn takes - -- exactly that many and the count goes into wKurtApricornQuantity -- - -- VAR_KURT_APRICORNS, which the `verbosegiveitemvar` on the collection - -- branch reads back to decide how many balls to hand over. - -- - -- Taking one apricorn and never writing that var is what left the var at 0 - -- and, with verbosegiveitemvar unlowered as well, Kurt holding a ball he - -- could never give. - local count = 1 - if held > 1 then - local rows = {} - for n = 1, held do - rows[#rows + 1] = { - label = string.format("x%d", n), - onSelect = function() count = n runner:resume() end, - } + if #items==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + if stock then + items[#items+1]={label="CANCEL",onSelect=function()runner:resume()end} + end + local menu=Menu.new(game,items,{maxVisible=version=="crystal" and 4 or 8, + onCancel=function()runner:resume()end}) + menu.index=math.min(selected,#items);menu:clampScroll() + game.stack:push(menu);runner:yield() + if picked==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + selected=menu.index + local count,cancelled=1,false + -- Gold/Silver's special always tosses exactly one. Crystal asks even + -- for a one-item stack and goes back to the list on quantity cancel. + if version=="crystal" or version~="gold" and version~="silver" and held>1 then + local choices={} + for n=1,held do + local quantity=n + choices[#choices+1]={label=string.format("x%d",n), + onSelect=function()count=quantity;runner:resume()end} + end + game.stack:push(Menu.new(game,choices,{maxVisible=5, + onCancel=function()cancelled=true;runner:resume()end})) + runner:yield() + end + if not cancelled or version~="crystal" then + count=math.max(1,math.min(count,held)) + local available=tonumber(save.inventory and save.inventory[key]) or 0 + if available~=available or available 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, compile, 2) + compiled[scripts][key] = #out > 0 and out or false + end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen2ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- Prism renamed a good deal of Crystal's script vocabulary without changing -- what the commands DO -- `jump` for `sjump`, `if_equal` for `ifequal`, -- `end_all` for `endall`. An unlowered command is not an error, it just diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..4c3cd65df 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -716,12 +716,16 @@ function Commands.g2_pokemon_center_pc(ctx) answer(ctx, 0) end --- PlayerPC is a Menu of its own, so it closes on its LOG OFF row and on B. --- Neither reaches this script, so there is nothing to wait on: push it and --- answer, exactly as g2_card_flip does. +-- The house script branches on the accumulated decoration-change bit only +-- after LOG OFF/B. Returning before the menu closes silently skips its reload. function Commands.g2_players_house_pc(ctx) - require("src.ui.Screens").push(ctx.game, "PlayerPC") - answer(ctx, 0) + local runner=ctx.runner + local done,changed=false,false + require("src.ui.Screens").push(ctx.game,"PlayerPC",{house=true,onDone=function(value) + if done then return end;done=true;changed=value==true;runner:resume() + end}) + runner:yield() + answer(ctx,changed and 1 or 0) end -- --------------------------------------------------------------------------- diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..7de34bef5 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -36,6 +36,7 @@ -- mis-frame most of the dialogue in Hoenn. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") require("src.script.Gen3Commands") @@ -80,7 +81,10 @@ Gen3ScriptVM.flagName = flagName local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end local droppedBranch = {} @@ -803,19 +807,20 @@ end Gen3ScriptVM.store = store -function Gen3ScriptVM.compile(data, entry) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][entry] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, @@ -827,33 +832,53 @@ function Gen3ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return ("%s_r%d"):format(entry, counter) + local label = ("%s_r%d"):format(entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) -- lastText does NOT survive across script boundaries: a callstd at the -- top of one script must not print the line the previous one loaded state.lastText = nil - for _, ir in ipairs(scripts[label] or {}) do + for irIndex, ir in ipairs(scripts[label] or {}) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local lower = L[ir[1]] if lower then lower(ir, state) end end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g3_return" }) + local finish = { "g3_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end if #out == 0 then Logger.warn("gen3 script vm: '%s' compiled to zero rows (silent NPC)", entry) end - compiled[scripts][entry] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, compile, 3) + compiled[scripts][entry] = #out > 0 and out or false + end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen3ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- --------------------------------------------------------------------------- -- registration -- --------------------------------------------------------------------------- diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..3088c5ee0 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -175,10 +175,20 @@ end -- an array of rows; control commands return a new program counter, as a -- row number or a label name. function ScriptRunner:exec(script, ctx) + local Decoded, trace, origin + local decodedHooks = Runtime.hooks._decodedHooks + if decodedHooks and decodedHooks.chains["script.command"] then + Decoded = require("src.script.DecodedScript") + local prepared + prepared, trace, origin = Decoded.prepare(script) + script = prepared or script + end local labels = ScriptRunner.scanLabels(script) local data = self.game and self.game.data local pc = 1 - while pc <= #script do + local function step() + if pc > #script then return end + if trace and ctx.scriptCancelled then pc = math.huge; return end local row = script[pc] local name = row[1] -- THE ROW THE RUNNER IS STANDING ON, kept so a script that parks and @@ -273,6 +283,68 @@ function ScriptRunner:exec(script, ctx) end end end + if not trace then + while pc <= #script do step() end + return + end + local overrides, visited = {}, {} + local dispatch + dispatch = function() + local candidates = trace and trace.starts[pc] + local command + for _, candidate in ipairs(candidates or {}) do + if not visited[candidate.key] then command = candidate; break end + end + if not command then + step() + visited = {} + else + visited[command.key] = true + local positive = command.after > command.pc + local ran, active, completed = false, true, false + local function vanilla(hctx, op, ir) + assert(active, "decoded next is only valid during its callback") + assert(not ran, "decoded command already executed") + assert(hctx == ctx, "decoded next must retain its native script context") + ran = true + if op ~= command.ir[1] or not Decoded.same(ir, command.ir) then + local copy = {}; for k, v in pairs(ir) do copy[k] = v end; copy[1] = op + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, copy, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.pc + end + local first, after = command.pc, command.after + local nestedCall = origin.generation == 2 and (op == "scall" or op == "farscall") + local depth = nestedCall and #(ctx.g2Stack or {}) or 0 + positive = after > first + while pc >= first and pc < after and pc <= #script do step() end + -- Gen-2 decoded calls are synchronous: their hook's post-next code + -- observes the completed subroutine, including any native suspension. + if nestedCall then + while pc <= #script and not ctx.scriptCancelled and #(ctx.g2Stack or {}) > depth do dispatch() end + end + completed = true + end + local copy = Decoded.copy(Decoded.source(origin, command)) + local ok, err = pcall(decodedHooks.call, decodedHooks, "script.command", vanilla, ctx, copy[1], copy) + active = false + if not ok then error(err, 0) end + assert(not ran or completed, "decoded native command did not complete; downstream failure cannot be swallowed") + if not ran then + -- Suppress compile-time state as well (loadword/text fusion), once for + -- this visit. The next visit still offers the original source opcode. + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, { "_decoded_skip" }, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.after + positive = command.after > command.pc + end + if positive then visited = {} end + end + end + while pc <= #script do + dispatch() + if ctx.scriptCancelled then return end + end end -- Called by blocking commands from inside the coroutine. diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 13566a36a..8fee26e5a 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1244,11 +1244,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..ba7edbdd0 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts) return self end +function ChoiceBox:choose(index) + if self.pending ~= nil then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.index = index + self.pending = index == 1 + self.holdFrames = Timing.YES_NO_ANSWER +end + function ChoiceBox:update(dt) local input = self.game.input -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still @@ -48,21 +60,9 @@ function ChoiceBox:update(dt) if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - self.pending = (self.index == 1) - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(self.index) elseif input:wasPressed("b") then - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so - -- the cursor visibly snaps to NO for those 15 frames - self.index = 2 - self.pending = false - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(2) end end diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua new file mode 100644 index 000000000..381b63b04 --- /dev/null +++ b/src/ui/Gen2DecorationMenu.lua @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The PC changes placed bytes. Room callbacks render them after native reload. +local Native=require('src.world.Gen2Decorations') +local Menu=require('src.ui.Menu') +local Runner=require('src.script.ScriptRunner') +local Strings=require('src.core.Strings') +local M={} +function M.new(game,opts) + opts=opts or{} + local changed,finished=false,false + local programs=Native.pool(game.data)and Native.pool(game.data).decorationPrograms + local texts=programs and programs.texts or{} + local function finish() + if finished then return end;finished=true + if opts.onDone then opts.onDone(changed)end + end + local function say(role,onDone,extra) + local id=texts[role] + if not id then if onDone then onDone()end;return end + local r=Runner.new(game,game.overworld) + r:run({{'show_text',id,nil,extra}}, {onDone=onDone}) + return r + end + local function perform(id,side) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + local state=Native.state(game.data,game.save) + if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end + local placement=Native.placement(game.data,state,id,side) + if not placement then return end + game.stringBuffers=game.stringBuffers or{} + for n,name in pairs(placement.buffers)do game.stringBuffers[n]=name end + say(placement.role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + end + local function select(id) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + if not action then return end + if not action.ornament then perform(id);return end + local selected,question + local function close(side) + selected=side + if question then game.stack:pop();question:resume() + elseif side then perform(id,side)end + end + local function sides() + game.stack:push(Menu.new(game,{ + {label=Strings('RIGHT SIDE'),onSelect=function()close('right')end}, + {label=Strings('LEFT SIDE'),onSelect=function()close('left')end}, + {label=Strings('CANCEL'),onSelect=function()close()end}, + },{tx=6,ty=6,tw=14,th=8,noSound=true,onCancel=function()close()end})) + end + local role=action.put and 'sidePut'or 'sideSetup' + if texts[role]then + question=say(role,function()if selected then perform(id,selected)end end,{stay={onShown=sides}}) + else sides()end + end + local function category(key) + local items={} + for _,c in ipairs(Native.categories(game.data))do + if c.slot==key then + for _,id in ipairs(c.members)do if Native.owns(game.data,game.save,id)then items[#items+1]=id end end + return items,c.headers + end + end + return items,{} + end + local rows={} + if Native.ready(game.data)and Native.state(game.data,game.save)then + for _,c in ipairs(Native.categories(game.data))do + local key=c.slot;local ids=category(key) + if #ids>0 then + rows[#rows+1]={label=Strings(c.label),keepOpen=true,onSelect=function() + local owned,headers=category(key) + if #owned==0 then say('noChoice');return end + local items={} + for _,id in ipairs(owned)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + for _,id in ipairs(headers)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + items[#items+1]={label=Native.name(game.data,0)~=''and Native.name(game.data,0)or Strings('CANCEL')} + game.stack:push(Menu.new(game,items,{tx=0,ty=0,tw=20,maxVisible=8,noSound=true})) + end} + end + end + end + rows[#rows+1]={label=Strings('EXIT'),onSelect=finish} + return Menu.new(game,rows,{tx=5,ty=0,tw=15,maxVisible=8,noSound=true,onCancel=finish}) +end +return M diff --git a/src/ui/Gen2MapRadio.lua b/src/ui/Gen2MapRadio.lua new file mode 100644 index 000000000..f93655920 --- /dev/null +++ b/src/ui/Gen2MapRadio.lua @@ -0,0 +1,65 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A wall radio owns its native script until the player closes the broadcast. +-- Native Pokegear program text is reused; this is not a ROM show-state port. +local Radio=require("src.world.Gen2Radio") +local Gear=require("src.ui.PokegearMenu") +local M={};M.__index=M;M.isOpaque=false +function M.new(game,opts) + opts=opts or {} + assert(require("src.core.GameVersion").isGen2(),"MapRadio requires a Gen2 host") + if opts.save and opts.save~=game.save then game=setmetatable({save=opts.save,writeSave=false},{__index=game})end + local world=game.overworld + local map=world and world.map + map=map and (map.def or map) + local tod=opts.timeOfDay or (world and world.timeOfDay and world:timeOfDay()) or "DAY" + local channel=tonumber(opts.channel) or 0 + local self=setmetatable({game=game,onDone=opts.onDone,channel=channel,hold=100,frames=0,line=1},M) + self.map,self.tod=map,tod + self.station=self:resolveStation(channel) + -- Reuse only the drawing/program helpers. Opening a wall radio must not + -- initialize Pokegear cards, roaming encounters or its portrait sheets. + self.gear=setmetatable({game=game},{__index=Gear}) + for _,row in ipairs(self.gear:stationList())do if row.id==self.station then self.program=row;break end end + if self.station then self.music=require("src.world.Gen2RadioAudio").tune(game,self.station)end + Radio.notify(game.data,self.station,channel,"map_radio") + return self +end +function M:exit() + local playing=self.music;self.music=nil + require("src.world.Gen2RadioAudio").finish(self.game,playing) +end +function M:name()return self.station and Radio.stationName(self.game.data,self.station)end +function M:resolveStation(channel) + local id=Radio.resolve(self.game.data,channel,self.map,self.tod) + return id,id and Radio.stationName(self.game.data,id) +end +function M:close() + if self.closed or self.game.stack:top()~=self then return false end + self.closed=true + self.game.stack:pop() + if self.onDone then self.onDone()end + return true +end +function M:update(_dt) + if self.closed then return end + if self.hold>0 then self.hold=self.hold-1;return end + local input=self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b"))then self:close();return end + self.frames=self.frames+1 + local lines=self.program and self.program.lines or {} + if self.frames%100==0 and #lines>0 then self.line=self.line+2;if self.line>#lines then self.line=1 end end +end +function M:draw() + self.gear:textbox(0,12,18,4) + local lines=self.program and self.program.lines or {} + if self.hold>0 or #lines==0 then self.gear:text('"'..(self:name() or '')..'"',1,14,Radio.isLiteral(self.game.data,self.station)) + else self.gear:printBoxText(lines[self.line] or "",lines[self.line+1] or "")end +end +M.channelRecord=Radio.channelRecord +M.STATIONS=Radio.STATIONS +function M.registerInto(registry,data,owner) + local n=0 + for id,row in pairs(Radio.defaults())do registry:register(id,row,owner);n=n+1 end + return n +end +return M diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..855f2c805 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -1015,6 +1015,38 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone) self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end +function Gen3PartyMenu:confirm() + if self.index > #self:party() then + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- the CANCEL button VALIDATES when a team is being chosen; everywhere + -- else it is the way out + if self.chooseOrder then + return self:confirmOrder() + end + return self:close() + end + self:choose() +end + +function Gen3PartyMenu:cancel() + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- B drops a SWITCH that was half-made before it closes the screen + if self.switchFrom then + self.switchFrom = nil + return + end + if self.chooseOrder then + return self:handOrder(nil) + end + self:close() +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1073,20 +1105,9 @@ function Gen3PartyMenu:update(dt) self.index = 1 end elseif input:wasPressed("a") then - if self.index > #self:party() then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- the CANCEL button VALIDATES when a team is being chosen; everywhere - -- else it is the way out - if self.chooseOrder then return self:confirmOrder() end - return self:close() - end - self:choose() + self:confirm() elseif input:wasPressed("b") then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- B drops a SWITCH that was half-made before it closes the screen - if self.switchFrom then self.switchFrom = nil return end - if self.chooseOrder then return self:handOrder(nil) end - self:close() + self:cancel() end end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..c717f362f 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -886,6 +886,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -918,23 +940,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/Gen5PokeTransferScreen.lua b/src/ui/Gen5PokeTransferScreen.lua index 0f4be03ad..a710de122 100644 --- a/src/ui/Gen5PokeTransferScreen.lua +++ b/src/ui/Gen5PokeTransferScreen.lua @@ -103,11 +103,12 @@ function S:picked(e) for i, p in ipairs(self.picks) do if p == e then return i end end end -local REFUSE = { - egg = "An Egg cannot be transferred.", - item = "A Pokémon holding an item cannot\nbe transferred.", - hm = "This Pokémon knows a hidden move.\nIt cannot be transferred.", -} +local function refusal(reason) + if reason == "egg" then return Strings("An Egg cannot be transferred.") end + if reason == "item" then return Strings("A Pokémon holding an item cannot\nbe transferred.") end + if reason == "hm" then return Strings("This Pokémon knows a hidden move.\nIt cannot be transferred.") end + return Strings("This Pokémon cannot be transferred.") +end function S:toggle(e) local at = self:picked(e) @@ -115,7 +116,7 @@ function S:toggle(e) if #self.picks >= T().MONS then return end if not e.walker then local why = T().refusal(self.srcData, e.raw) - if why then return self:say(Strings(REFUSE[why] or "This Pokémon cannot be transferred.")) end + if why then return self:say(refusal(why)) end end self.picks[#self.picks + 1] = e if #self.picks == T().MONS then diff --git a/src/ui/HgssAthleteShop.lua b/src/ui/HgssAthleteShop.lua index d9d51199d..7db88187b 100644 --- a/src/ui/HgssAthleteShop.lua +++ b/src/ui/HgssAthleteShop.lua @@ -30,6 +30,7 @@ -- (overlay 31's line for that case was not traced); a taken prize is drawn -- like any other (whether overlay 31 dims it was not read). +local Strings = require("src.core.Strings") local T = require("src.import.Gen4Text") local M = {} @@ -101,7 +102,7 @@ local function choose(self) return end self.mode, self.yesNo = "confirm", 1 - self.message = bank(self, 46, "Would you like\n" .. (def.name or "") .. "?", def.name or "") + self.message = bank(self, 46, Strings("Would you like\n%s?", def.name or ""), def.name or "") end local function transact(self) @@ -115,15 +116,15 @@ local function transact(self) if self.athlon.kind == "daily" then if isApricorn(id) then require("src.world.HgssApricorns").give(save, id - M.APRICORN_FIRST, 1) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) else require("src.inventory.Bag").add(save, id, 1, self.game.data) - self.message = self:line(6, "Here you are!\nThank you!", def.name or "", self:pocketName(def)) + self.message = self:line(6, Strings("Here you are!\nThank you!"), def.name or "", self:pocketName(def)) end S.takeDaily(save, self.cursor - 1) else S.giveCard(save, (tonumber(id) or 0) - M.DATA_CARD_01) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) end -- MartData_SubCurrency (PokeathlonSave_SubAthletePoints: not below 0) and -- GAME_STAT_CURRENCY_SPENT diff --git a/src/ui/HgssPokeathlonTeamInfo.lua b/src/ui/HgssPokeathlonTeamInfo.lua index aa75d3eaa..7a5c33749 100644 --- a/src/ui/HgssPokeathlonTeamInfo.lua +++ b/src/ui/HgssPokeathlonTeamInfo.lua @@ -48,6 +48,7 @@ -- * the OBJ set's sequence 5 (a sparkle, created hidden at the Medals and -- never shown by ov97) and 6 are not drawn +local Strings = require("src.core.Strings") local Font = require("src.render.Font") local T = require("src.import.Gen4Text") @@ -199,7 +200,7 @@ function M.draw(game, opts) elseif r.gender == "female" then text(game, line(game, 3, "♀"), 76, 120, 3, 4) end for _, l in ipairs(M.LABELS) do text(game, line(game, l[1], ""), 104, l[2]) end local sp = speciesName(game, mon) - text(game, line(game, 9, "Medals won by\n" .. sp, sp), 8, 152) + text(game, line(game, 9, Strings("Medals won by\n%s", sp), sp), 8, 152) -- the OBJs: the picture, the stars and arrows, the Medals local pic = picture(game, mon) if pic then diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..4b487d623 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -472,6 +472,399 @@ function PartyMenu:close() if self.game.stack:top() == self then self.game.stack:pop() end end +-- Native confirmation shared by input and the public UI facade. +function PartyMenu:confirm() + local party = self.party or self.game.save.party + if self.submenu then + local mon = party[self.index] + local entry = self.subItems[self.subIndex] + local action = entry.action + if not action and entry.onSelect then + -- hook-injected entries carry a callback instead of an action id + entry.onSelect(mon, self.game) + elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) + Screens.push(self.game, "SummaryMenu", mon, { + mons = party, + index = self.index, + onMonChange = function(index) self.index = index end, + }) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif action == "switch" then + self.swapFrom = self.index + elseif action == "item" then + self:openItemMenu(mon) + elseif action == "quit" then + self.submenu = nil + elseif action == "fly" then + -- FLY opens the TOWN MAP with a cursor over the visited fly towns, + -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). + -- flyTo (OverworldController) validates the fly-warp + runs the + -- departure/warp, so we just hand it the chosen mapId (#195). + local ow = self.game.overworld + self.game.stack:pop() -- close the party menu + Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if ow then ow:flyTo(mapId) end + end }) + return + elseif action == "flash" then -- FLASH lights dark tunnels + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs + -- with the party menu still on screen, and only then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads + -- over the menu, and the cave is lit when the blink hands the + -- screen back, never under the text (#385). + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + -- The other half of CheckUseFlash: using it in the Aerodactyl chamber + -- opens the wall. The chamber's scene script plays the opening the + -- next time the map loads, exactly as the escape-rope one does. + require("src.script.RuinsOfAlph").aerodactylChamber(self.game) + self.game.save.flashLit = true + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._FlashLightsAreaText + or Strings("A blinding FLASH\nlights the area!"), function() + self:close() + -- setDark, not a bare field write: ADVANCED carries the darkness + -- in a baked atlas, so lighting the cave drops every resident map + -- and rebakes this one (#383). It runs HERE, before the blink, + -- because start_sub_menus.asm .flash clears wMapPalOffset before + -- PrintText and blinks last of all: the cave is already lit by the + -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the + -- blink's completion instead left that rebuild's whole cost -- + -- seconds of per-pixel atlas baking on a phone -- on screen as a + -- solid white frame with nothing under it, which reads as a + -- lockup (#610). + ow:setDark(false) + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + elseif action == "surf" then + -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time + -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- current refusals, both of which loop back to the submenu), then + -- ItemUseSurfboard: while surfing it tries to dismount instead; + -- otherwise it mounts only if the FACING tile is water, else + -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the + -- submenu. useSurfFieldMove reports which; trySurf does the mount. + local ow = self.game.overworld + local reason = ow:useSurfFieldMove() + local Transition = require("src.render.Transition") + if reason == "ok" then + -- UseItem prints _SurfingGotOnText with the party menu still up; + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so + -- trySurf closes this menu when its text does (#385) + local fx, fy = ow.player:facingCell() + ow:trySurf(fx, fy, function() self:close() end) + return + end + if reason == "dismount" then + -- ItemUseSurfboard .stopSurfing: no text -- the walking state + -- and music return first (PlayDefaultMusic + + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the + -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press + -- steps the player forward onto land (or across a connection + -- strip when the shore is the next map's edge) + self.game.stack:pop() + ow.player.surfing = false + require("src.core.Music").setSurfing(self.game.data, false) + self.game.stack:push(Transition.whiteFlash(self.game, nil, function() + ow:stepForwardOrCrossEdge(ow.player.facing) + end)) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = ({ no_badge = "_NewBadgeRequiredText", + forced_bike = "_CyclingIsFunText", + current = "_CurrentTooFastText", + no_place = "_SurfingNoPlaceToGetOffText" })[reason] + or "_NoSurfingHereText" + local txt = (self.game.data.text[key] or Strings("No SURFing here!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + if reason == "no_place" then + -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but + -- never zeroes wActionResultOrTookBattleTurn, so unlike the + -- other refusals the menu still closes afterwards + -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints + -- over the still-open menu like every other .loop refusal (#385) + self.game.stack:push(TextBox.new(self.game, txt, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + end + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "cut" then + -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): + -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the + -- submenu when the FACING tile isn't a cuttable tree. + local ow = self.game.overworld + local reason = ow:useCutFieldMove() + if reason == "ok" then + self.game.stack:pop() -- close the party menu (CloseTextDisplay) + local fx, fy = ow.player:facingCell() + ow:tryCut(fx, fy) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = (reason == "no_badge") and "_NewBadgeRequiredText" + or "_NothingToCutText" + local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "strength" then + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- predef PrintStrengthText (field_move_messages.asm) sets + -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate + -- push_boulder.asm reads -- then prints _UseStrengthText (no + -- prompt: after the text, the text_asm tail plays the chosen + -- mon's cry, Delay3, and it auto-advances) and + -- _MoveBoulderText (`prompt`: waits for A/B). Back in + -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white + -- before CloseTextDisplay returns to the map. + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + local def = self.game.data.pokemon[mon.species] + local name = mon.nickname or (def and def.name) or mon.species + ow.strengthActive = true + -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; + -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. + local t1raw = self.game.data.text._UseStrengthText + or self.game.data.text._UsedStrengthText + local t2raw = self.game.data.text._MoveBoulderText + or self.game.data.text._CanMoveBouldersText + local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) + local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) + -- like surf (#320, #385): both texts print with the party menu + -- still on screen, and the blink IS the menu closing afterwards, + -- not a flashbang on the empty map + self.game.stack:push(TextBox.new(self.game, t1, function() + self.game.stack:push(TextBox.new(self.game, t2, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + end, { auto = { sound = function() + return require("src.core.Sound").playCry(self.game.data, mon.species) + end } })) + return + elseif action == "gen2_field" then + -- GSC runs the same handler from the menu as from the overworld + -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the + -- tile check lives in one place. + local ow = self.game.overworld + local fx, fy = ow.player:facingCell() + self:close() + if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then + local TextBox = require("src.render.TextBox") + -- _CantSurfText is "You can't SURF here", so using it for every + -- field move told a player holding HEADBUTT that the game thought + -- they had picked SURF. Only SURF gets the surf line. + local text = (entry.move == "SURF") + and self.game.data.text._CantSurfText or nil + self.game.stack:push(TextBox.new(self.game, + text or Strings("You can't use that\nhere."))) + end + return + elseif action == "softboiled" then + -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer + -- 1/5 of the user's max HP to a chosen teammate + self.softboiledFrom = self.index + elseif action == "dig" then + -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as + -- a DOOR warp so the player steps out of the opening rather than + -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares + -- TELEPORT's Pokemon Center warp, so it falls through to the same + -- departure with no target. + local ow = self.game.overworld + self.game.stack:pop() + if ow then + local escape = require("src.core.GameVersion").isGen2() + and ow.escapePoint and ow:escapePoint() and true or nil + ow:beginTeleportOut(nil, { escape = escape }) + end + return + elseif action == "escape" then + -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, + -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction + -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup + -- / wLastSpawnMapNumber, so this destination is right for both + -- generations. Only DIG diverged; it has its own branch above. + -- pokered's .dig/.teleport spin the + -- player up (LeaveMapAnim), white/fade out, then land it; this port + -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut + -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares + -- the exact departure; the fade + warp fire when the spin ends. + local ow = self.game.overworld + self.game.stack:pop() + if ow then ow:beginTeleportOut() end + return + end + self.submenu = nil + return + end + if #party == 0 then return end + local mon = party[self.index] + if self.softboiledFrom then + local user = party[self.softboiledFrom] + local heal = math.floor(user.stats.hp / 5) + if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp + or user.hp <= heal then + self.softboiledFrom = nil + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) + else + user.hp = user.hp - heal + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + self.softboiledFrom = nil + require("src.core.Sound").play(self.game.data, "Heal_HP") + local def = self.game.data.pokemon[mon.species] + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + Strings("%s's HP\nwas restored!", mon.nickname or def.name))) + end + elseif self.swapFrom then + if self.swapFrom ~= self.index then + party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + require("src.core.Sound").play(self.game.data, "Swap") + end + self.swapFrom = nil + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then + -- keepOpen callers (HP medicine) need the menu still drawn while the + -- bar fills and the message prints, and close it themselves; everyone + -- else keeps the old pop-then-call order. Popping first is what made + -- a POTION snap the picker shut before the item had even run (#252). + if not self.keepOpen then self.game.stack:pop() end + self.onSwitch(mon, self) + else + self.submenu = true + self.subIndex = 1 + local items + local ow = self.game.overworld + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = Strings("SWITCH"), action = "battle_switch" }, + { label = Strings("STATS"), action = "stats" }, + { label = Strings("CANCEL"), action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = Strings("STATS"), action = "stats" }, + { label = Strings("SWITCH"), action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) + -- The badge gate per field move comes from constants.hmBadges and + -- the badge itself through Badges.has: Gen2 gates the same moves on + -- the Johto badges and stores them as engine flags rather than bag + -- items, so the hard-coded R/B item names listed nothing at all. + local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} + local function badged(moveId) + local gate = gates[moveId] + if not (gate and gate.badge) then return true end + return Badges.has(self.game.save, { id = gate.badge }) + end + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside and badged("FLY") then + table.insert(items, { label = Strings("FLY"), action = "fly" }) + -- LIGHT is carved on the Aerodactyl chamber wall, and the + -- chamber is not a dark cave -- so CheckUseFlash asks + -- SpecialAerodactylChamber FIRST and lets FLASH be used on its + -- carry, before it ever looks at the palset + -- (engine/events/overworld.asm:285). Without this the move is + -- never even offered there and the wall can never open. + elseif mv.id == "FLASH" and badged("FLASH") + and (ow.dark or (ow.map and ow.map.id + == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then + table.insert(items, { label = Strings("FLASH"), action = "flash" }) + elseif mv.id == "CUT" and badged("CUT") then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(items, { label = Strings("CUT"), action = "cut" }) + elseif mv.id == "SURF" and badged("SURF") then + table.insert(items, { label = Strings("SURF"), action = "surf" }) + elseif mv.id == "STRENGTH" and badged("STRENGTH") then + table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) + elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "softboiled" }) + elseif mv.id == "TELEPORT" and outside then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) + elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" + and (DIG_TILESETS[ow.map.def.tileset] + or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] + and ow.escapePoint and ow:escapePoint())) then + -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel). + -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a + -- recorded entrance to come back out of. Its own action, + -- because Gen 2's DIG and TELEPORT no longer share a + -- destination the way Gen 1's did. + table.insert(items, { label = Strings("DIG"), action = "dig" }) + elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then + -- GSC's own field moves; the tile check happens on selection, + -- exactly like CUT's (start_sub_menus.asm never gates the list + -- on where the player is standing) + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "gen2_field", move = mv.id }) + end + end + end + if require("src.core.GameVersion").isGen2() then + -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, + -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. + local stats, switch = table.remove(items, 1), table.remove(items, 1) + items[#items + 1] = stats + items[#items + 1] = switch + items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings("QUIT"), action = "quit" } + end + end + local ctx = { battle = self.battle, overworld = ow } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + end + self.subItems = items + end +end + +function PartyMenu:cancel() + if self.submenu then self.submenu = nil; return end + self.game.stack:pop() + if self.onCancel then self.onCancel() end +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -505,245 +898,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then - self.submenu = nil + self:cancel() elseif input:wasPressed("a") then - local mon = party[self.index] - local entry = self.subItems[self.subIndex] - local action = entry.action - if not action and entry.onSelect then - -- hook-injected entries carry a callback instead of an action id - entry.onSelect(mon, self.game) - elseif action == "stats" then - -- battle and field alike return to the party list afterwards - -- (core.asm .partyMenuWasSelected) - Screens.push(self.game, "SummaryMenu", mon, { - mons = party, - index = self.index, - onMonChange = function(index) self.index = index end, - }) - elseif action == "battle_switch" then - self.game.stack:pop() - self.onSwitch(mon) - return - elseif action == "cancel" then - self.game.stack:pop() - if self.onCancel then self.onCancel() end - return - elseif action == "switch" then - self.swapFrom = self.index - elseif action == "item" then - self:openItemMenu(mon) - elseif action == "quit" then - self.submenu = nil - elseif action == "fly" then - -- FLY opens the TOWN MAP with a cursor over the visited fly towns, - -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). - -- flyTo (OverworldController) validates the fly-warp + runs the - -- departure/warp, so we just hand it the chosen mapId (#195). - local ow = self.game.overworld - self.game.stack:pop() -- close the party menu - Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) - if ow then ow:flyTo(mapId) end - end }) - return - elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs - -- with the party menu still on screen, and only then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads - -- over the menu, and the cave is lit when the blink hands the - -- screen back, never under the text (#385). - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- The other half of CheckUseFlash: using it in the Aerodactyl chamber - -- opens the wall. The chamber's scene script plays the opening the - -- next time the map loads, exactly as the escape-rope one does. - require("src.script.RuinsOfAlph").aerodactylChamber(self.game) - self.game.save.flashLit = true - self.game.stack:push(TextBox.new(self.game, - self.game.data.text._FlashLightsAreaText - or Strings("A blinding FLASH\nlights the area!"), function() - self:close() - -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave drops every resident map - -- and rebakes this one (#383). It runs HERE, before the blink, - -- because start_sub_menus.asm .flash clears wMapPalOffset before - -- PrintText and blinks last of all: the cave is already lit by the - -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the - -- blink's completion instead left that rebuild's whole cost -- - -- seconds of per-pixel atlas baking on a phone -- on screen as a - -- solid white frame with nothing under it, which reads as a - -- lockup (#610). - ow:setDark(false) - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F - -- current refusals, both of which loop back to the submenu), then - -- ItemUseSurfboard: while surfing it tries to dismount instead; - -- otherwise it mounts only if the FACING tile is water, else - -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the - -- submenu. useSurfFieldMove reports which; trySurf does the mount. - local ow = self.game.overworld - local reason = ow:useSurfFieldMove() - local Transition = require("src.render.Transition") - if reason == "ok" then - -- UseItem prints _SurfingGotOnText with the party menu still up; - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so - -- trySurf closes this menu when its text does (#385) - local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy, function() self:close() end) - return - end - if reason == "dismount" then - -- ItemUseSurfboard .stopSurfing: no text -- the walking state - -- and music return first (PlayDefaultMusic + - -- LoadWalkingPlayerSpriteGraphics), the menu closes with the - -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press - -- steps the player forward onto land (or across a connection - -- strip when the shore is the next map's edge) - self.game.stack:pop() - ow.player.surfing = false - require("src.core.Music").setSurfing(self.game.data, false) - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:stepForwardOrCrossEdge(ow.player.facing) - end)) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = ({ no_badge = "_NewBadgeRequiredText", - forced_bike = "_CyclingIsFunText", - current = "_CurrentTooFastText", - no_place = "_SurfingNoPlaceToGetOffText" })[reason] - or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or Strings("No SURFing here!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - if reason == "no_place" then - -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but - -- never zeroes wActionResultOrTookBattleTurn, so unlike the - -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints - -- over the still-open menu like every other .loop refusal (#385) - self.game.stack:push(TextBox.new(self.game, txt, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - end - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "cut" then - -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. - local ow = self.game.overworld - local reason = ow:useCutFieldMove() - if reason == "ok" then - self.game.stack:pop() -- close the party menu (CloseTextDisplay) - local fx, fy = ow.player:facingCell() - ow:tryCut(fx, fy) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = (reason == "no_badge") and "_NewBadgeRequiredText" - or "_NothingToCutText" - local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); - -- predef PrintStrengthText (field_move_messages.asm) sets - -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate - -- push_boulder.asm reads -- then prints _UseStrengthText (no - -- prompt: after the text, the text_asm tail plays the chosen - -- mon's cry, Delay3, and it auto-advances) and - -- _MoveBoulderText (`prompt`: waits for A/B). Back in - -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white - -- before CloseTextDisplay returns to the map. - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - local def = self.game.data.pokemon[mon.species] - local name = mon.nickname or (def and def.name) or mon.species - ow.strengthActive = true - -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; - -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. - local t1raw = self.game.data.text._UseStrengthText - or self.game.data.text._UsedStrengthText - local t2raw = self.game.data.text._MoveBoulderText - or self.game.data.text._CanMoveBouldersText - local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320, #385): both texts print with the party menu - -- still on screen, and the blink IS the menu closing afterwards, - -- not a flashbang on the empty map - self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - end, { auto = { sound = function() - return require("src.core.Sound").playCry(self.game.data, mon.species) - end } })) - return - elseif action == "gen2_field" then - -- GSC runs the same handler from the menu as from the overworld - -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the - -- tile check lives in one place. - local ow = self.game.overworld - local fx, fy = ow.player:facingCell() - self:close() - if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then - local TextBox = require("src.render.TextBox") - -- _CantSurfText is "You can't SURF here", so using it for every - -- field move told a player holding HEADBUTT that the game thought - -- they had picked SURF. Only SURF gets the surf line. - local text = (entry.move == "SURF") - and self.game.data.text._CantSurfText or nil - self.game.stack:push(TextBox.new(self.game, - text or Strings("You can't use that\nhere."))) - end - return - elseif action == "softboiled" then - -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer - -- 1/5 of the user's max HP to a chosen teammate - self.softboiledFrom = self.index - elseif action == "dig" then - -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as - -- a DOOR warp so the player steps out of the opening rather than - -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares - -- TELEPORT's Pokemon Center warp, so it falls through to the same - -- departure with no target. - local ow = self.game.overworld - self.game.stack:pop() - if ow then - local escape = require("src.core.GameVersion").isGen2() - and ow.escapePoint and ow:escapePoint() and true or nil - ow:beginTeleportOut(nil, { escape = escape }) - end - return - elseif action == "escape" then - -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, - -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction - -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup - -- / wLastSpawnMapNumber, so this destination is right for both - -- generations. Only DIG diverged; it has its own branch above. - -- pokered's .dig/.teleport spin the - -- player up (LeaveMapAnim), white/fade out, then land it; this port - -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut - -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares - -- the exact departure; the fade + warp fire when the spin ends. - local ow = self.game.overworld - self.game.stack:pop() - if ow then ow:beginTeleportOut() end - return - end - self.submenu = nil + self:confirm() end return end @@ -753,150 +910,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() elseif input:wasPressed("a") and #party > 0 then - local mon = party[self.index] - if self.softboiledFrom then - local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end - elseif self.swapFrom then - if self.swapFrom ~= self.index then - party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] - require("src.core.Sound").play(self.game.data, "Swap") - end - self.swapFrom = nil - elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - -- keepOpen callers (HP medicine) need the menu still drawn while the - -- bar fills and the message prints, and close it themselves; everyone - -- else keeps the old pop-then-call order. Popping first is what made - -- a POTION snap the picker shut before the item had even run (#252). - if not self.keepOpen then self.game.stack:pop() end - self.onSwitch(mon, self) - else - self.submenu = true - self.subIndex = 1 - local items - local ow = self.game.overworld - if self.battle and self.onSwitch then - -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = Strings("SWITCH"), action = "battle_switch" }, - { label = Strings("STATS"), action = "stats" }, - { label = Strings("CANCEL"), action = "cancel" } } - else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - -- The badge gate per field move comes from constants.hmBadges and - -- the badge itself through Badges.has: Gen2 gates the same moves on - -- the Johto badges and stores them as engine flags rather than bag - -- items, so the hard-coded R/B item names listed nothing at all. - local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} - local function badged(moveId) - local gate = gates[moveId] - if not (gate and gate.badge) then return true end - return Badges.has(self.game.save, { id = gate.badge }) - end - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside and badged("FLY") then - table.insert(items, { label = Strings("FLY"), action = "fly" }) - -- LIGHT is carved on the Aerodactyl chamber wall, and the - -- chamber is not a dark cave -- so CheckUseFlash asks - -- SpecialAerodactylChamber FIRST and lets FLASH be used on its - -- carry, before it ever looks at the palset - -- (engine/events/overworld.asm:285). Without this the move is - -- never even offered there and the wall can never open. - elseif mv.id == "FLASH" and badged("FLASH") - and (ow.dark or (ow.map and ow.map.id - == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then - table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and badged("CUT") then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and badged("SURF") then - table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and badged("STRENGTH") then - table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) - elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" - and (DIG_TILESETS[ow.map.def.tileset] - or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] - and ow.escapePoint and ow:escapePoint())) then - -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel). - -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a - -- recorded entrance to come back out of. Its own action, - -- because Gen 2's DIG and TELEPORT no longer share a - -- destination the way Gen 1's did. - table.insert(items, { label = Strings("DIG"), action = "dig" }) - elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then - -- GSC's own field moves; the tile check happens on selection, - -- exactly like CUT's (start_sub_menus.asm never gates the list - -- on where the player is standing) - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "gen2_field", move = mv.id }) - end - end - end - if require("src.core.GameVersion").isGen2() then - -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, - -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. - local stats, switch = table.remove(items, 1), table.remove(items, 1) - items[#items + 1] = stats - items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } - items[#items + 1] = { label = Strings("QUIT"), action = "quit" } - end - end - local ctx = { battle = self.battle, overworld = ow } - local hooked = Runtime.call("ui.party.submenu", sameItems, - self.game, items, mon, ctx) - if type(hooked) == "table" then - items = hooked - else - Logger.error("ui.party.submenu returned %s; keeping the vanilla list", - type(hooked)) - end - self.subItems = items - end + self:confirm() end end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..704b885a1 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -169,19 +169,31 @@ PlayerPC.withdraw = withdraw PlayerPC.deposit = deposit PlayerPC.toss = toss -function PlayerPC.new(game) +function PlayerPC.new(game,opts) + opts=opts or{} + local changed,done=false,false + local function finish() + if done then return end;done=true + if opts.onDone then opts.onDone(changed)end + end game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"),onSelect=finish }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if opts.house and require("src.world.Gen2Decorations").ready(game.data)then + table.insert(items,#items,{label=Strings("DECORATION"),keepOpen=true,onSelect=function() + require("src.ui.Screens").push(game,"Gen2DecorationMenu",{onDone=function(value)changed=value or changed end}) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,maxVisible=8,noSound=true,onCancel=finish}) end return PlayerPC diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..872242134 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -5,6 +5,7 @@ local Theme = require("src.ui.Theme") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Runtime = require("src.mods.Runtime") local PokegearMenu = {} PokegearMenu.__index = PokegearMenu @@ -125,7 +126,6 @@ local function visibleCards(save) return list end -local KANTO_LANDMARK_FIRST = 0x2F local images = {} local function loadImage(path) @@ -318,9 +318,10 @@ function PokegearMenu:drawModeArrow() love.graphics.setColor(1, 1, 1, 1) end -function PokegearMenu:text(str, tx, ty) +function PokegearMenu:text(str, tx, ty, literal) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings(tostring(str or "")), tx * 8, ty * 8) + local text=tostring(str or "") + Font.draw(literal and text or Strings(text), tx * 8, ty * 8) love.graphics.setColor(1, 1, 1, 1) end @@ -370,6 +371,18 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Source renderers observe the selected native card. Keep one cursor: +-- falling back to a separate cardIndex leaves them drawing the clock while +-- native input and callbacks are already operating the radio. +function PokegearMenu:card() + return self:current() +end + +function PokegearMenu:cardLabel(card) + card = card or self:current() + return card and Strings(card.label or card.id) or "" +end + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -389,15 +402,51 @@ end -- `db PHONE_MOM, PHONE_ELM, -1`): those two can never be deleted. local GEN2_PERMANENT_NUMBERS = { [1] = true, [4] = true } -function PokegearMenu.canDeleteContact(entry) +function PokegearMenu.canDeleteContact(entry, data) local id = tonumber(entry and entry.id) - return id ~= nil and not GEN2_PERMANENT_NUMBERS[id] + if not id or id < 1 then return false end + if entry.permanent ~= nil then return not entry.permanent end + if data then return require("src.world.Gen2PhoneBook").canDelete(data, id) end + return not GEN2_PERMANENT_NUMBERS[id] end function PokegearMenu:contacts() local data = self.game.data local save = self.game.save + if require("src.core.GameVersion").isGen2() then + local Book = require("src.world.Gen2PhoneBook") + local Calls = require("src.script.Gen2Commands") + local slots = Book.contacts(save) + if Runtime.wantsHook("phone.contact_list") then + -- Save is an opaque native handle; the display list is detached. The + -- incoming selector continues to use the unhooked physical book. + local shown = Runtime.call("phone.contact_list", function(_, list) return list end, + save, Book.contacts(save)) + if type(shown) == "table" and #shown == Book.SIZE then + slots = {} + for i = 1, Book.SIZE do + local value = shown[i] + local id = (type(value) == "number" or type(value) == "string") and tonumber(value) + slots[i] = id and id % 1 == 0 and id >= 1 and id <= 255 + and Calls.phoneContact(data, id) and id or 0 + end + end + end + local physical = Book.contacts(save) + local permanent, out = Book.permanent(data), {} + for displaySlot, id in ipairs(slots) do + local name = Calls.phoneName(data,id) + local slot = id == 0 and displaySlot or nil + if id ~= 0 then + for i, nativeId in ipairs(physical) do if nativeId == id then slot = i; break end end + end + out[displaySlot] = { id=name and id or 0, slot=slot, name=name or "----------", + permanent=permanent[id] == true } + end + return out + end + local list = {} local registered = save and save.g2Phone if type(registered) == "table" then @@ -427,6 +476,9 @@ function PokegearMenu:contacts() end function PokegearMenu:stationList() + if require("src.core.GameVersion").isGen2() then + return require("src.world.Gen2Radio").gearList(self.game.data,self.game.save,STATIONS,hasExpn(self.game.save)) + end local list = {} for _, st in ipairs(STATIONS) do list[#list + 1] = st end if hasExpn(self.game.save) then @@ -440,6 +492,43 @@ function PokegearMenu:stationList() return list end +-- Read-only presentation of the existing native tuner, not a second tuner +-- or a reproduction of the cartridge's complete radio show state machine. +function PokegearMenu:stations() + if not require("src.core.GameVersion").isGen2() then return {} end + local rows = self:stationList() + for _, row in ipairs(rows) do + row.station = row.id + row.frequency = ("%04.1f"):format(((row.knob or 0) + 2) / 4) + end + return rows +end + +function PokegearMenu:currentStation() + return self:stations()[self.station] +end + +function PokegearMenu:syncRadioView(station) + if not require("src.core.GameVersion").isGen2() or not station then + self.radio = nil + return + end + local view = self.radio + if not view or view.station ~= station.id then view = {}; self.radio = view end + local lines, line = station.lines or {}, self.radioLine or 1 + view.station, view.top, view.bottom = station.id, lines[line] or "", lines[line + 1] or "" +end + +function PokegearMenu:exitRadioMusic() + local playing = self._radioMusic + self._radioMusic = nil + require("src.world.Gen2RadioAudio").finish(self.game, playing) +end + +function PokegearMenu:exit() + self:exitRadioMusic() +end + function PokegearMenu:update(dt) self.blink = (self.blink + dt) % 1 -- A call in progress owns the card: PokegearPhone_MakePhoneCall does not @@ -457,6 +546,7 @@ function PokegearMenu:update(dt) end local input = self.game.input if input:wasPressed("b") then + self.radio = nil Sound.play(self.game.data, "Press_AB") self.game.stack:pop() if self.onCancel then self.onCancel() end @@ -488,9 +578,14 @@ function PokegearMenu:update(dt) return end end + if card.id ~= "RADIO" then + self:exitRadioMusic() + self._radioTuned=nil;self.radio=nil + end if card.id == "RADIO" then local stations = self:stationList() local n = #stations + self.station = math.max(1, math.min(self.station, n)) if input:wasPressed("up") then -- pret: up winds knob toward higher frequencies (clamp, no wrap) if self.station < n then @@ -515,10 +610,17 @@ function PokegearMenu:update(dt) end end local st = stations[self.station] - if st and st.flute then - self.game.save.g2RadioChannel = "POKE_FLUTE" - self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" + if require("src.core.GameVersion").isGen2() and st and self._radioTuned~=st.id then + self._radioTuned=st.id + self.radioLine=1 + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,st.id) + require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") + end + if not st and self._radioTuned then + self._radioTuned=nil + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,nil) end + self:syncRadioView(st) elseif card.id == "PHONE" then local list = self:contacts() if self.phoneSubmenu then @@ -528,7 +630,7 @@ function PokegearMenu:update(dt) -- stay filled -- and the ROM's DELETE refuses only for the two -- PermanentNumbers, MOM and PROF.ELM. local entry = list[self.contact] - local canDelete = entry and PokegearMenu.canDeleteContact(entry) + local canDelete = entry and PokegearMenu.canDeleteContact(entry, self.game.data) if canDelete and (input:wasPressed("down") or input:wasPressed("up")) then self.phoneAction = self.phoneAction == "delete" and "call" or "delete" Sound.play(self.game.data, "Tink") @@ -539,9 +641,15 @@ function PokegearMenu:update(dt) self.phoneAction = nil self.callText = nil if entry and deleting then - require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + if require("src.core.GameVersion").isGen2() then + local Book = require("src.world.Gen2PhoneBook") + if entry.slot then Book.deleteAt(self.game.save, entry.slot) + else Book.remove(self.game.save, entry.id) end + else + require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + end if self.contact > 1 then self.contact = self.contact - 1 end - elseif entry then + elseif entry and entry.id ~= 0 then self:startPhoneCall(entry) end elseif input:wasPressed("b") then @@ -558,7 +666,8 @@ function PokegearMenu:update(dt) self.contact = self.contact > 1 and self.contact - 1 or #list Sound.play(self.game.data, "Tink") elseif input:wasPressed("a") then - if #list > 0 then + local entry = list[self.contact] + if entry and entry.id ~= 0 then self.phoneSubmenu = true self.phoneAction = "call" Sound.play(self.game.data, "Press_AB") @@ -724,7 +833,7 @@ function PokegearMenu:drawRadio() -- Station name at (2,9) — UpdateRadioStation if st and st.name then - self:text(st.name, 2, 9) + self:text(st.name, 2, 9, st.nameLiteral) end -- Tuning knob marker (SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB) @@ -767,22 +876,22 @@ function PokegearMenu:drawPhone() self:drawStrip() local list = self:contacts() - if type(list) ~= "table" or #list == 0 then + if (type(list) ~= "table" or #list == 0) and not require("src.core.GameVersion").isGen2() then list = { { name = "MOM" }, { name = "PROF.ELM" } } end if self.contact < 1 then self.contact = 1 end if self.contact > #list then self.contact = #list end local selectedName = nil - -- One row per contact, starting at tile row 4 (inside the cream plate) + -- Keep the selected native slot visible, including slots nine and ten. + local top = math.max(0, self.contact - 8) for i, entry in ipairs(list) do local name = tostring( (type(entry) == "table" and (entry.name or entry.label)) or entry or "?" ):upper() - local ty = 3 + i -- MOM at row 4, next at 5, ... + local ty = 3 + i - top if ty > 11 then break end - - if i == self.contact then + if ty >= 4 and i == self.contact then selectedName = name -- THE CURSOR IS A GLYPH, NOT THE LETTER ">". -- @@ -806,7 +915,7 @@ function PokegearMenu:drawPhone() Font.draw(Strings(name), 18, ty * 8) if Font.endTint then Font.endTint() end love.graphics.setColor(1, 1, 1, 1) - else + elseif ty >= 4 then self:text(name, 3, ty) end end @@ -837,7 +946,7 @@ function PokegearMenu:drawMap() local landmark = def and def.landmark local entry = landmark and town.landmarks and (town.landmarks[landmark] or town.landmarks[tostring(landmark)]) - local path = (landmark and landmark >= KANTO_LANDMARK_FIRST and town.kanto) + local path = (landmark and landmark >= require("src.world.Gen2Landmarks").kantoStart(game.data) and town.kanto) or town.johto local image = path and loadImage(path) if image then @@ -924,4 +1033,16 @@ function PokegearMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +-- Native presentation keeps its original canvas. A replacement of this +-- optional drawer opts a skin into window-resolution composition. +local classicDraw = PokegearMenu.draw +local function drawWidescreen(self,width,height) + return require('src.render.ScreenSpaceUI').classic(function()classicDraw(self)end, + require('src.render.Renderer'),width,height) +end +PokegearMenu.drawWidescreen = drawWidescreen +function PokegearMenu:drawsWidescreen() + return self.drawWidescreen ~= drawWidescreen +end + return PokegearMenu diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 63a5a8155..4727a2725 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -37,18 +37,19 @@ end -- Gen2 town map. field.townMap is a completely different shape from Gen1's: -- two full-screen pictures (johto/kanto) plus `landmarks`, a flat id -> {name, -- x, y} table whose coordinates are absolute screen pixels, and every map --- record carries the landmark id it belongs to. Landmarks from $2E up are --- Kanto: `DEF KANTO_LANDMARK EQU const_value` sits immediately BEFORE --- LANDMARK_PALLET_TOWN in constants/landmark_constants.asm, so Pallet is --- the first of them, not Route 1. A landmark short of it and Pallet read --- as Johto -- flown to off the Johto picture with no cursor drawn on it. -local GEN2_KANTO_FIRST = 0x2E +-- record carries the landmark id it belongs to. Gold/Silver's fallback +-- boundary is $2e; Crystal inserts the Battle Tower and starts Kanto at $2f. +-- Prefer the active cartridge's imported Pallet header below. local function gen2Locations(game) local tm = (game.data.field or {}).townMap if type(tm) ~= "table" or type(tm.landmarks) ~= "table" or not tm.johto then return nil end + -- Crystal inserts the Battle Tower landmark before Silver Cave. The + -- native Pallet header is the first Kanto landmark on the active cartridge; + -- the old Gold-only $2e boundary misclassified Crystal's Silver Cave. + local kantoFirst = require("src.world.Gen2Landmarks").kantoStart(game.data) local byLandmark, locs = {}, {} for id, e in pairs(tm.landmarks) do local x, y = tonumber(e and e.x), tonumber(e and e.y) @@ -62,7 +63,7 @@ local function gen2Locations(game) -- draws around -- taken raw, every nest sat 4px right and 12px -- below the town it belongs to. px = x - 4, py = y - 12, landmark = tonumber(id), - region = tonumber(id) >= GEN2_KANTO_FIRST and "kanto" or "johto", + region = tonumber(id) >= kantoFirst and "kanto" or "johto", } byLandmark[tonumber(id)] = loc locs[#locs + 1] = loc diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..437932a88 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,346 @@ +-- Admission and dispatch for native party, bag and dialogue screens. +-- No screen is loaded here: an already loaded native class must own the top. +local Commands = {} +local CLASSES = { + PartyMenu = "party_gb", + Gen3PartyMenu = "party_gba", + Gen4PartyMenu = "party_ds", + Gen3BagMenu = "bag_gba", + Gen4BagMenu = "bag_ds", + HgssBagMenu = "bag_hgss", + Gen5BagMenu = "bag_bw", + Gen4BattleBag = "battle_bag", + Gen5BattleBag = "battle_bag", + Gen3ItemMenu = "item_actions", + ChoiceBox = "choice", +} +local function class(screen) + local mt = getmetatable(screen) + if mt == nil then + return + end + if mt == package.loaded["src.render.TextBox"] then + return "text" + end + for name, kind in pairs(CLASSES) do + if mt == package.loaded["src.ui." .. name] then + return kind + end + end + if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then + return "bag_gb" + end + if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then + return "bag_actions" + end +end +local function row(out, value, ids, item) + local i = #out.rows + 1 + local r = { index = i, enabled = true } + if type(value) == "table" then + r.label = type(value.label) == "string" and value.label or nil + r.action = type(value.action) == "string" and value.action or nil + if value.header then + r.enabled = false + end + if value.close then + r.action = "back" + end + if item then + r.item = ids:read("items", value.id or value.value) + r.count = type(value.qty) == "number" and value.qty or nil + if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then + r.label = value.def.name + end + end + elseif type(value) == "string" then + r.action, r.label = value, value + end + out.rows[i] = r + return r +end +function Commands.describe(game, screen, ids) + local kind = class(screen) + if not kind then + return nil, "unsupported UI screen" + end + if screen.game ~= game then + return nil, "UI does not belong to current Game" + end + local out = { + kind = "party", + rows = {}, + index = screen.index, + canBack = true, + canAdvance = false, + canPocket = false, + locked = false, + } + local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save } + -- Keep callback and nested option identity private; no engine references escape. + for key, value in pairs(screen) do + if type(value) == "function" or type(value) == "table" then + refs[key] = value + end + end + local function rows(values, items) + for i, value in ipairs(values or {}) do + refs["row:" .. i] = value + if type(value) == "table" then + refs["callback:" .. i] = value.onSelect + end + row(out, value, ids, items) + end + end + if kind:match("^party_") then + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + end + if screen.selectNotice then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.submenu then + out.kind = "party_actions" + rows( + kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions() + ) + out.index = kind == "party_ds" and screen.submenu or screen.subIndex + else + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + local r = row(out, {}, ids) + r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname + r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp + end + if kind ~= "party_gb" then + if kind == "party_ds" and screen.select then + row(out, "confirm", ids) + end + row(out, "back", ids) + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/src/world/Gen2ApricornPrograms.lua b/src/world/Gen2ApricornPrograms.lua new file mode 100644 index 000000000..b80a71476 --- /dev/null +++ b/src/world/Gen2ApricornPrograms.lua @@ -0,0 +1,188 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Project recipe records into decoded native Kurt programs. Dialogue, +-- working/day flags, quantity and bag-refusal branches remain native IR. +local Merge=require('src.mods.Merge') +local M={} +local copy=Merge.deepCopy +local function number(value) + return type(value)=='number' and value%1==0 and value>=0 and value<=65534 +end +local function item(key) + local n=type(key)=='string' and tonumber(key:match('^ITEM_(%d%d%d)$')) + return n and n>0 and n<255 and n or nil +end +M.item=item +local function selected(row) + return type(row)=='table' and row[1]=='special' and + (row[3]=='SelectApricornForKurt' or row[3]=='Special_SelectApricornForKurt' or row[3]=='select_apricorn') +end +local function eventBranch(scripts,label,gave) + local rows=scripts[label] + if type(rows)~='table' or #rows~=2 then return nil end + if type(rows[1])~='table' or type(rows[2])~='table' then return nil end + if rows[1][1]~='setevent' or not number(rows[1][2]) or + rows[2][1]~='sjump' or rows[2][2]~=gave then return nil end + return rows[1][2] +end +local function chain(rows,start,kind,known,count,ask) + local seen,targets={},{} + for n=0,count-1 do + local a,b=rows[start+n*2],rows[start+n*2+1] + if type(a)~='table' or type(b)~='table' or a[1]~=kind or + not known[a[2]] or seen[a[2]] or b[1]~='iftrue' or + type(b[2])~='string' or ask and b[2]~=ask then return nil end + seen[a[2]]=true;targets[a[2]]=b[2] + end + return targets +end +function M.describe(data) + local pool=data and data.map_scripts + local roles=pool and pool.apricornPrograms + local scripts=pool and pool.scripts + local recipes=pool and pool.apricornBalls + if type(roles)~='table' or type(scripts)~='table' or type(recipes)~='table' or #recipes==0 then + return nil,'apricorns: native Kurt programs require a current ROM import' + end + for _,role in ipairs({'ask','pending','gave','cancel'})do + if type(roles[role])~='string' or type(scripts[roles[role]])~='table' then + return nil,'apricorns: missing native Kurt program '..role + end + end + local ask=scripts[roles.ask];local zero + for i,row in ipairs(ask)do + if selected(row)then + if zero then return nil,'apricorns: ambiguous native selection' end + local nextRow=ask[i+1] + if type(nextRow)=='table' and nextRow[1]=='ifequal' and nextRow[2]==0 and nextRow[3]==roles.cancel then zero=i+1 end + end + end + if not zero then return nil,'apricorns: native selection/cancel signature differs' end + local cases,at={},zero+1 + while type(ask[at])=='table' and ask[at][1]=='ifequal' do + local row=ask[at];local id=row[2] + local event=eventBranch(scripts,row[3],roles.gave) + if type(id)~='number' or id%1~=0 or id<1 or id>254 or cases[id] or not event then + return nil,'apricorns: invalid native selection branch' + end + cases[id]=event;at=at+1 + end + if #ask~=at+1 or type(ask[at])~='table' or type(ask[at+1])~='table' or + ask[at][1]~='setevent' or not number(ask[at][2]) or + ask[at+1][1]~='sjump' or ask[at+1][2]~=roles.gave then + return nil,'apricorns: native default selection signature differs' + end + local base,items,events,default={},{},{},nil + for i,row in ipairs(recipes)do + if type(row)~='table'then return nil,'apricorns: invalid native recipe record' end + local id=item(row.apricorn) + if not id or not item(row.ball) or items[id] then return nil,'apricorns: invalid native recipe table' end + local event=cases[id] + if not event then + if default then return nil,'apricorns: ambiguous native default recipe' end + default=id;event=ask[at][2] + end + if events[event] then return nil,'apricorns: duplicate native recipe event' end + items[id]=true;events[event]=true + base[i]={apricorn=row.apricorn,ball=row.ball,event=event,index=i} + end + if not default then return nil,'apricorns: no native default recipe' end + for id in pairs(cases)do if not items[id] then return nil,'apricorns: native selection is outside recipe table' end end + local pending=scripts[roles.pending];local targets + for i=1,#pending do + local found=chain(pending,i,'checkevent',events,#base) + if found then + if targets then return nil,'apricorns: ambiguous native collection chain' end + targets=found + end + end + if not targets then return nil,'apricorns: native collection chain differs' end + local template,giftAt,clearAt + for _,row in ipairs(base)do + local rows=scripts[targets[row.event]] + if type(rows)~='table' then return nil,'apricorns: native delivery program is absent' end + local gift,clear + for i,op in ipairs(rows)do + if type(op)~='table'then return nil,'apricorns: invalid native delivery row' end + if op[1]=='verbosegiveitem' or op[1]=='verbosegiveitemvar' then + if gift or op[2]~=item(row.ball) then return nil,'apricorns: native ball output differs from recipe' end + gift=i + elseif op[1]=='clearevent' and op[2]==row.event then + if clear then return nil,'apricorns: ambiguous native event clear' end + clear=i + end + end + if not gift or not clear or clear<=gift then return nil,'apricorns: native delivery/event signature differs' end + if not template then template,giftAt,clearAt=rows,gift,clear end + end + return {roles=copy(roles),base=base,items=items,events=events,template=template, + giftAt=giftAt,clearAt=clearAt,zero=zero,scripts=scripts,ask=ask} +end +function M.project(description,recipes,retired) + local d=description;local scripts=d.scripts;local out={} + for key,value in pairs(scripts)do out[key]=value end + local choices,deliveries,generated={},{},{} + local used,generation={},1 + for label in pairs(scripts)do + if type(label)=='string'then + local n=tonumber(label:match('^engine:apricorn:(%d+):')) + if n then used[n]=true end + end + end + while used[generation]do generation=generation+1 end + local function key(kind,index) + local id='engine:apricorn:'..generation..':'..kind..':'..index + assert(scripts[id]==nil,'apricorns: generated script label collision') + return id + end + for i,row in ipairs(recipes)do + assert(item(row.apricorn) and item(row.ball) and number(row.event),'apricorns: invalid projected recipe') + local choose=key('choose',i) + choices[#choices+1]={'ifequal',item(row.apricorn),choose} + generated[choose]={{'setevent',row.event},{'sjump',d.roles.gave}} + end + local deliveryRules,events={},{} + for _,list in ipairs({recipes,retired or {}})do + for _,row in ipairs(list)do + if not events[row.event]then deliveryRules[#deliveryRules+1]=row;events[row.event]=true end + end + end + for i,row in ipairs(deliveryRules)do + local deliver=key('deliver',i) + deliveries[#deliveries+1]={'checkevent',row.event};deliveries[#deliveries+1]={'iftrue',deliver} + local body=copy(d.template);body[d.giftAt][2]=item(row.ball);body[d.clearAt][2]=row.event + table.insert(body,d.clearAt+1,{'apricornconverted',row.apricorn,row.ball,row.event}) + generated[deliver]=body + end + choices[#choices+1]={'sjump',d.roles.cancel} + local touched,memberCount,pendingCount={ },0,0 + for label,rows in pairs(scripts)do + if type(rows)=='table'then + local result,i,changed={},1,false + while i<=#rows do + local op=rows[i] + if chain(rows,i,'checkitem',d.items,#d.base,d.roles.ask) then + for _,row in ipairs(recipes)do + result[#result+1]={'checkitem',item(row.apricorn)} + result[#result+1]={'iftrue',d.roles.ask} + end + i=i+#d.base*2;changed=true;memberCount=memberCount+1 + elseif chain(rows,i,'checkevent',d.events,#d.base) then + for _,row in ipairs(deliveries)do result[#result+1]=copy(row)end + i=i+#d.base*2;changed=true;pendingCount=pendingCount+1 + else result[#result+1]=op;i=i+1 end + end + -- Ask is an actual queued root. Its original prefix includes the real + -- selected item and native zero/cancel branch; never substitute an ID. + if label==d.roles.ask then + result={};for n=1,d.zero do result[n]=copy(rows[n])end + for _,row in ipairs(choices)do result[#result+1]=copy(row)end + changed=true + end + if changed then out[label]=copy(result);touched[label]=true end + end + end + assert(memberCount>0 and pendingCount>0,'apricorns: native item/event consumers are absent') + for label,rows in pairs(generated)do out[label]=rows;touched[label]=true end + return out,touched +end +return M diff --git a/src/world/Gen2Apricorns.lua b/src/world/Gen2Apricorns.lua new file mode 100644 index 000000000..bc7268d4c --- /dev/null +++ b/src/world/Gen2Apricorns.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Native recipe order for Kurt's selection; ball delivery remains script-owned. +local M={} +local Version=require('src.core.GameVersion') +local function stock(version)return version=='gold' or version=='silver' or version=='crystal'end +local function item(value) + local id=type(value)=='string' and value:match('^ITEM_(%d%d%d)$') + id=tonumber(id) + return id and id>0 and id<255 and id or nil +end +function M.rows(data) + local pool=data and data.map_scripts + local source=pool and pool.apricornBalls + local out={} + if source~=nil then + if type(source)~='table' or pool.apricornInfo and pool.apricornInfo.complete==false then return out end + for _,row in ipairs(source)do + if type(row)~='table' or not item(row.apricorn) or not item(row.ball) then return {} end + out[#out+1]={index=#out+1,apricorn=row.apricorn,ball=row.ball} + end + return out + end + -- Historical caches have no recipe table. Keep selection available until + -- reimport; do not invent their missing ball mappings. + local legacy={85,89,92,93,97,99,101} + for i,id in ipairs(legacy)do out[i]={index=i,apricorn=string.format('ITEM_%03d',id)}end + return out +end +function M.inBag(data,save) + local out={} + local nativeStock=stock(Version.get()) + for _,row in ipairs(M.rows(data))do + local n=tonumber(save and save.inventory and save.inventory[row.apricorn]) + if n and n==n and n=1 then + local quantity=math.floor(n) + if nativeStock then quantity=math.min(99,quantity)end + out[#out+1]={index=row.index,apricorn=row.apricorn,ball=row.ball, + item=item(row.apricorn),quantity=quantity} + end + end + return out +end +return M diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua new file mode 100644 index 000000000..3f87b4942 --- /dev/null +++ b/src/world/Gen2Decorations.lua @@ -0,0 +1,221 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native ownership flags, placed bytes and callback presentation are distinct. +-- Attribute/name rows come from the current cartridge, never a retail table. +local V=require('src.core.GameVersion') +local Strings=require('src.core.Strings') +local M={} +M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +M.objectSlots={ + {slot='console',sprite=0,flag=1857},{slot='leftOrnament',sprite=1,flag=1858}, + {slot='rightOrnament',sprite=2,flag=1859},{slot='bigDoll',sprite=3,flag=1860}, +} +local groups={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.byte(id) + local n=(type(id)=='number'or type(id)=='string')and tonumber(id) + return n and n%1==0 and n>=0 and n<=255 and n or nil +end +local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} +local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} +local actions={} +for _,kind in ipairs(kinds)do + actions[#actions+1]='SET_UP_'..kind;actions[#actions+1]='PUT_AWAY_'..kind +end +function M.actionName(n)return actions[n]end +function M.action(name) + if type(name)~='string'then return nil end + local kind=name:match('^SET_UP_(.+)$');local put=false + if not kind then kind=name:match('^PUT_AWAY_(.+)$');put=true end + if kind=='DOLL'then return{ornament=true,put=put}end + return slotFor[kind] and {slot=slotFor[kind],put=put}or nil +end +function M.pool(data)return data and data.map_scripts end +function M.ready(data) + local p=M.pool(data);local info=p and p.decorationInfo + return V.generation(V.get())==2 and type(p)=='table' and type(p.decorations)=='table' and + type(info)=='table' and info.complete==true or false +end +function M.attributes(data,id) + if not M.ready(data)then return nil end + id=M.byte(id);if not id then return nil end + local rows=M.pool(data).decorations + return rows[id]or rows[tostring(id)] +end +function M.state(data,save) + if not M.ready(data)or type(save)~='table'then return nil end + if save.decorations==nil then + local info=M.pool(data).decorationInfo + if not info.defaultsComplete then return nil end + save.decorations=require('src.mods.Merge').deepCopy(info.defaults or{}) + end + return type(save.decorations)=='table'and save.decorations or nil +end +local names={ + [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), + [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), +} +function M.name(data,id,monName) + id=M.byte(id) + local row=M.attributes(data,id) + if not row then return ''end + local name=row.name + if row.type>=4 then + if monName then name=monName(name)or name + else + local info=M.pool(data).decorationInfo + local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name + local mon=data.pokemon and data.pokemon[key] + name=mon and mon.name or name + end + end + return names[row.type] and Strings(names[row.type],name)or name +end +function M.owns(data,save,id) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, + require('src.script.Gen2Flags').eventFlag(row.flag))or false +end +function M.give(data,save,id) + id=M.byte(id) + local row=M.attributes(data,id) + if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end + save.flags=save.flags or{} + require('src.script.Flags').set(save,require('src.script.Gen2Flags').eventFlag(row.flag));return true +end +function M.giveFlag(data,save,n) + local info=M.pool(data)and M.pool(data).decorationInfo + local id=info and info.gifts and info.gifts[(tonumber(n)or -1)+1] + return id~=nil and M.give(data,save,id)or false +end +function M.choose(data,save,id,side) + id=M.byte(id) + local state=M.state(data,save);local row=M.attributes(data,id) + local action=row and M.action(row.action) + if id==0 or not(state and action)then return false end + if not action.put and not M.owns(data,save,id)then return false end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return false end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local value=action.put and 0 or id + local changed=(state[slot]or 0)~=value + state[slot]=value + if changed and action.ornament and not action.put then + local other=side=='left'and 'rightOrnament'or 'leftOrnament' + if state[other]==id then state[other]=0 end + end + return changed +end +-- Public menu metadata is derived from the current native rows, including +-- registry additions/removals. No independent retail catalogue is seeded. +function M.categories(data) + if not M.ready(data)then return{}end + local indexed={} + for raw in pairs(M.pool(data).decorations)do + local id=M.byte(raw);if id and id~=0 then indexed[id]=true end + end + local out={} + for _,group in ipairs(groups)do + local category={label=group[1],slot=group[2],members={},headers={}} + for id in pairs(indexed)do + local row=M.attributes(data,id);local action=row and M.action(row.action) + if action and(action.slot or 'ornament')==category.slot then + local list=action.put and category.headers or category.members;list[#list+1]=id + end + end + table.sort(category.members);table.sort(category.headers) + category.id=category.headers[1];out[#out+1]=category + end + return out +end +function M.placement(data,state,id,side,monName) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + if type(state)~='table'or not action or id==0 then return nil end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return nil end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local old=M.byte(state[slot])or 0 + local value=action.put and 0 or id + local role=action.put and(old==0 and 'empty'or 'put')or + old==id and 'same'or old==0 and 'setup'or 'replace' + return{slot=slot,value=value,changed=old~=value,role=role, + buffers={[3]=M.name(data,role=='setup'and id or old,monName),[4]=M.name(data,id,monName)}} +end +function M.feedbackPages(data,placement) + if not placement then return{}end + local programs=M.pool(data)and M.pool(data).decorationPrograms + local id=programs and programs.texts and programs.texts[placement.role] + if not id then return{}end + local private=data._portableScriptText and data._portableScriptText[id] + local text=private and private.value or not private and data.text and data.text[id] + if type(text)~='string'then return{}end + text=text:gsub('{RAM:wStringBuffer([34])}',function(n)return placement.buffers[tonumber(n)]end) + text=text:gsub('{DONE}',''):gsub('{PROMPT}',''):gsub('\v','\n') + local pages={} + for page in(text..'\f'):gmatch('(.-)\f')do if page~=''then pages[#pages+1]=page end end + return pages +end +function M.layoutVisibility(data,state) + local out={} + for _,binding in ipairs(M.objectSlots)do + local id=type(state)=='table'and M.byte(state[binding.slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + out[#out+1]={sprite=binding.sprite,flag=binding.flag,hidden=not row,byte=row and row.sprite or nil} + end + return out +end +function M.layoutTiles(data,state) + local out={} + local function paint(slot,x,y) + local id=type(state)=='table'and M.byte(state[slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + if row then out[#out+1]={x=x,y=y,block=row.sprite};return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + out[#out+1]={x=0,y=1,block=(carpet+1)%256} + out[#out+1]={x=1,y=1,block=(carpet+2)%256} + out[#out+1]={x=2,y=1,block=(carpet+1)%256} + end + return out +end +function M.posterVisible(state)return((state and state.poster)or 0)~=0 end +-- These callbacks deliberately preserve the VM's current script/check vars. +function M.visibility(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') + for _,row in ipairs(M.layoutVisibility(data,state))do + if not row.hidden then + C.g2_variablesprite(ctx,row.sprite,row.byte);C.clear_flag(ctx,Flags.eventFlag(row.flag)) + else C.set_flag(ctx,Flags.eventFlag(row.flag))end + end +end +function M.tiles(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands') + for _,row in ipairs(M.layoutTiles(data,state))do C.replace_block(ctx,row.x,row.y,row.block)end + local flag=require('src.script.Gen2Flags').eventFlag(716) + if M.posterVisible(state)then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end +end +function M.description(data,save,kind) + local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms + if not state or type(programs)~='table'then return nil end + if kind==0 then return programs.posters and programs.posters[state.poster or 0]or programs.null end + if kind==3 then return programs.big end + local slot=({[1]='leftOrnament',[2]='rightOrnament',[4]='console'})[kind] + if slot then return programs.ornament,M.name(data,state[slot]or 0)end +end +return M diff --git a/src/world/Gen2Landmarks.lua b/src/world/Gen2Landmarks.lua new file mode 100644 index 000000000..2ce8d3b55 --- /dev/null +++ b/src/world/Gen2Landmarks.lua @@ -0,0 +1,76 @@ +-- Original adapter/selection logic by Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Constant identities/order only, not cartridge coordinates, names or art. +-- Orders: pret/pokegold and pret/pokecrystal constants/landmark_constants.asm. +local Version=require('src.core.GameVersion') +local M={} +local gold={} +for name in ([[ + SPECIAL NEW_BARK_TOWN ROUTE_29 CHERRYGROVE_CITY ROUTE_30 ROUTE_31 + VIOLET_CITY SPROUT_TOWER ROUTE_32 RUINS_OF_ALPH UNION_CAVE ROUTE_33 + AZALEA_TOWN SLOWPOKE_WELL ILEX_FOREST ROUTE_34 GOLDENROD_CITY RADIO_TOWER + ROUTE_35 NATIONAL_PARK ROUTE_36 ROUTE_37 ECRUTEAK_CITY TIN_TOWER BURNED_TOWER + ROUTE_38 ROUTE_39 OLIVINE_CITY LIGHTHOUSE ROUTE_40 WHIRL_ISLANDS ROUTE_41 + CIANWOOD_CITY ROUTE_42 MT_MORTAR MAHOGANY_TOWN ROUTE_43 LAKE_OF_RAGE + ROUTE_44 ICE_PATH BLACKTHORN_CITY DRAGONS_DEN ROUTE_45 DARK_CAVE ROUTE_46 + SILVER_CAVE PALLET_TOWN ROUTE_1 VIRIDIAN_CITY ROUTE_2 PEWTER_CITY ROUTE_3 + MT_MOON ROUTE_4 CERULEAN_CITY ROUTE_24 ROUTE_25 ROUTE_5 UNDERGROUND_PATH + ROUTE_6 VERMILION_CITY DIGLETTS_CAVE ROUTE_7 ROUTE_8 ROUTE_9 ROCK_TUNNEL + ROUTE_10 POWER_PLANT LAVENDER_TOWN LAV_RADIO_TOWER CELADON_CITY SAFFRON_CITY + ROUTE_11 ROUTE_12 ROUTE_13 ROUTE_14 ROUTE_15 ROUTE_16 ROUTE_17 ROUTE_18 + FUCHSIA_CITY ROUTE_19 ROUTE_20 SEAFOAM_ISLANDS CINNABAR_ISLAND ROUTE_21 + ROUTE_22 VICTORY_ROAD ROUTE_23 INDIGO_PLATEAU ROUTE_26 ROUTE_27 TOHJO_FALLS + ROUTE_28 FAST_SHIP +]]):gmatch('%S+')do gold[#gold+1]='LANDMARK_'..name end +local function validOrder(source) + if type(source)~='table' or #source==0 or #source>256 then return nil end + local result,seen={},{} + for i=1,#source do + local id=source[i] + if type(id)~='string' or id=='' or seen[id]then return nil end + seen[id]=true;result[i]=id + end + for key in pairs(source)do + if type(key)~='number' or key%1~=0 or key<1 or key>#source then return nil end + end + return result +end +function M.order(version,metadata) + local declared=metadata and (metadata.landmarkOrder or metadata.constants and metadata.constants.landmarkOrder) + if declared~=nil then + local order=validOrder(declared) + if not order then return {},'invalid landmark identity order' end + return order + end + if version~='gold' and version~='silver' and version~='crystal'then return{}end + local result={};for i,id in ipairs(gold)do result[i]=id end + if version=='crystal'then table.insert(result,30,'LANDMARK_BATTLE_TOWER')end + return result +end +function M.town(data)return data and data.field and data.field.townMap end +function M.dataOrder(data) + local town=M.town(data) + if town and town.landmarkOrder~=nil then return validOrder(town.landmarkOrder) or{}end + return M.order(Version.get(),data and data.constants and {landmarkOrder=data.constants.landmarkOrder}) +end +function M.index(data,id) + local town=M.town(data);local rows=town and town.landmarks + local found + for key,row in pairs(rows or{})do + if type(row)=='table' and (row.landmarkId or row.id)==id and tonumber(row.index or key)then + local n=tonumber(row.index or key) + if found and found~=n then return nil end + found=n + end + end + if found then return found,true end + for i,name in ipairs(M.dataOrder(data))do if name==id then return i-1,false end end +end +function M.kantoStart(data) + local index,native=M.index(data,'LANDMARK_PALLET_TOWN') + local town=M.town(data) + if index and (native or town and town.landmarkOrder~=nil or + data and data.constants and data.constants.landmarkOrder~=nil)then return index end + local pallet=data and data.maps and data.maps.PALLET_TOWN + return tonumber(pallet and pallet.landmark) or index or (Version.get()=='crystal' and 0x2f or 0x2e) +end +return M diff --git a/src/world/Gen2MomShopping.lua b/src/world/Gen2MomShopping.lua new file mode 100644 index 000000000..ab075abdf --- /dev/null +++ b/src/world/Gen2MomShopping.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native accounts, ROM shopping rows and original phone programs share one save. +local Version=require('src.core.GameVersion') +local M={MAX_MONEY=999999} +local function integer(value,lo,hi) + return type(value)=='number'and value%1==0 and value>=lo and value<=hi and value or nil +end +local function money(value)return integer(value,0,M.MAX_MONEY)or 0 end +function M.stock() + local v=Version.get();return v=='gold'or v=='silver'or v=='crystal' +end +function M.mode(save) + local mode=integer(save.g2MomSavingMode,0,3) + local flag=save.flags and save.flags.ENGINE_MOM_SAVING_MONEY + if mode==nil then mode=flag~=nil and(flag and 1 or 0)or(save.g2MomSaving and 1 or 0) + elseif flag~=nil then mode=mode-mode%2+(flag and 1 or 0)end + return mode +end +function M.prepareBank(save) + if not M.stock()then return end + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + local active=save.flags.ENGINE_MOM_ACTIVE + if active==nil and save.g2MomBankOpen==true then Flags.set(save,'ENGINE_MOM_ACTIVE') + elseif active==true then save.g2MomBankOpen=true + elseif active==false then save.g2MomBankOpen=nil end + if save.flags.ENGINE_MOM_SAVING_MONEY==nil and(save.g2MomSaving~=nil or save.g2MomSavingMode~=nil)then + if M.mode(save)%2==1 then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end + end + save.g2MomSaving=M.mode(save)~=0 +end +function M.setSaving(save,on) + save.g2MomSaving=on==true + if not M.stock()then return end + save.g2MomSavingMode=on and 1 or 0 + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + if on then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end +end +function M.openBank(save) + save.g2MomBankOpen=true + if M.stock()then + save.flags=save.flags or{};require('src.script.Flags').set(save,'ENGINE_MOM_ACTIVE') + end +end +-- WinTrainerBattle deals four quarters; near-cap overflow is lost in the +-- selected account, not redirected to the other one. +function M.reward(save,total) + if not M.stock()or not integer(total,0,0xffffff*4)then return nil end + M.prepareBank(save) + local quarter=math.floor(total/4) + local mode=money(save.g2MomMoney)0 and type(p.ladder)=='table'and #p.ladder>0 +end +function M.caller(data) + local found + for id,row in pairs(data and data.map_scripts and data.map_scripts.phone or{})do + if integer(id,1,255)and type(row)=='table'and(row.class==nil or row.class==0)and(row.contactNumber or row.trainer)==1 then + if found then return nil end;found=id + end + end + return found +end +function M.state(data,save) + if not M.ready(data)then return nil end + if save.g2MomShopping==nil then save.g2MomShopping={whichItem=0,whichSet=0,triggerBalance=M.pool(data).step}end + local state=save.g2MomShopping + if type(state)~='table'or not integer(state.whichItem,0,255)or not integer(state.triggerBalance,0,M.MAX_MONEY)then return nil end + return state +end +function M.pick(data,save,random) + local state=M.state(data,save);if not state then return nil end + local p=M.pool(data);local saved=money(save.g2MomMoney) + state.whichSet=0 + local row=p.ladder[state.whichItem+1] + if row and saved>=row.trigger then return{row=row,set=2}end + while state.triggerBalancecap then return false end + save.pcItems=pc;pc[id]=held+1;return true +end +function M.tryBuy(data,save,opts) + opts=opts or{} + if not M.ready(data)or opts.phoneService~=true or opts.queued then return nil end + local caller=M.caller(data);if not caller then return nil end + local p=M.pool(data);local VM=require('src.script.Gen2ScriptVM') + local itemScript=p.programs and VM.compile(data,p.programs.item) + local dollScript=p.programs and VM.compile(data,p.programs.doll) + if not(itemScript and dollScript)then return nil end + local choice=M.pick(data,save,opts.random);if not choice then return nil end + if not receive(data,save,choice.row)then return nil end + save.g2MomMoney=math.max(0,money(save.g2MomMoney)-choice.row.cost) + local state=M.state(data,save) + if choice.set==2 then state.whichItem=(state.whichItem+1)%256 end + return{kind=choice.row.kind,item=choice.row.item,cost=choice.row.cost,set=choice.set, + saved=save.g2MomMoney,caller=caller,script=choice.row.kind==2 and dollScript or itemScript} +end +return M diff --git a/src/world/Gen2PhoneBook.lua b/src/world/Gen2PhoneBook.lua new file mode 100644 index 000000000..db0ba6d25 --- /dev/null +++ b/src/world/Gen2PhoneBook.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. +-- Native ten-byte order beside the existing numeric registration set. Reads +-- retain legacy overflow entries; no save or contact ID is renumbered. +local M = { SIZE=10 } +local function valid(id) + return type(id)=='number' and id%1==0 and id>=1 and id<=255 +end +local function registered(save) + if type(save.g2Phone)~='table' then save.g2Phone={} end + return save.g2Phone +end +function M.slots(save) + local set=registered(save) + local slots=type(save.g2PhoneSlots)=='table' and save.g2PhoneSlots or {} + save.g2PhoneSlots=slots + local seen={} + for i=1,M.SIZE do + local id=slots[i] + if valid(id) and set[id] and not seen[id] then seen[id]=true + else slots[i]=0 end + end + local pending={} + for id,on in pairs(set) do + if on and valid(id) and not seen[id] then pending[#pending+1]=id end + end + table.sort(pending) + local cursor=1 + for i=1,M.SIZE do + if slots[i]==0 and pending[cursor] then slots[i]=pending[cursor];cursor=cursor+1 end + end + return slots +end +function M.contacts(save) + local slots,out=M.slots(save),{} + for i=1,M.SIZE do out[i]=slots[i] end + return out +end +function M.has(save,id) + if id==0 then + for _,value in ipairs(M.slots(save)) do if value==0 then return true end end + return false + end + return valid(id) and registered(save)[id] and true or false +end +function M.permanent(data) + local out={} + local pool=data and data.map_scripts and data.map_scripts.phone + local constants=data and data.constants or {} + local order=constants.phoneContactOrder or {} + local version=require('src.core.GameVersion').get() + local retail=version=='gold' or version=='silver' or version=='crystal' + for id,row in pairs(pool or {}) do + if valid(id) and type(row)=='table' and not (row.class and row.class~=0) then + local number=row.contactNumber + if number==1 or number==4 or number==nil and + (order[id+1]=='PHONE_MOM' or order[id+1]=='PHONE_ELM' + or retail and constants.phoneContactOrder==nil and (id==1 or id==4)) then out[id]=true end + end + end + -- Before import, the retail constants still reserve their two slots. A + -- custom cartridge receives no guessed retail identities. + if pool==nil and retail and constants.phoneContactOrder==nil then out[1],out[4]=true,true end + return out +end +function M.remainingSlots(data,save,id) + local missing=0 + for permanent in pairs(M.permanent(data)) do + if permanent~=id and not M.has(save,permanent) then missing=missing+1 end + end + return math.max(0,M.SIZE-missing) +end +function M.openSlot(data,save,id) + local slots=M.slots(save) + for i=1,M.remainingSlots(data,save,id) do if slots[i]==0 then return i end end +end +function M.add(data,save,id) + if not valid(id) then return false,'unknown' end + local pool=data and data.map_scripts and data.map_scripts.phone + if pool and type(pool[id])~='table' then return false,'unknown' end + if M.has(save,id) then return false,'already' end + local slot=M.openSlot(data,save,id) + if not slot then return false,'full' end + registered(save)[id]=true;save.g2PhoneSlots[slot]=id + return true +end +function M.remove(save,id) + local slots=M.slots(save) + for i=1,M.SIZE do + if slots[i]==id then + slots[i]=0 + if id~=0 then registered(save)[id]=nil end + return true + end + end + -- An old oversized flat set remains recoverable, including deletion of an + -- entry that could not fit into the ten hardware slots yet. + if valid(id) and registered(save)[id] then registered(save)[id]=nil;return true end + return false +end +function M.deleteAt(save,slot) + if type(slot)~='number' or slot%1~=0 or slot<1 or slot>M.SIZE then return false end + local slots=M.slots(save);local id=slots[slot] + if id==0 then return false end + registered(save)[id]=nil;slots[slot]=0 + local write=1 + for read=1,M.SIZE do + if slots[read]~=0 then slots[write]=slots[read];write=write+1 end + end + for i=write,M.SIZE do slots[i]=0 end + return true +end +function M.canDelete(data,id) + local pool=data and data.map_scripts and data.map_scripts.phone + return valid(id) and pool and type(pool[id])=='table' and not M.permanent(data)[id] or false +end +return M diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/Gen2Radio.lua b/src/world/Gen2Radio.lua new file mode 100644 index 000000000..f9eb6a1ef --- /dev/null +++ b/src/world/Gen2Radio.lua @@ -0,0 +1,103 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Station identities/MapRadio numbers are shared by both native radio UIs. +-- Program text stays in the existing native Pokegear implementation. +local Copy = require("src.mods.Merge").deepCopy +local R = {} +local literal=setmetatable({},{__mode="k"}) +local WALL = { + "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", "LUCKY_CHANNEL", + "UNOWN_RADIO", "PLACES_AND_PEOPLE", "LETS_ALL_SING", "ROCKET_RADIO", +} +local NAMES = { + OAKS_POKEMON_TALK="OAK'S POKéMON TALK", POKEDEX_SHOW="POKéDEX SHOW", + POKEMON_MUSIC="POKéMON MUSIC", LUCKY_CHANNEL="LUCKY CHANNEL", + UNOWN_RADIO="UNOWN RADIO", PLACES_AND_PEOPLE="PLACES & PEOPLE", + LETS_ALL_SING="LET'S ALL SING", ROCKET_RADIO="ROCKET RADIO", + POKE_FLUTE_RADIO="POKé FLUTE", EVOLUTION_RADIO="EVOLUTION RADIO", + BUENAS_PASSWORD="BUENA'S PASSWORD", +} +local GEAR = { "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", + "LUCKY_CHANNEL", "BUENAS_PASSWORD", "PLACES_AND_PEOPLE" } + +function R.defaults() + local rows={} + for id,name in pairs(NAMES)do rows[id]={name=name} end + for channel,id in ipairs(WALL)do rows[id].channel=channel end + return rows +end + +function R.records(data) + local rows=data and data.gen2RadioChannels + -- An explicitly empty mounted table is authoritative, including removals. + return type(rows)=="table" and rows or R.defaults() +end + +function R.literalState(data)return data and literal[data]end +function R.setLiteralState(data,state)if data then literal[data]=state end end +function R.isLiteral(data,id) + local state=data and literal[data] + return state and state[id]==true or state==nil and data and type(data.gen2RadioChannels)=="table" +end + +function R.channelRecord(data,channel) + local ids={} + for id,row in pairs(R.records(data))do + if type(row)=="table" and row.channel==channel then ids[#ids+1]=id end + end + table.sort(ids) + local id=ids[1] + return id and Copy(R.records(data)[id]),id +end + +function R.stationName(data,id) + local row=R.records(data)[id] + if type(row)~="table" then return nil end + return row.name or NAMES[id] or id +end + +function R.resolve(data,channel,map,tod) + if channel~=0 then + local _,id=R.channelRecord(data,channel) + if id then return id end + else + local maps=data and data.maps or {} + local pallet=maps.PALLET_TOWN + local boundary=tonumber(pallet and pallet.landmark) + or (require("src.core.GameVersion").get()=="crystal" and 0x2f or 0x2e) + if tonumber(map and map.landmark) and tonumber(map.landmark)>=boundary then + return R.stationName(data,"PLACES_AND_PEOPLE") and "PLACES_AND_PEOPLE" + end + if tod=="MORNING" or tod=="MORN" then + return R.stationName(data,"POKEDEX_SHOW") and "POKEDEX_SHOW" + end + end + return R.stationName(data,"OAKS_POKEMON_TALK") and "OAKS_POKEMON_TALK" +end + +function R.gearList(data,save,programs,expn) + local rows=R.records(data) + local list={} + for i,id in ipairs(GEAR)do + if type(rows[id])=="table" then + local entry=Copy(programs[i] or {}) + entry.id,entry.name,entry.nameLiteral=id,R.stationName(data,id),R.isLiteral(data,id) + list[#list+1]=entry + end + end + if expn and type(rows.POKE_FLUTE_RADIO)=="table" then + list[#list+1]={id="POKE_FLUTE_RADIO",name=R.stationName(data,"POKE_FLUTE_RADIO"),nameLiteral=R.isLiteral(data,"POKE_FLUTE_RADIO"), + knob=78,lines={"Playing the","POKé FLUTE...","A soothing melody.","..."},flute=true} + end + return list +end + +function R.notify(data,id,channel,source) + local name=id and R.stationName(data,id) + local Runtime=require("src.mods.Runtime") + if name and Runtime.wants("radio.channel") then + Runtime.emit("radio.channel",{station=id,channel=channel,name=name,source=source}) + end +end + +R.STATIONS=WALL +return R diff --git a/src/world/Gen2RadioAudio.lua b/src/world/Gen2RadioAudio.lua new file mode 100644 index 000000000..0327f824c --- /dev/null +++ b/src/world/Gen2RadioAudio.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native station playback owns the map cue after leaving the radio. This +-- covers channel entry/exit; cartridge show-driven song changes are separate. +local Music = require("src.core.Music") +local M = {} +local SONGS = { + OAKS_POKEMON_TALK = "Music_ProfOaksPokemonTalk", + POKEDEX_SHOW = "Music_PokemonCenter", + POKEMON_MUSIC = "Music_TitleScreen", + LUCKY_CHANNEL = "Music_GameCorner", + PLACES_AND_PEOPLE = "Music_ViridianCity", + LETS_ALL_SING = "Music_Bicycle", + ROCKET_RADIO = "Music_RocketTheme", + POKE_FLUTE_RADIO = "Music_PokeFluteChannel", + UNOWN_RADIO = "Music_RuinsOfAlphRadio", + EVOLUTION_RADIO = "Music_LakeOfRageRocketRadio", + BUENAS_PASSWORD = "Music_BuenasPassword", +} +M.FLUTE = SONGS.POKE_FLUTE_RADIO + +function M.tune(game, station) + if not require("src.core.GameVersion").isGen2() then return nil end + local song = SONGS[station] + local songs = game.data and game.data.audio and game.data.audio.songs + Music.stop() + if song and songs and songs[song] then + Music.play(game.data, song) + -- Observe the actual selected cue: an audio hook can replace/silence it, + -- and a missing or failed Source must never manufacture flute playback. + local current = Music.current() + if current then return { song = current } end + end + return { restore = true } +end + +function M.finish(game, playing) + if not playing then return end + if playing.restore and Music.current() == nil then + Music.restoreMap(game.data) + elseif Music.current() == playing.song then + Music.setMapSong(playing.song) + end +end + +function M.snorlaxAwake(player) + if Music.current() ~= M.FLUTE or not player then return false end + local x, y = player.cellX, player.cellY + return (x == 33 and y == 8) or (x == 34 and y == 10) + or (x == 35 and y == 10) or (x == 36 and (y == 8 or y == 9)) +end + +return M diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 000000000..adf912a6b --- /dev/null +++ b/src/world/MapOverview.lua @@ -0,0 +1,176 @@ +-- Generation-neutral terrain grid; optional GB tile luminance. All cells are +-- zero based. The scratch Map shares read-only data, but never the cold block +-- decoder's mutable definition or streamed renderer. +local Map = require("src.world.Map") +local Assets = require("src.render.Assets") +local Overview = {} +local shades = {} +Assets.register(function() + shades = {} +end) + +local function digit(sum, count) + return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5)))) +end + +local function visual(map) + local ts = map.tileset + -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt + -- identifiers do not index a GB atlas. DS maps do not have one either. + if + map.blockCells ~= 2 + or not ts.blocks + or type(ts.image) ~= "string" + or type(ts.tilesPerRow) ~= "number" + or ts.tilesPerRow < 1 + or ts.tilesPerRow >= math.huge + or ts.tilesPerRow ~= math.floor(ts.tilesPerRow) + then + return nil + end + local path = Assets.resolve(ts.image) + local cache = shades[path] + if not cache then + if not (love and love.image and love.image.newImageData) then + return nil + end + local ok, pixels = pcall(love.image.newImageData, path) + if not ok or not pixels then + return nil + end + cache = { pixels = pixels, tiles = {} } + shades[path] = cache + end + local rows, details, pixels = {}, {}, cache.pixels + local width, height = pixels:getDimensions() + for y = 0, map.heightCells * 2 - 1 do + local row, top, bottom = {}, {}, {} + for x = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(x, y) + if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then + return nil + end + local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8 + if ox + 8 > width or oy + 8 > height then + return nil + end + local key = ts.tilesPerRow .. ":" .. tile + local s = cache.tiles[key] + if not s then + local sums = { 0, 0, 0, 0 } + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = pixels:getPixel(ox + px, oy + py) + local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + s = { + digit(sums[1] + sums[2] + sums[3] + sums[4], 64), + digit(sums[1], 16), + digit(sums[2], 16), + digit(sums[3], 16), + digit(sums[4], 16), + } + cache.tiles[key] = s + end + row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5] + end + rows[#rows + 1] = table.concat(row) + details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom) + end + return rows, details +end + +local function integer(n) + return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n) +end + +function Overview.build(game, ow) + local map = ow.map + local w, h = map.widthCells, map.heightCells + if not integer(w) or not integer(h) or w == 0 or h == 0 then + return nil, "invalid map dimensions" + end + -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that + -- renderer from a query can load/cache chunks. Interior permission grids are + -- already resident and can use the same pure Map classifiers below. + if map.def.generation == 4 and not map.def.behaviorCells then + return nil, "map overview unavailable for streamed terrain" + end + local scratch, def = {}, {} + for k, v in pairs(map) do + scratch[k] = v + end + for k, v in pairs(map.def) do + def[k] = v + end + scratch.def, scratch.renderer = def, nil + setmetatable(scratch, Map) + -- blockArray() writes only into the private definition on a cold GBA map. + -- It may reuse the warm immutable array, and honors the live block patches. + local rows, markers = {}, {} + local function marker(kind, x, y) + if integer(x) and integer(y) and x < w and y < h then + markers[#markers + 1] = { kind = kind, x = x, y = y } + end + end + for _, warp in ipairs(def.warps or {}) do + marker("warp", warp.x, warp.y) + end + -- The active object list is the result of native flag, session, time and + -- scripted spawn filters. Re-running those filters can consult another Game + -- singleton or initialize sprite state. Use the objects actually present. + for _, npc in ipairs(ow.npcs or {}) do + local obj = npc.def or {} + if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then + marker("item", npc.cellX, npc.cellY) + end + end + local save = game.save or {} + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then + marker("hidden", item.x, item.y) + end + end + -- Gen2/3 imports also carry hidden-item sign events, with their own taken + -- key and event flag (e.g. Cerulean Gym's Machine Part). + for _, item in ipairs(def.signs or {}) do + if + item.item + and item.item ~= 0 + and item.item ~= "0" + and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true) + and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y]) + then + marker("hidden", item.x, item.y) + end + end + for y = 0, h - 1 do + local row = {} + for x = 0, w - 1 do + row[#row + 1] = scratch:isWarpTileCell(x, y) and "+" + or scratch:isWaterCell(x, y) and "~" + or scratch:isWalkableCell(x, y) and "." + or " " + end + rows[#rows + 1] = table.concat(row) + end + local tiles, details = visual(scratch) + return { + mapId = map.id, + width = w, + height = h, + rows = rows, + markers = markers, + tileRows = tiles, + tileWidth = tiles and w * 2, + tileHeight = tiles and h * 2, + tileDetailRows = details, + tileDetailWidth = details and w * 4, + tileDetailHeight = details and h * 4, + } +end + +return Overview diff --git a/src/world/NpcControls.lua b/src/world/NpcControls.lua new file mode 100644 index 000000000..d5a08d952 --- /dev/null +++ b/src/world/NpcControls.lua @@ -0,0 +1,49 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native actor operations. Facades retain their own admission policy. +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local C = {} +function C.isMoving(actor) + return not not actor.moving +end +function C.canStep(actor, map, peers, direction) + return not not Collision.canMove(map, peers or {}, actor, direction) +end +function C.stepNow(actor, direction) + if actor.moving then + return nil, "already moving" + end + actor.facing = direction + actor.targetX, actor.targetY = Collision.target(actor.cellX, actor.cellY, direction) + actor.moving, actor.progress = true, 0 + return true +end +function C.placeAt(actor, x, y, facing) + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + actor.cellX, actor.cellY, actor.px, actor.py = x, y, x * 16, y * 16 + if facing then + actor.facing = facing + end + return true +end +function C.setPassable(actor, passable) + actor.passable = passable + return true +end +function C.setAppearance(actor, game, id, req) + if not (game.data and game.data.sprites and game.data.sprites[id]) then + return nil, "unknown sprite: " .. tostring(id) + end + -- Resolve first, then commit. A renderer failure cannot half-change a live + -- actor or its imported template. This is the existing API-3 native path. + local proxy = { id = actor.id, def = copy(actor.def) } + proxy.def.sprite = id + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = nil, nil, nil, nil + local nativeNpc = (req or require)("src.world.NPC") + nativeNpc.refreshSprite(proxy, game.data) + actor.def, actor.sprite, actor.appearance = proxy.def, proxy.sprite, id + actor.big = proxy.sprite and proxy.sprite.big or false + return true +end +return C diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a17b2dd6e..2f4cb79b1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3424,6 +3424,34 @@ function OverworldState:checkSpecialPhoneCall() Game.save.g2CurCaller = caller self:queueScript(script, { phoneCaller = caller, onDone = function() Game.save.g2CurCaller = nil end }) + Gen2Commands.announceIncomingCall(Game.data, caller, Game.save.g2SpecialCallActive) +end + +-- A purchase is already committed at trainer reload. Its original phone +-- program waits for the trainer's queued dialogue and overlays to finish. +function OverworldState:checkMomShoppingCall() + local pending=self.g2MomCallPending + if not pending then return false end + if pending.save~=Game.save or pending.data~=Game.data then self.g2MomCallPending=nil;return false end + local top=Game.stack and Game.stack.top and Game.stack:top() + if top and top~=self then return false end + if self.player and(self.player.moving or self.player.targetX or self.player.stairExit or self.player.inputLocked)then return false end + if self.transitioning or self.runner:isRunning()or #(self.scriptMoves or{})>0 or self.engaging or self.emote or + self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.flyAnim or self.fishing or + self.pendingScripts and self.pendingScripts[1]then return false end + self.g2MomCallPending=nil + local purchase=pending.purchase + Game.save.g2CurCaller=purchase.caller + pcall(function()require("src.core.Sound").play(Game.data,"Call")end) + self:queueScript(purchase.script,{phoneCaller=purchase.caller,onDone=function() + if Game.save~=pending.save or Game.data~=pending.data then return end + Game.save.g2CurCaller=nil + require("src.script.Gen2Commands").resetCallDelay(Game.save,math.floor(require("src.core.SaveData").playSeconds(Game.save)/60)) + pcall(function()require("src.core.Sound").play(Game.data,"Hang_Up")end) + end}) + local commands=require("src.script.Gen2Commands") + if commands.announceIncomingCall then commands.announceIncomingCall(Game.data,purchase.caller)end + return true end -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step @@ -3459,6 +3487,7 @@ function OverworldState:checkIncomingPhoneCall() Game.save.g2CurCaller = nil pcall(function() require("src.core.Sound").play(Game.data, "Hang_Up") end) end }) + Gen2Commands.announceIncomingCall(Game.data, id) end function OverworldState:update(dt) @@ -3793,6 +3822,7 @@ function OverworldState:update(dt) -- not an event, and the check is two flag reads. self:applyForcedBike() self:checkSpecialPhoneCall() + self:checkMomShoppingCall() self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -14827,6 +14857,19 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if result=="win" and battle and battle.kind=="trainer" and not battle.linkBattle and not battle.battleTower and + not battle.g2MomHandled and require("src.world.Gen2MomShopping").stock()then + battle.g2MomHandled=true + if not self.g2MomCallPending then + local purchase=require("src.world.Gen2MomShopping").tryBuy(Game.data,Game.save,{ + phoneService=require("src.world.Gen2PhoneService").available(self.map and self.map.def), + queued=self.pendingScripts and self.pendingScripts[1]~=nil, + }) + if purchase then self.g2MomCallPending={save=Game.save,data=Game.data,purchase=purchase}end + end + end + + self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish @@ -15374,7 +15417,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection - Game.stack:push(Transition.new(Game, function() + local transition = Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) self:noteGen2Spawn(fromId) -- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile @@ -15564,7 +15607,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- side, which is what the scripts already do. if self.player then self.player.inputLocked = false end if onDone then onDone() end - end)) + end) + -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint + -- and completion path; ordinary/native callers keep their existing fade. + if opts and opts.fade == false then + transition.onMidpoint() + transition.onDone() + else + Game.stack:push(transition) + end end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..f2fddcada 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,17 +6,24 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local NpcControls = require("src.world.NpcControls") +local Collision = require("src.world.Collision") +local Version = require("src.core.GameVersion") +local Reads = require("src.mods.WorldReads") +local WorldOverview = require("src.mods.WorldOverview") +local MapOverview = require("src.world.MapOverview") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" -function WorldAPI.new(game, modId) - return setmetatable({ game = game, modId = modId }, WorldAPI) +function WorldAPI.new(game, modId, readContext) + return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack @@ -58,6 +65,41 @@ function WorldAPI:current() elevation) } end +-- Detached common Pokemon records. This also works with a save at the title +-- screen: reading the party does not need an active overworld. +function WorldAPI:party() + return Reads.party(self.game, self.readContext) +end + +-- Require the expected active map to reject stale requests after a warp. +-- The second result describes block size/coordinates and the DS proxy grid. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.activeBlockAt(ow.map, mapId, bx, by) +end + +-- Loaded maps only, active first and with distinct placements. loadedMaps() +-- retains its legacy shape/order; these records also describe block units. +function WorldAPI:liveMaps() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.liveMaps(ow) +end + +-- Detached, zero-based terrain and marker grids for companion UIs/minimaps. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return MapOverview.build(self.game, ow) +end + +-- Table distribution before per-step RNG, repel, roamers and roll hooks. +-- Explicit map IDs also work at the title screen without loading that map. +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts) +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) @@ -362,6 +404,79 @@ function Handle:elevation() return self.npc.gen3Elevation end +-- New immediate controls retain the legacy handle and its existing methods. +-- A retained handle cannot apply these controls to a later native cohort. +local function npcCoordinate(v) + return type(v) == "number" and v == v and v >= 0 and v <= 1000000 and v % 1 == 0 +end +local function control(handle, name, a, b, c) + local context = handle._controlContext + local api, game, ow, actor = context[1], context[2], context[3], context[4] + if + not ow.map + or handle.ow ~= ow + or handle.npc ~= actor + or api.game ~= game + or game.data ~= context[5] + or game.save ~= context[6] + or Version.get() ~= context[8] + or api:overworld() ~= ow + or ow.map ~= context[7] + or ow.map.id ~= context[10] + or game.overworld ~= context[11] + or api._npcAlive and (not api._npcAlive() or Runtime.hooks ~= context[9]) + then + return nil, "NPC handle is stale" + end + local found = false + for _, candidate in ipairs(ow.npcs or {}) do + if candidate == actor then + found = true + break + end + end + if not found then + return nil, "NPC handle is stale" + end + if name == "stepNow" or name == "canStep" then + if not Collision.DELTA[a] then + return nil, "invalid direction" + end + end + if name == "placeAt" then + if not npcCoordinate(a) or not npcCoordinate(b) or c ~= nil and not Collision.DELTA[c] then + return nil, "invalid coordinates" + end + end + if name == "canStep" then + return NpcControls.canStep(actor, ow.map, ow.cast or ow.entities, a) + end + if name == "isMoving" then + return NpcControls.isMoving(actor) + end + if name == "setAppearance" then + return NpcControls.setAppearance(actor, game, a) + end + if name == "setPassable" then + return NpcControls.setPassable(actor, not not a) + end + return NpcControls[name](actor, a, b, c) +end +for _, method in ipairs({ "stepNow", "canStep", "placeAt", "isMoving", "setPassable", "setAppearance" }) do + local name = method + Handle[name] = function(self, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = { pcall(control, self, name, ...) } + Runtime.currentMod = previous + if not result[1] then + local ok, message = pcall(tostring, result[2]) + return nil, "NPC operation failed: " .. (ok and message or "unprintable error") + end + return result[2], result[3] + end +end + function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end @@ -369,7 +484,9 @@ function WorldAPI:npc(mapId, indexOrName) for _, npc in ipairs(ow.npcs or {}) do if npc.def.index == indexOrName or npc.def.name == indexOrName or npc.id == indexOrName then - return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle) + return setmetatable({ ow = ow, npc = npc, id = npc.id, + _controlContext = { self, self.game, ow, npc, self.game.data, self.game.save, + ow.map, Version.get(), Runtime.hooks, ow.map and ow.map.id, self.game.overworld } }, Handle) end end return nil, "no such object: " .. tostring(indexOrName) @@ -386,6 +503,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..3244aa8a6 --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,471 @@ +-- Supported mutations go through the existing gameplay entry points. Shared +-- guards keep companion UIs out of screens/scripts/transitions that own input. +local GameVersion = require("src.core.GameVersion") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} +local METHODS = { + canReorderParty = true, + reorderParty = true, + availableFieldActions = true, + useFieldAction = true, + canFly = true, + flyTo = true, +} +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", +} +local pending = setmetatable({}, { __mode = "k" }) +local planning = setmetatable({}, { __mode = "k" }) +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function A.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +local function ready(game, ow) + if not (game and game.save) then + return nil, "no save" + end + if not (ow and ow.map and ow.player) then + return nil, "no overworld" + end + local token = pending[game] + if token and token.state then + local held = false + for _, state in ipairs(game.stack and game.stack.states or {}) do + if state == token.state then + held = true + end + end + if game.stack and game.stack.top and game.stack:top() == token.state then + held = true + end + if + not held + or game.save ~= token.save + or game.data ~= token.data + or ow.map ~= token.map + or ow.player ~= token.player + then + pending[game] = nil + end + end + if not (game.stack and game.stack.top and game.stack:top() == ow) then + return nil, "world is busy" + end + if + pending[game] + or planning[game] + or (game.overworld and game.overworld ~= ow) + or game.linkSession + or (game.linkNet and not game.linkNet.closed) + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + local player = ow.player + if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then + return nil, "world is busy" + end + if not (ow.runner and type(ow.runner.isRunning) == "function") then + return nil, "world is busy" + end + if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then + return nil, "world is busy" + end + return true +end +-- Shared admission guard for the API-3 warp adapter. +A.ready = ready +local function guardedCall(game, fn, ...) + planning[game] = true + local result = { pcall(fn, game, ...) } + planning[game] = nil + if not result[1] then + error(result[2], 0) + end + return result[2], result[3] +end +local function party(game) + return game and game.save and game.save.party or {} +end +local function member(game, mon) + if type(mon) ~= "table" or Party.isEgg(mon) then + return nil + end + for _, p in ipairs(party(game)) do + if p == mon then + return mon + end + end +end +local function user(game, ow, move) + if type(ow.partyKnows) ~= "function" then + return nil + end + return member(game, ow:partyKnows(move)) +end +local function context(game, ow) + local p = ow.player + return { + save = game.save, + data = game.data, + map = ow.map, + player = p, + x = p.cellX, + y = p.cellY, + facing = p.facing, + version = GameVersion.get(), + } +end +local function same(game, ow, c) + return game.save == c.save + and game.data == c.data + and ow.map == c.map + and ow.player == c.player + and ow.player.cellX == c.x + and ow.player.cellY == c.y + and ow.player.facing == c.facing + and GameVersion.get() == c.version +end +local function currentWorld(game, ow) + -- A state replaced while a cut-in was active must not receive its effect. + return game.stack and game.stack.top and game.stack:top() == ow +end +local function present(game, ow, mon, check, run) + local c = context(game, ow) + local called = false + local token = { save = game.save, data = game.data, map = ow.map, player = ow.player } + pending[game] = token + local result, reason + local function finish() + if called then + return + end + called = true + if pending[game] ~= token then + return + end + pending[game] = nil + if + not currentWorld(game, ow) + or not same(game, ow, c) + or not ready(game, ow) + or not member(game, mon) + then + result, reason = nil, "world changed" + return + end + if not check() then + result, reason = nil, "action unavailable" + return + end + -- A capability hook invoked by check() may have changed the session. + if not same(game, ow, c) or not ready(game, ow) then + result, reason = nil, "world changed" + return + end + local answer = guardedCall(game, run) + if answer == false then + result, reason = nil, "action unavailable" + else + result = true + end + end + if GameVersion.generation() == 3 then + -- Never retry an engine callback that threw after invoking finish(). + local ok, shown = pcall(function() + return require("src.world.Gen3FieldMove").show(game, mon, finish) + end) + if not ok then + pending[game] = nil + error(shown, 0) + end + if shown then + token.state = game.stack:top() + return true + end + end + finish() + return result, reason +end + +function A.canReorderParty(game, ow) + return #party(game) > 1 and ready(game, ow) == true +end +function A.reorderParty(game, ow, from, to) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + local p = party(game) + if + not integer(from, 1, Party.MAX) + or not integer(to, 1, Party.MAX) + or type(p[from]) ~= "table" + or type(p[to]) ~= "table" + then + return nil, "invalid party slot" + end + -- Match the native party menus: swap references, never reconstruct mons. + if from ~= to then + p[from], p[to] = p[to], p[from] + end + return true +end + +local function outside(game, ow) + return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets")) +end +local function validSpot(data, id, x, y, gen) + local def = data and data.maps and data.maps[id] + if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then + return false + end + local tileset = data.tilesets and data.tilesets[def.tileset] + local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2) + return integer(cells, 1, 1000000) + and integer(x, 0, def.width * cells - 1) + and integer(y, 0, def.height * cells - 1) +end +local function flyPlan(game, ow, mapId) + local gen = GameVersion.generation() + local data, save = game.data or {}, game.save + local mon + if gen >= 4 then + local F = require("src.world.Gen4FieldMoves") + for _, p in ipairs(party(game)) do + if not Party.isEgg(p) and F.knows(p, "FLY") then + mon = p + break + end + end + if not mon then + return nil, "fly unavailable" + end + if gen == 5 then + local Fly = require("src.world.Gen5Fly") + if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, loc in ipairs(Fly.destinations(data, save)) do + local dest = Fly.destination(data, loc) + if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = dest, ds = true } + end + end + else + local Fly = require("src.world.Gen4Fly") + local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save) + if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, dest in ipairs(Fly.destinations(data, save)) do + if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true } + end + end + end + else + mon = user(game, ow, "FLY") + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not mon or not travel or type(ow.flyTo) ~= "function" then + return nil, "fly unavailable" + end + local field = data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = data.maps and data.maps[id] + local spot = field.flyWarps and field.flyWarps[id] + if + (not mapId or mapId == id) + and save.visited + and save.visited[id] + and spot + and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") + and validSpot(data, id, spot.x, spot.y, gen) + then + return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } } + end + end + end + return nil, "destination unavailable" +end +function A.canFly(game, ow) + if not ready(game, ow) then + return false + end + local c = context(game, ow) + return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true +end +function A.flyTo(game, ow, mapId) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(mapId) ~= "string" or mapId == "" then + return nil, "invalid destination" + end + local c = context(game, ow) + local plan, why = guardedCall(game, flyPlan, ow, mapId) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, flyPlan, ow, mapId) + return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y + end, function() + if plan.ds then + return ow:gen4FlyTo(plan.dest, plan.mon) + end + return ow:flyTo(plan.dest.map, plan.mon) + end) +end + +local ORDER = { "cut", "surf", "dig", "teleport" } +local function fieldPlan(game, ow, id) + local gen = GameVersion.generation() + if gen >= 4 then + return nil, "DS field actions require a native script adapter" + end + if id == "cut" then + if gen == 3 then + return nil, "Gen 3 Cut requires an object-script adapter" + end + local mon = user(game, ow, "CUT") + if + mon + and type(ow.useCutFieldMove) == "function" + and ow:useCutFieldMove() == "ok" + and type(ow.tryCut) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:tryCut(x, y) + end, + } + end + elseif id == "surf" then + local mon = user(game, ow, "SURF") + if + mon + and not ow.player.surfing + and type(ow.useSurfFieldMove) == "function" + and ow:useSurfFieldMove() == "ok" + and type(ow.trySurf) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:trySurf(x, y, nil, mon) + end, + } + end + elseif id == "dig" or id == "teleport" then + local mon = user(game, ow, id:upper()) + if not mon or type(ow.beginTeleportOut) ~= "function" then + return nil, "field action unavailable" + end + local escape = false + if id == "teleport" then + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not travel or not game.save.lastHeal then + return nil, "field action unavailable" + end + elseif gen == 1 then + local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } + if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then + return nil, "field action unavailable" + end + else + local point = type(ow.escapePoint) == "function" and ow:escapePoint() + if not point then + return nil, "field action unavailable" + end + if gen == 3 then + if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then + return nil, "field action unavailable" + end + elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then + return nil, "field action unavailable" + end + escape = true + end + return { + mon = mon, + run = function() + return ow:beginTeleportOut(nil, { escape = escape or nil }) + end, + } + end + return nil, "field action unavailable" +end +function A.availableFieldActions(game, ow) + local ok, reason = ready(game, ow) + if not ok then + return {}, reason + end + local c = context(game, ow) + local out = {} + for _, id in ipairs(ORDER) do + if guardedCall(game, fieldPlan, ow, id) then + out[#out + 1] = { id = id, label = id:upper() } + end + if not same(game, ow, c) or not ready(game, ow) then + return {}, "world changed" + end + end + return out +end +function A.useFieldAction(game, ow, id, opts) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(id) ~= "string" then + return nil, "invalid field action" + end + if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then + return nil, "unsupported field action options" + end + local c = context(game, ow) + local plan, why = guardedCall(game, fieldPlan, ow, id) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, fieldPlan, ow, id) + return fresh and fresh.mon == plan.mon + end, plan.run) +end +return A diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua new file mode 100644 index 000000000..d26c5321e --- /dev/null +++ b/tests/api3_contract/mods/edits/main.lua @@ -0,0 +1,37 @@ +-- Identical source on both engines; native layout stays in the driver. +local mod = ... +local world = mod.world +assert(mod:supports("world", "toggleObject")) +assert(mod:supports("world", "replaceBlock")) +local ok, why = world:toggleObject("HOME", "NPC", false) +assert(ok == nil and why == "no game") +ok, why = world:replaceBlock(0, 0, 7) +assert(ok == nil and why == "no game") +local events, reenter, nested = {}, false, nil +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } + if reenter then + nested = { world:replaceBlock(0, 0, 7) } + end +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block } +end) +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.replace = function(...) + return world:replaceBlock(...) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end +mod.exports.events = function() + return events +end +mod.exports.reenter = function(value) + reenter = value +end +mod.exports.nested = function() + return nested +end diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json new file mode 100644 index 000000000..8d0912a07 --- /dev/null +++ b/tests/api3_contract/mods/edits/manifest.json @@ -0,0 +1 @@ +{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua new file mode 100644 index 000000000..696dfaafe --- /dev/null +++ b/tests/api3_contract/mods/npcs/main.lua @@ -0,0 +1,28 @@ +-- Same public-only mod on both engines. Native timing stays in the driver. +local mod = ... +local world = mod.world +for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + assert(mod:supports("world", name)) +end +local ok, why = world:npc("HOME", 1) +assert(ok == nil and why == "no game") +local calls, args = 0, nil +mod.exports.spawn = function(...) + return world:spawnNpc(...) +end +mod.exports.remove = function(...) + return world:removeNpc(...) +end +mod.exports.npc = function(...) + return world:npc(...) +end +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.done = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json new file mode 100644 index 000000000..16a8a3457 --- /dev/null +++ b/tests/api3_contract/mods/npcs/manifest.json @@ -0,0 +1 @@ +{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua new file mode 100644 index 000000000..2dd8de861 --- /dev/null +++ b/tests/api3_contract/mods/warp/main.lua @@ -0,0 +1,23 @@ +-- Identical public-only mod on both engines: the harness owns native timing. +local mod = ... +local world = mod.world +assert(mod:supports("world", "warpTo")) +local early, reason = world:warpTo("DEST", 0, 0) +assert(early == nil and reason == "no game") +local calls, args, events = 0, nil, {} +mod.events:on("map.entered", function(ev) + events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via } +end) +mod.exports.request = function(...) + return world:warpTo(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.results = function() + return { calls = calls, args = args, events = events } +end +mod.exports.current = function() + return world:current() +end diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json new file mode 100644 index 000000000..6bc2f80ea --- /dev/null +++ b/tests/api3_contract/mods/warp/manifest.json @@ -0,0 +1 @@ +{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua new file mode 100644 index 000000000..1e3170249 --- /dev/null +++ b/tests/api3_contract/npcs.lua @@ -0,0 +1,320 @@ +-- Real loaders/native public adapters with native actor/map/timing seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version, modId) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs") + local Runtime = require("src.mods.Runtime") + love = love or require("tests.love_stub") + -- Render construction has no bearing on the public state contract. + local Renderer = require("src.render.SpriteRenderer") + local savedRenderer = Renderer.new + Renderer.new = function(def) + return { def = def, big = false } + end + f.restoreRenderer = function() + Renderer.new = savedRenderer + end + local game, ow = f.game, f.ow + game.data.sprites = { + PROBE = { image = "fixture.png", frames = 6, walker = true }, + ALT = { image = "alt.png", frames = 6, walker = true }, + } + game.data.gen2Sprites = game.data.sprites + ow.game, ow.sprites = game, game.data.sprites + local tiles = { + blocks = { [2] = {} }, + walkable = { 0 }, + collision = { [2] = { 0, 0, 0, 0 } }, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + for i = 1, 16 do + tiles.blocks[2][i] = 0 + end + if not (backend == "gen1" and f.gen == 2) then + tiles.collision = nil + end + for _, id in ipairs({ f.home, f.dest }) do + local def = game.data.maps[id] + def.width, def.height, def.objects, def.blocks = 8, 8, {}, {} + for i = 1, 64 do + def.blocks[i] = 1 + end + end + local base = { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 6, + y = 6, + movement = "STAY", + range = "DOWN", + } + game.data.maps[f.home].objects = { base } + if backend == "gen1" and f.gen == 2 then + ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles) + elseif not (backend == "gen1" and f.gen == 3) then + ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles) + end + ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {} + f.player.cellX, f.player.cellY = 7, 7 + function f:make(id, row) + local actor + if backend == "gen1" and self.gen == 2 then + actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite]) + elseif backend == "gen1" and self.gen == 3 then + actor = require("src.core.game3.object_prepare").instance(row, { mapId = id }) + actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index + else + actor = require("src.world.NPC").new(game.data, id, row) + end + function actor:queueApiMoves(_, callback) + f:begin(self, "march", nil, 1, callback) + return true + end + return actor + end + f.imported = f:make(f.home, base) + ow.npcs[1] = f.imported + ow.entities[2] = f.imported + ow.npcPool[f.imported.id] = f.imported + function f:begin(actor, kind, dir, count, callback) + self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback } + self.busy = true + actor.moving = true + if backend == "gen2" or self.gen == 1 then + local entry = + { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" } + ow.scriptMoves[#ow.scriptMoves + 1] = entry + end + if self.synchronous then + self:complete("sync", nil, "tail") + end + if self.failMovement then + return false, "movement refused" + end + return true + end + function ow:scriptMove(actor, dir, count, callback) + return f:begin(actor, "move", dir, count, callback) + end + function ow:marchInPlace(actor, callback) + return f:begin(actor, "march", nil, 1, callback) + end + function ow:beginMovement(objectId, bytes, callback) + local actor + for _, candidate in ipairs(self.npcs) do + if candidate.def.index + 1 == objectId then + actor = candidate + end + end + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback) + end + function ow:applySpritePalette(actor) + f.paletteActor = actor + end + function f:complete(...) + local pending = self.pending + assert(pending, "no pending move") + local actor = pending.actor + if pending.kind == "move" then + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir] + actor.cellX, actor.cellY = + actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count + end + actor.moving, actor.marching = false, false + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + self.busy = false + ow.scriptMoves = {} + if pending.callback then + pending.callback(...) + end + end + function f:spawn(id, row, owner) + local def = assert(game.data.maps[id]) + local index = 0 + for _, existing in ipairs(def.objects) do + index = math.max(index, existing.index) + end + row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner + def.objects[#def.objects + 1] = row + local publicId = id .. "_obj_" .. row.index + if id == self.home then + local actor = self:make(id, row) + ow.npcs[#ow.npcs + 1] = actor + ow.entities[#ow.entities + 1] = actor + ow.npcPool[publicId] = actor + if self.byId then + self.byId[row.localId] = actor + self.order[#self.order + 1] = row.localId + end + end + return publicId + end + function ow:addRuntimeObject(id, row, owner) + return f:spawn(id, row, owner) + end + function f:remove(id, owner) + for _, def in pairs(game.data.maps) do + for i, row in ipairs(def.objects or {}) do + if row.runtime and def.id .. "_obj_" .. row.index == id then + if row.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + for _, list in ipairs({ ow.npcs, ow.entities }) do + for j = #list, 1, -1 do + if list[j].id == id then + table.remove(list, j) + end + end + end + ow.npcPool[id] = nil + if self.byId then + self.byId[row.localId] = nil + for j = #self.order, 1, -1 do + if self.order[j] == row.localId then + table.remove(self.order, j) + end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(id) + end + function ow:removeRuntimeObject(id, owner) + return f:remove(id, owner) + end + if backend == "gen1" and f.gen == 3 then + f.byId, f.order = { [1] = f.imported }, { 1 } + package.loaded["src.core.game3.objects"] = { + _byId = f.byId, + _order = f.order, + addRuntimeObject = function(id, row, owner) + return f:spawn(id, row, owner) + end, + removeRuntimeObject = function(id, owner) + return f:remove(id, owner) + end, + scriptFace = function(actor, dir) + actor.facing = dir + end, + scriptStep = function(actor, dir) + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir] + actor.facing = dir + actor.targetX = actor.cellX + delta[1] + actor.targetY = actor.cellY + delta[2] + actor.moving = true + end, + startTrack = function(id, actions, callback) + local count = 0 + for _, action in ipairs(actions) do + if action.kind == "step" then + count = count + 1 + end + end + return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback) + end, + } + package.loaded["src.core.game3.collision"] = { + canEnter = function() + return true + end, + isWater = function() + return false + end, + } + end + function f:update() + return self.loader.hooks:call("input.step", function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ex = f.exports + local report = { invalid = {}, actions = {} } + local function refused(value, why) + assert(value == nil and type(why) == "string" and #why > 0, tostring(why)) + end + refused(ex.spawn("UNKNOWN", { x = 0, y = 0 })) + refused(ex.spawn(f.home, { x = -1, y = 0 })) + refused(ex.spawn(f.home, { x = 0.5, y = 0 })) + refused(ex.npc(f.home, false)) + refused(ex.remove(false)) + assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1) + local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" } + local id = assert(ex.spawn(f.home, definition)) + local handle = assert(ex.npc(f.home, id)) + assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id) + definition.x, definition.sprite = 99, "changed" + local actor = f.ow.npcPool[id] + assert(actor.cellX == 0 and actor.def.sprite == "PROBE") + for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do + refused(handle[method](handle, "north")) + end + refused(handle:scriptMove("right", 0)) + refused(handle:scriptMove("right", 1025)) + refused(handle:scriptMove("right", 1, false)) + refused(handle:marchInPlace(false)) + refused(handle:placeAt(-1, 0)) + refused(handle:setPassable("yes")) + refused(handle:setAppearance("")) + assert(handle:face("right")) + local allowed, blocked = handle:canStep("right") + assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked)) + assert(handle:setPassable(true) and actor.passable == true) + assert(handle:setAppearance("ALT")) + assert(handle:position() == 0 and handle:isMoving() == false) + assert(handle:stepNow("right")) + assert(handle:isMoving() == true and actor.targetX == 1) + refused(handle:stepNow("right")) + assert(handle:placeAt(2, 2, "up")) + assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32) + local x, y = handle:position() + assert(x == 2 and y == 2) + assert(handle:scriptMove("right", 2, ex.done)) + assert(ex.result().calls == 0) + f:complete("done", nil, "tail") + x, y = handle:position() + assert(x == 4 and y == 2) + assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail") + assert(handle:marchInPlace(ex.done)) + f:complete("march") + x, y = handle:position() + assert(x == 4 and y == 2 and ex.result().calls == 2) + assert(handle:scriptMove("left")) + f:complete() + assert(handle:position() == 3 and ex.result().calls == 2) + assert(handle:marchInPlace()) + f:complete() + assert(handle:placeAt(4, 2)) + local imported = assert(ex.npc(f.home, "IMPORTED")) + refused(ex.remove(imported.id)) + local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + refused(ex.remove(other)) + assert(ex.remove(id)) + refused(ex.npc(f.home, id)) + assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id)) + report.actions = { + x = x, + y = y, + passable = actor.passable, + callbacks = ex.result().calls, + definitionCopied = true, + ownedRemoval = true, + } + report.invalid = true + assert(#f.loader.errors == 0) + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..c9fe68f51 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,257 @@ +-- Shared harness; only fixture construction knows the native engine layout. +local M = {} +local function read(path) + local f = assert(io.open(path, "rb")) + local body = f:read("*a") + f:close() + return body +end +local function dataFor(backend, generation) + if backend == "gen1" and generation == 3 then + local fixture = require("tests.modkit.sdk").gen3Data() + fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } + return fixture + end + local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 } + local mon = { + id = "MEW", + name = "MEW", + dex = 151, + types = { "PSYCHIC" }, + baseStats = stats, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + local effect = "NO_ADDITIONAL_EFFECT" + if generation > 1 then + stats.spatk, stats.spdef = 100, 100 + end + if backend == "gen1" and generation == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 100, 100 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + effect = "EFFECT_NORMAL_HIT" + end + local pokemon = { MEW = mon } + if backend == "gen1" and generation == 2 then + pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + return { + pokemon = pokemon, + moves = { + POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = effect, + }, + }, + items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } }, + maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } }, + trainers = {}, + encounters = {}, + audio = {}, + type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} }, + gen2Maps = backend == "gen1" and generation == 2 and { + PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }, + } or nil, + } +end +local function nativeMon(backend, gen) + local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 } + local mon = { + species = "MEW", + nickname = "Scout", + name = "Native name", + level = 20, + hp = 30, + status = "PSN", + stats = stats, + moves = { { id = "POUND", pp = 30, ppUps = 0 } }, + } + if gen > 1 then + stats.spatk, stats.spdef = 24, 25 + end + if backend == "gen1" and gen > 1 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 24, 25 + mon.status = gen == 2 and "poison" or 8 + if gen == 3 then + mon.species, mon.moves[1].id = 151, 1 + end + end + return mon +end +function M.run(root, backend, version, failing, scaffold) + local Loader = require("src.mods.Loader") + local Runtime = require("src.mods.Runtime") + local Version = require("src.core.GameVersion") + local Json = require("src.link.Json") + Version.set(version) + local generation = Version.generation(version) + local data = dataFor(backend, generation) + local files = {} + for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name) + end + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(assert(files[p]), "@" .. p) + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + for key in pairs(files) do + if key:sub(1, #p + 1) == p .. "/" then + return { type = "directory" } + end + end + end, + getDirectoryItems = function(p) + if p == "mods" then + return failing and { "portable", "rollback" } or { "portable" } + end + return {} + end, + } + local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version }) + local game + loader._game = function(_, loadedOnly) + if backend == "gen2" then + assert(loadedOnly, "API 3 must not bootstrap Game") + end + return game + end + local ok = loader:load(data) + if scaffold then + assert(ok, table.concat(loader.errors, "; ")) + assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3") + local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power + or data.moves.POUND.power + assert(power == 45, "generated entry changed the native move") + local report = { scaffold = true, power = power } + print("API3_CONTRACT " .. Json.encode(report)) + return report + end + local errorCount = #loader.errors + local exported = assert(loader.exports.portable, table.concat(loader.errors, "; ")) + assert(exported.report, table.concat(loader.errors, "; ")) + if failing then + assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true)) + else + assert(ok, table.concat(loader.errors, "; ")) + end + local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp + or data.pokemon.MEW.baseStats.hp + assert(nativeHp == 101, "rollback/native merge") + assert( + (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2, + "passthrough reads remain detached" + ) + local raw = nativeMon(backend, generation) + local save = { party = { raw }, flags = {}, modData = {}, money = 0 } + game = { data = data, save = save, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = save + end + exported.exercise() + Runtime.emit("pokemon.received", { mon = raw }) + assert(raw.hp == 30, "event read did not mutate native Pokemon") + loader.hooks:call("evolution.check", function(_, mon) + assert(mon == raw and mon.hp == 27, "native hook forwarding") + assert( + mon.status + == ( + backend == "gen1" and generation == 3 and 16 + or backend == "gen1" and generation == 2 and "burn" + or "BRN" + ), + "native status writeback" + ) + local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special + local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special + assert(attack == 31 and defense == 31, "common Special writes both later stats") + return "UNCHANGED" + end, game, raw, {}, {}) + assert(raw.hp == 27) + exported.late() + Runtime.emit("mod.portable.probe", { value = 8 }) + assert(exported.report.custom == 8, "custom callback reached live owner") + assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks") + if generation == 1 then + require("src.battle.TypeChart").load(data) + local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation) + local function battler(mon) + return { + mon = mon, + species = mon.species, + name = mon.nickname, + curStats = mon.stats, + curTypes = { "PSYCHIC" }, + curMoves = mon.moves, + stages = {}, + } + end + local battle = { + isBattleState = true, + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(player), + enemy = battler(enemy), + enemyParty = { enemy }, + ruleset = {}, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + } + function battle:battleKind() + return "wild" + end + function battle:visibleText() + return {} + end + function battle:effectRecord() + return { accuracyChecked = true } + end + game.save.party, game.save.inventory = { player }, {} + game.stack.states = { battle } + exported.snapshot() + assert(player.hp == 30, "invalid intent never dispatched") + end + -- The entry-time world handle must follow a later Game instead of retaining the old save. + local replacement = nativeMon(backend, generation) + replacement.hp = 19 + local nextSave = { party = { replacement }, modData = {}, flags = {} } + game = { data = data, save = nextSave, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = nextSave + end + assert(exported.world:party()[1].hp == 19, "captured facade follows current game") + assert(exported.battle:snapshot() == nil, "no battle") + local result = exported.report + assert(result.event and result.hook and result.party and result.custom == 8) + print("API3_CONTRACT " .. Json.encode(result)) + return result +end +M.dataFor = dataFor +return M diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..5e7493d19 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,388 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version, modId) + modId = modId or "warp" + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/" .. modId then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { modId } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports[modId]) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..b288ae8c1 --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,28 @@ +-- Separate process per engine/version: modules and runtime buses cannot leak. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = nil +local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] +assert( + root + and backend + and version + and ( + mode == "normal" + or mode == "rollback" + or mode == "scaffold" + or mode == "warp" + or mode == "world_edits" + or mode == "npcs" + ), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +if mode == "npcs" then + dofile(codeRoot .. "/npcs.lua").run(root, backend, version) +elseif mode == "world_edits" then + dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) +elseif mode == "warp" then + dofile(codeRoot .. "/warp.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua new file mode 100644 index 000000000..7d77c555b --- /dev/null +++ b/tests/api3_contract/world_edits.lua @@ -0,0 +1,230 @@ +-- Real loaders, adapters and host commands, with native-shaped map/render seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "edits") + local Runtime = require("src.mods.Runtime") + local ow, game = f.ow, f.game + local def = game.data.maps[f.home] + local flagged = + { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 } + def.objects = { flagged, flagless } + def.blocks = { 7, 8, 7, 8, 7, 8 } + local tiles = { + walkable = {}, + blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} }, + collision = {}, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true } + f.rebuilds, f.syncs, f.reloads = 0, 0, 0 + if backend == "gen1" and f.gen == 2 then + -- Native Gen2 object definitions use list keys; public refs are still 1-based. + flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7 + ow.map = require("src.world.gen2.Map").new(def, tiles) + elseif backend == "gen1" and f.gen == 3 then + -- Native Gen3 uses the reflected layout and field override service. + local overrides = {} + def.midLayout.midAt = function(_, x, y) + return def.blocks[y * def.width + x + 1] + end + f.field.metatileOverrideAt = function(_, x, y) + return overrides[y * 1024 + x] + end + f.field.setMetatile = function(x, y, block) + overrides[y * 1024 + x] = { metatile = block } + f.rebuilds = f.rebuilds + 1 + Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block }) + end + package.loaded["src.core.game3.objects"] = { + _defs = { flagged, flagless }, + addObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = true + if obj.flag then + game.save.flags[obj.flag] = false + end + end, + removeObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = false + if obj.flag then + game.save.flags[obj.flag] = true + end + end, + } + else + ow.map = require("src.world.Map").new(def, tiles) + end + ow.map.renderer = { + rebuild = function() + f.rebuilds = f.rebuilds + 1 + end, + } + function ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + self.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:changeBlock(x, y, block) + self.map.blocks[y * self.map.width + x + 1] = block + self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:appearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = true + if obj.eventFlag then + game.save.flags[obj.eventFlag] = false + end + end + function ow:disappearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = false + if obj.eventFlag then + game.save.flags[obj.eventFlag] = true + end + end + function ow:syncObjectVisibility(obj) + f.syncs = f.syncs + 1 + local save = game.save + local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects + local key = f.gen == 2 and obj.name or obj.index + local value = session and session.mapId == f.home and session[key] + if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then + value = not save.flags[obj.eventFlag] + end + f.visible[obj.name] = value == nil and true or value + end + function ow:setMap(id, x, y, facing, opts) + f.reloads = f.reloads + 1 + assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player") + assert(opts.seamless and opts.keepMusic and opts.via == "reload") + for _, obj in ipairs(def.objects) do + local toggles = game.save.objectToggles[id] + if toggles[obj.name] ~= nil then + f.visible[obj.name] = toggles[obj.name] + end + end + end + function f:flagValue() + local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A" + return game.save.flags[key] + end + return f +end +function M.run(root, backend, version) + local Json = require("src.link.Json") + local f = M.fixture(root, backend, version) + local report = { invalid = {}, toggles = {}, blocks = {} } + local function unchanged() + assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0) + assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil) + assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0) + end + local function refusal(method, args, reason) + local ok, why = f.exports[method](unpack(args, 1, args.n or #args)) + assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason) + unchanged() + report.invalid[#report.invalid + 1] = why + end + for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do + refusal("toggle", { bad, "NPC", false }, "invalid object toggle") + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN") + refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle") + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do + refusal("replace", { 0, 0, bad }, "invalid block id") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do + refusal("replace", { bad, 0, 7 }, "invalid block coordinates") + refusal("replace", { 0, bad, 7 }, "invalid block coordinates") + end + refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds") + refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds") + if f.gen ~= 3 then + refusal("replace", { 0, 0, 9 }, "unknown block id") + end + if f.gen > 1 then + refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest) + refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN") + refusal("toggle", { f.home, 0, false }, "no such object: 0") + end + for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do + local other = M.fixture(root, backend, version) + if state == "overlay" then + other.game.stack.states[2] = {} + other.busy = true + elseif state == "moving" then + other.player.moving = true + elseif state == "script" then + other.busy = true + other.ow.runner.isRunning = function() + return true + end + if other.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + else + other.busy, other.ow.transitioning = true, true + if other.field then + other.field.locked = true + end + end + for _, method in ipairs({ "toggle", "replace" }) do + local ok, why + if method == "toggle" then + ok, why = other.exports.toggle(other.home, "NPC", false) + else + ok, why = other.exports.replace(0, 0, 7) + end + assert(ok == nil and type(why) == "string" and #other.exports.events() == 0) + end + end + f = M.fixture(root, backend, version) + for _, visible in ipairs({ false, true, true, false }) do + assert(f.exports.toggle(f.home, "NPC", visible)) + assert(f.visible.NPC == visible and f.visible.TEMP == true) + if f.gen > 1 then + assert(f:flagValue() == not visible) + end + report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() } + end + -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list + -- position, Gen3 local id. A portable mod queries the active map definition. + if f.gen > 1 then + assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false)) + assert(not f.visible.TEMP) + assert(f.exports.toggle(f.home, "TEMP", true)) + assert(f.visible.TEMP) + else + assert(f.exports.toggle(f.dest, "LATER", false)) + assert(f.game.save.objectToggles[f.dest].LATER == false) + end + for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do + assert(f.exports.replace(unpack(op))) + local value, metadata = f.exports.read(f.home, op[1], op[2]) + assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32)) + report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind } + end + assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0) + assert(#f.loader.errors == 0) + report.events = f.exports.events() + report.busy = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..35e00aece --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,232 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Manifest = require("src.mods.Manifest") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(api, extra) + local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api } + for k, v in pairs(extra or {}) do + raw[k] = v + end + return raw +end +for _, api in ipairs({ 1, 2, 3 }) do + local m = Manifest.validate(manifest(api)) + local format = api == 3 and "unified" or "native" + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.eq(m[field], format, "API " .. api .. " default " .. field) + end +end +T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy") +for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.raises(function() + Manifest.validate(manifest(3, { [field] = "native" })) + end, "API 3 requires unified", "API 3 refuses contradictory " .. field) + T.eq( + Manifest.validate(manifest(2, { [field] = "unified" }))[field], + "unified", + "API 2 keeps explicit opt-in" + ) + T.raises(function() + Manifest.validate(manifest(1, { [field] = "unified" })) + end, "mod API 2", "API 1 opt-in remains rejected") +end +T.raises(function() + Manifest.validate(manifest(4)) +end, "provides 3", "future API not silently accepted") +T.raises(function() + Manifest.validate(manifest(3.5)) +end, "positive integer", "fractional API rejected") +local function boot(api, extra, second, version) + Version.set(version or "red") + local rows = { probe = manifest(api, extra) } + if second then + rows.other = manifest(api, extra) + rows.other.id = "other" + end + local files = {} + for id, raw in pairs(rows) do + files["mods/" .. id .. "/manifest.json"] = Json.encode(raw) + files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod" + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if files[p] then + return { type = "file" } + end + if p == "mods" or rows[p:match("^mods/(.*)$")] then + return { type = "directory" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {} + end, + } + local loader = Loader.new({ fs = fs, dev = true }) + local game, probes = nil, 0 + loader._game = function(_, loadedOnly) + probes = probes + 1 + if api == 3 then + assert(loadedOnly, "API 3 requested Game bootstrap") + end + return game + end + T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api) + local mod = assert(loader.apis.probe) + return mod, loader, function(value) + game = value + end, function() + return probes + end +end +-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog. +for _, api in ipairs({ 1, 2 }) do + local m = boot(api) + T.eq(m.contentFormat, "native", "legacy content native") + T.eq(m.callbackFormat, "native", "legacy callbacks native") + T.eq(m.scriptFormat, "native", "legacy scripts native") +end +do + local m, loader, attach = + boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" }) + local mon = { + species = "MEW", + nickname = "Scout", + hp = 10, + status = "PSN", + stats = { hp = 20, special = 9 }, + moves = {}, + } + attach({ + save = { party = { mon } }, + data = { pokemon = {}, moves = {}, items = {} }, + stack = { + states = {}, + }, + }) + T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged") + local seen + m.events:on("pokemon.received", function(p) + seen = p.mon.status + end) + Runtime.emit("pokemon.received", { mon = mon }) + T.eq(seen, "poison", "API-2 unified event status unchanged") + T.raises(function() + m.events:on("checkpoint.restored", function() end) + end, "unknown unified callback", "API-2 catalog unchanged") +end +-- Availability must be honest, and a probe cannot construct Game or attach a world. +do + local m, loader, attach, probes = boot(3, nil, true) + T.eq(probes(), 0, "entry does not resolve Game") + local world, battle = m.world, m.battle + T.eq(type(world), "table", "world facade exists before Game") + T.eq(type(world.party), "function", "early world method callable") + T.eq(type(battle.snapshot), "function", "early battle method callable") + for _, name in ipairs({ + "spawnNpc", + "removeNpc", + "npc", + "warpTo", + "toggleObject", + "replaceBlock", + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "startWildBattle", + "invalidateMap", + }) do + local available, why = m:supports("world", name) + T.eq(available, false, "no unverified native promise for " .. name) + T.check(type(why) == "string", "unavailable reason for " .. name) + local ok, reason = world[name](world, "bad") + T.eq(ok, nil, "unsupported method does not dispatch " .. name) + T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping refused") + T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") + save.modData.probe.worldFlags = false + T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") + T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten") + attach({ save = { party = {} }, data = {}, stack = { states = {} } }) + T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save") + T.eq(m.world, world, "world handle identity stable") + T.eq(m.battle, battle, "battle handle identity stable") +end +for _, version in ipairs({ "platinum", "black" }) do + local m = boot(3, nil, false, version) + T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) + end + T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) + T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) + T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua new file mode 100644 index 000000000..f3c4844c4 --- /dev/null +++ b/tests/engine/api3_npc.lua @@ -0,0 +1,423 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Renderer = require("src.render.SpriteRenderer") +local originalRenderer = Renderer.new +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function spawn(f) + local id = assert( + f.exports.spawn( + f.home, + { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" } + ) + ) + return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id] +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared NPC contract: " .. tostring(why)) +end +local cycle = { x = 0, y = 0 } +cycle.self = cycle +local meta = setmetatable({ x = 0, y = 0 }, {}) +local deep = { x = 0, y = 0 } +local child = deep +for _ = 1, 18 do + child.next = {} + child = child.next +end +local wide = { x = 0, y = 0 } +for i = 1, 260 do + wide[i] = i +end +for _, row in ipairs({ + cycle, + meta, + deep, + wide, + { x = 0, y = 0, handler = function() end }, + { x = math.huge, y = 0 }, + { x = 0 / 0, y = 0 }, + { x = 1000001, y = 0 }, + { x = 0, y = -1 }, + { x = 0, y = 0.5 }, + { x = 0, y = 0, name = "bad\nname" }, + { x = 0, y = 0, sprite = "missing" }, + { x = 0, y = 0, facing = "north" }, + { x = 0, y = 0, payload = string.rep("x", 4097) }, +}) do + local f = fixture() + local value, why = f.exports.spawn(f.home, row) + T.eq(value, nil, "invalid definition refused") + T.check(type(why) == "string", "definition failure explained") + T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template") + T.eq(#f.ow.npcs, 1, "invalid definition adds no actor") +end +for _, version in ipairs({ "red", "gold", "firered" }) do + for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do + local f = fixture(version) + local handle, actor = spawn(f) + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "player" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "parallel" then + f.ow.parallelRunners = { {} } + else + f.ow._modScriptLock = {} + end + T.eq(handle:position(), 0, "busy world permits position read") + T.eq(handle:isMoving(), false, "busy world permits motion read") + T.eq(handle:face("left"), nil, lock .. " blocks mutation") + T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn") + T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal") + T.eq(actor.facing, "down", "refusal does not change actor") + end +end +for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do + for _, bad in ipairs({ "north", false, {}, 1 }) do + local f = fixture() + local handle = spawn(f) + local ok, why = handle[method](handle, bad) + T.eq(ok, nil, "bad direction refused for " .. method) + T.eq(why, "invalid direction", "bad direction diagnosed") + T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction") + end +end +for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do + local f = fixture() + local handle = spawn(f) + T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused") + T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect") +end +do + local f = fixture() + local handle, actor = spawn(f) + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + local value, why = handle:face("left") + T.eq(value, nil, "native construction cannot reenter NPC mutation") + T.eq(why, "world is busy", "NPC reentry diagnosed") + value, why = f.exports.toggle(f.home, "IMPORTED", false) + T.eq(value, nil, "NPC construction cannot reenter object edit") + T.eq(why, "world is busy", "shared mutation guard diagnosed") + return native(self, ...) + end + T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds") + T.eq(actor.facing, "down", "reentry leaves actor untouched") + T.check(handle:face("right"), "construction releases guard") + local constructor = Renderer.new + Renderer.new = function() + error("renderer failed", 0) + end + local def, sprite = actor.def, actor.sprite + T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained") + T.eq(actor.def, def, "failed renderer retains original definition") + T.eq(actor.sprite, sprite, "failed renderer retains original sprite") + Renderer.new = constructor + T.check(handle:face("up"), "renderer exception releases guard") +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do + local f = fixture() + local handle, actor = spawn(f) + T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change) + local callback = f.pending.callback + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "world" then + local old = f.game.overworld + f.game.overworld = {} + restore = function() + f.game.overworld = old + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "actor" then + table.remove(f.ow.npcs, #f.ow.npcs) + restore = function() + f.ow.npcs[#f.ow.npcs + 1] = actor + end + else + local object = (change == "map") and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f:update() + T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement") + restore() + callback("stale") + callback("replayed") + T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback") +end +for _, event in ipairs({ "map.exited", "map.entered" }) do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle") + local callback = f.pending.callback + Runtime.emit(event, { mapId = f.home }) + T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately") + callback() + T.eq(f.exports.result().calls, 0, "old map callback suppressed") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + local handle, actor = spawn(f) + local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action) + local callback = f.pending.callback + local foreignEntry = { + entity = f.imported, + remaining = 1, + onDone = function() + error("do not invoke") + end, + } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry + if action == "rollback" then + f.loader:_rollback("npcs") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries") + T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained") + T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template") + T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool") + T.check(f.ow.npcPool[foreign], "foreign owner retained") + callback() + T.eq(f.exports.result().calls, 0, "unload suppresses native callback") + T.eq(handle:position(), nil, "unloaded handle retired") + T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired") +end +do + local f = fixture() + local handle, actor = spawn(f) + T.check(f.exports.remove(handle.id), "own removal succeeds") + local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 })) + T.eq(replacement, handle.id, "native ID can be reused") + local value, why = handle:face("left") + T.eq(value, nil, "old handle cannot change replacement with same ID") + T.eq(why, "NPC handle is stale", "stale handle diagnosed") + T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched") +end +do + local f = fixture() + local handle, actor = spawn(f) + f.synchronous = true + T.check( + handle:scriptMove("right", 1, function(...) + T.eq(Runtime.currentMod, "npcs", "movement callback attributed") + f.exports.done(...) + f.synchronous = false + T.check(handle:face("up"), "synchronous callback runs after commit guard released") + end), + "synchronous move accepted" + ) + T.eq(f.exports.result().calls, 1, "synchronous callback called once") + T.eq(f.exports.result().args.n, 3, "nil argument holes preserved") + f.pending.callback() + T.eq(f.exports.result().calls, 1, "duplicate completion ignored") + T.eq(Runtime.currentMod, nil, "callback restores runtime owner") +end +for _, err in ipairs({ + "authored failure", + setmetatable({}, { + __tostring = function() + error("hostile tostring") + end, + }), +}) do + local f = fixture() + local handle = spawn(f) + T.check( + handle:scriptMove("right", 1, function() + error(err, 0) + end), + "throwing callback admitted" + ) + T.check( + pcall(function() + f:complete() + f.pending.callback() + end), + "callback errors cannot escape native completion" + ) + T.eq(#f.loader.errors, 1, "callback error attributed once") + T.check( + f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true), + "callback error names owner" + ) +end +do + local f = fixture() + local handle = spawn(f) + f.failMovement, f.synchronous = true, true + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission") + T.eq(f.exports.result().calls, 0, "callback deferred until native result") + T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry") +end +do + local f = fixture() + local handle = spawn(f) + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + Runtime.emit("map.exited", { mapId = f.home }) + return native(self, ...) + end + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion") + T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race") +end +do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right"), "optional count/callback accepted") + f:complete() + T.eq(handle:position(), 1, "default count is one") + T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable") +end +-- Real native object creation/removal, NPC updates and movement queue. +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.addRuntimeObject, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.addRuntimeObject, i, game) + return function() + debug.setupvalue(OW.addRuntimeObject, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject + f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace + local handle, actor = spawn(f) + actor.frozen = true + T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts") + for _ = 1, 40 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "native queue walks exactly two cells") + T.eq(f.exports.result().calls, 1, "native completion exactly once") + T.eq(#f.ow.scriptMoves, 0, "native movement releases world input") + T.check(handle:marchInPlace(f.exports.done), "native march starts") + for _ = 1, 20 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "march does not translate") + T.eq(f.exports.result().calls, 2, "native march completes") + T.check(handle:setAppearance("ALT"), "native renderer refresh path") + T.eq(actor.def.sprite, "ALT", "actor appearance changed") + T.eq( + f.game.data.maps[f.home].objects[2].sprite, + "PROBE", + "native template data not rewritten by appearance" + ) + T.check(f.exports.remove(handle.id), "native removal succeeds") + T.eq(#f.ow.npcs, 1, "native removal retains imported actor") + restore() +end +do + local f = fixture() + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + native(self, ...) + error("creation failure", 0) + end + T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained") + T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back") + T.eq(#f.ow.npcs, 1, "failed creation actor rolled back") + T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged") +end +do + local f = fixture() + local row = + { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 } + local id = assert(f.exports.spawn(f.dest, row)) + local record = f.game.data.maps[f.dest].objects[1] + T.eq(record.owner, "npcs", "caller cannot forge runtime ownership") + T.eq(record.runtime, true, "native runtime marker assigned") + T.eq(record.index, 1, "native identity replaces supplied index") + T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor") + local actor = f:make(f.dest, record) + actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones. + local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset) + f.ow.ghosts = { { npc = actor, map = map } } + f.ow.npcPool[id] = actor + local handle = assert(f.exports.npc(f.dest, id)) + T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame") + T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map") + local homeHandle, homeActor = spawn(f) + homeActor.cellX, homeActor.cellY = 2, 1 + local nb = { map = map, cx = 0, cy = 0, peers = { actor } } + f.ow.neighbors = { nb } + f.ow.ghosts[1].nb = nb + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor") + T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor") + homeActor.passable = true + actor.passable = true + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability") + T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability") + T.check(handle:face("left"), "neighbor can face") + local ok, why = handle:scriptMove("right", 1) + T.eq(ok, nil, "neighbor track refused") + T.eq(why, "movement tracks require the active map", "neighbor restriction explicit") + Loader.teardown() + T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload") + T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload") + T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload") +end +do + local f = fixture() + local handle, actor = spawn(f) + handle.id = f.imported.id + T.check(handle:face("left"), "public ID field cannot redirect private handle binding") + T.eq(actor.facing, "left", "original binding changed") + T.eq(f.imported.facing, "down", "imported actor unaffected") +end +Renderer.new = originalRenderer +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public NPC actions") diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua new file mode 100644 index 000000000..0bc8ddbb1 --- /dev/null +++ b/tests/engine/api3_warp.lua @@ -0,0 +1,249 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.warp") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared warp contract: " .. tostring(why)) +end +for _, lock in ipairs({ + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "scriptMoves", + "stairExit", + "spinning", + "inputLocked", + "fieldMove", + "linkSession", + "linkNet", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then + f.player[lock] = true + elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, lock .. " declines admission") + T.eq(why, "world is busy", lock .. " explains busy state") + T.eq(#f.trace, 0, lock .. " never dispatches") +end +do + local f = fixture() + f.game.save = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses") + f.live = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses") + T.eq(#f.trace, 0, "missing context never dispatches") +end +for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do + local f = fixture() + f.game.data.maps[f.dest].width = size + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, "malformed map dimensions refuse") + T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work") + T.eq(#f.trace, 0, "malformed maps never dispatch") +end +do + local f = fixture() + local opts = setmetatable({}, { + __index = function() + error("must not run") + end, + }) + local ok, why = f.exports.request(f.dest, 0, 0, "down", opts) + T.eq(ok, nil, "metatable options rejected") + T.eq(why, "invalid options", "plain options required") + T.eq(#f.trace, 0, "metatable never reaches native warp") +end +for _, change in ipairs({ "data", "stack", "player", "version" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change) + f:arrive() + local restore + if change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + else + local obj = change == "player" and f.ow or f.game + local previous = obj[change] + obj[change] = {} + restore = function() + obj[change] = previous + end + end + f:update() + restore() + f:complete() + T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change") +end +for _, failure in ipairs({ "refuse", "throw" }) do + local f = fixture() + f.failure, f.synchronous = failure, true + f.ow.arriveWarp = "original" + local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify }) + T.eq(ok, nil, "native " .. failure .. " is not admitted") + T.check(type(why) == "string", "native failure explained") + T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance") + T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field") + f:complete() + T.eq(f.exports.results().calls, 0, "failed callback cannot replay") +end +do + local f = fixture() + f.synchronous = true + local ok = f.exports.request(f.dest, 0, 0, "up", { + onDone = function(...) + T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod") + f.exports.notify(...) + f.synchronous = false + T.check( + f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }), + "callback may start next warp" + ) + end, + }) + T.check(ok, "synchronous native arrival admitted") + T.eq(#f.trace, 2, "one reentrant request dispatches once") + T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 2, "old completion does not clear next request") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation) + f:arrive() + if operation == "rollback" then + f.loader:_rollback("warp") + elseif operation == "reload" then + fixture() -- A new loader/session retires every previous facade. + else + Loader.teardown() + end + f:complete() + T.eq(f.exports.results().calls, 0, operation .. " retires callback") + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, operation .. " retires facade") + T.eq(why, "warp facade is retired", "stale facade diagnostic") +end +for _, direction in ipairs({ "up", "down", "left", "right" }) do + local f = fixture() + T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction) + T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion") +end +do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival") + f:complete() + T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival") +end +-- Exercise the actual native transition; only map materialization is synthetic. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, fade in ipairs({ true, false }) do + if fade or Version.generation(version) == 3 then + local f = fixture(version) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + T.eq(opts.via, "warp", "native transition retains warp reason") + T.eq(opts.keepMusic, true, "native transition retains music request") + f:arrive() + end + local opts = { onDone = f.exports.notify, keepMusic = true } + if f.gen == 3 then + opts.fade = fade + end + T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted") + if fade then + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade") + T.eq(f.exports.results().calls, 0, "callback waits for native transition") + for _ = 1, state.frames + state.framesIn + 2 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + end + else + T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen") + end + T.eq(f.ow.map.id, f.dest, "native transition arrives at destination") + T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained") + T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained") + T.eq(f.ow.transitioning, false, "native transition releases lock") + T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once") + restore() + end + end +end +-- Legacy public facade continues to ignore fade, rather than opting into API 3. +do + local f = fixture("firered") + local native = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged") + T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 portable warp") diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua new file mode 100644 index 000000000..3ef071813 --- /dev/null +++ b/tests/engine/api3_world_edits.lua @@ -0,0 +1,241 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.world_edits") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared object/block contracts: " .. tostring(why)) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do + local gen = Version.generation(version) + if + gen == 1 and (key == "objectToggles" or key == "flags") + or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles") + or gen == 3 + and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles") + then + local f = fixture(version) + f.game.save[key] = "keep" + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, key .. " malformed store rejected") + T.check(type(why) == "string", "store refusal explained") + T.eq(f.game.save[key], "keep", "malformed store retained") + T.eq(#f.exports.events(), 0, "malformed store emits no event") + T.eq(f.visible.NPC, true, "malformed store leaves native object untouched") + end + end +end +for _, version in ipairs({ "red", "firered" }) do + local f = fixture(version) + local key = f.gen == 1 and "objectToggles" or "gen3Spawned" + f.game.save[key] = { [f.home] = "keep" } + T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected") + T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced") + T.eq(#f.exports.events(), 0, "no event for malformed nested bucket") +end +for _, lock in ipairs({ + "linkSession", + "linkNet", + "gen3Locked", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "_modScriptLock", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects") + T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks") + T.eq(#f.exports.events(), 0, "busy requests never emit effects") +end +for _, failure in ipairs({ + "renderer", + "coordinates", + "geometry", + "current_block", + "objects", + "object_runtime", +}) do + local f = fixture("gold") + if failure == "renderer" then + f.ow.map.renderer = nil + elseif failure == "coordinates" then + f.ow.map.def.width = 0 + elseif failure == "geometry" then + f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2 + elseif failure == "current_block" then + f.ow.map.def.blocks[1] = nil + elseif failure == "objects" then + f.ow.map.def.objects = nil + else + f.ow.syncObjectVisibility = nil + end + local ok, why + if failure == "objects" or failure == "object_runtime" then + ok, why = f.exports.toggle(f.home, "NPC", false) + else + ok, why = f.exports.replace(0, 0, 7) + end + T.eq(ok, nil, failure .. " refuses safely") + T.check(type(why) == "string", "native context refusal explained") + T.eq(#f.exports.events(), 0, "invalid context emits nothing") + T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if operation == "rollback" then + f.loader:_rollback("edits") + elseif operation == "reload" then + fixture() + else + Loader.teardown() + end + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, operation .. " retires object action") + T.eq(why, "world facade is retired", "retired object diagnostic") + ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, operation .. " retires block action") + T.eq(why, "world facade is retired", "retired block diagnostic") + T.eq(#f.exports.events(), 0, "retired facade emits nothing") +end +do + local f = fixture() + f.exports.reenter(true) + T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted") + T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit") + T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly") + T.eq(f.rebuilds, 0, "nested edit never redraws") + T.eq(#f.exports.events(), 1, "outer edit emits once") + f.exports.reenter(false) + T.check(f.exports.replace(0, 0, 8), "guard released after successful edit") + local rebuild = f.ow.map.renderer.rebuild + f.ow.map.renderer.rebuild = function() + error("draw probe", 0) + end + local ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, "native redraw failure contained") + T.check(why:find("draw probe", 1, true), "native failure explained") + f.ow.map.renderer.rebuild = rebuild + T.check(f.exports.replace(0, 0, 8), "guard released after native exception") +end +-- Packed Gen3 source reads remain observational; replacement uses instance patches. +do + local f = fixture("emerald") + f.ow.map.def.blocks = string.rep(string.char(7, 0), 6) + f.ow.map.def._blockArray = nil + f.ow.map.collisionPatch[1] = 1 + f.ow.map.shutCells = { ["0:0"] = true } + T.check(f.exports.replace(0, 0, 8), "packed metatile map writable") + T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch") + T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched") + T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source") + T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override") + T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell") +end +-- Native controller methods; no actor construction or graphics are mocked. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.syncObjectVisibility, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.syncObjectVisibility, i, game) + return function() + debug.setupvalue(OW.syncObjectVisibility, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + setmetatable(f.ow, { __index = OW }) + f.ow.syncObjectVisibility = OW.syncObjectVisibility + f.ow.replaceBlock = OW.replaceBlock + local function actor(def, x) + return { + id = f.home .. "_obj_" .. def.index, + def = def, + cellX = x, + cellY = 1, + px = x * 16, + py = 16, + facing = "right", + } + end + local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2) + -- Distinct live actors share one persistent flag; only the named actor moves. + f.flagless.eventFlag = f.flagged.eventFlag + f.ow.npcs = { target, sibling } + f.ow.entities = { f.player, target, sibling } + f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling } + T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal") + T.eq(#f.ow.npcs, 1, "only target removed") + T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live") + T.eq(sibling.cellX, 2, "unrelated actor not relocated") + T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn") + T.eq(#f.ow.npcs, 2, "one actor restored") + T.eq(target.cellX, 3, "pooled position retained") + T.eq(target.facing, "right", "pooled facing retained") + f.flagless.eventFlag = nil + T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit") + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide") + f.game.save.g2ObjectToggles = nil + f.game.save.gen3SessionObjects = nil + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset") + T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path") + T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block") + T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained") + T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag") + restore() +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = fixture(version) + T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves") + T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID") +end +do + local f = fixture("gold") + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2ResetObjectBase() + local ctx = { game = f.game, save = f.game.save, overworld = f.ow } + Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first. + f.game.data.field = { objectScriptBase = 1 } + T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs") + T.eq(f.visible.NPC, false, "current native template receives visibility effect") + Commands.g2_object(ctx, 1, true) + T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged") + Commands.g2ResetObjectBase() +end +-- Existing native/API-2 facade retains its own coercion/validation behavior. +do + local f = fixture() + local api = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged") + T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 object and block edits") diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua new file mode 100644 index 000000000..2c6799081 --- /dev/null +++ b/tests/engine/battle_actions.lua @@ -0,0 +1,758 @@ +-- Public intents exercised through real Loader, BattleState and native input. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Merge = require("src.mods.Merge") +local Commands = require("src.battle.BattleCommands") +local Intents = require("src.mods.BattleIntents") +local oldVersion = GameVersion.get() + +local function boot(version, trainer, level) + GameVersion.set(version) + math.randomseed(12345) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(type(mod.battle.submit)=="function") + assert(mod.battle:snapshot()==nil) + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + assert(loader:load(data), table.concat(loader.errors, "; ")) + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + game.stack.top = function(stack) + return stack.states[#stack.states] + end + game.stack.push = function(stack, state) + stack.states[#stack.states + 1] = state + end + game.stack.pop = function(stack) + return table.remove(stack.states) + end + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase = "menu" + b.gen4CursorActive = true + b.frame = 0 + b.queue = {} + b.menuIndex = 1 + b.moveIndex = 1 + local rolls = 0 + b.rng = function(lo, hi) + rolls = rolls + 1 + return lo + end + game.stack.states = { b } + return loader.apis.probe.battle, + game, + b, + loader.apis.other.battle, + loader, + function() + return rolls + end +end +local function intent(api, id, kind, fields) + local s = assert(api:snapshot()) + local out = { id = id, revision = s.revision, kind = kind } + for k, v in pairs(fields or {}) do + out[k] = v + end + return out +end +local function send(api, id, kind, fields) + return api:submit(intent(api, id, kind, fields)) +end +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local row = { values = {}, mt = getmetatable(value) } + graph[value] = row + for key, item in pairs(value) do + row.values[key] = item + visit(item) + end + end + visit(root) + return function() + for tbl, row in pairs(graph) do + if getmetatable(tbl) ~= row.mt then + return false + end + for key, value in pairs(row.values) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if row.values[key] == nil then + return false + end + end + end + return true + end +end +local function forbidden() + error("unexpected native side effect") +end +local function reject(api, game, raw, fragment, label) + local battler = game.stack.states[1] + local oldRng = battler and battler.rng + if battler and battler.isBattleState then + battler.rng = forbidden + end + local unchanged = freeze(game) + local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call + math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden + local ok, reason = api:submit(raw) + math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall + T.eq(ok, nil, label .. ": rejected") + T.check( + type(reason) == "string" and reason:find(fragment, 1, true) ~= nil, + label .. ": reason " .. tostring(reason) + ) + T.check(unchanged(), label .. ": whole game graph unchanged") + if battler and battler.isBattleState then + battler.rng = oldRng + end +end +local function view(api) + local s = assert(api:snapshot()) + s.revision = nil + return s +end +local function press(game, b, key) + game.input.wasPressed = function(_, name) + return name == key + end + b:update(1 / 60) + game.input.wasPressed = function() + return false + end +end + +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. ": " + -- Identical native input and API commands, including actual turn planning. + local api, game, b, other, loader, rolls = boot(version) + local control, cgame, cb, _, _, crolls = boot(version) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "FIGHT matches native A dispatch") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "move matches native A dispatch") + T.eq(rolls(), crolls(), label .. "native RNG consumption matches") + T.eq(#b.queue, #cb.queue, label .. "native action queue matches") + T.eq(b.turnCount, 1, label .. "exactly one native turn planned") + local function drain(g, bb) + g.input.wasPressed = function() + return true + end + g.input.isDown = function() + return true + end + for frame = 1, 5000 do + if not bb:updateQueue() then + return true + end + end + return false + end + T.check(drain(game, b), label .. "API native turn queue completes") + T.check(drain(cgame, cb), label .. "input native turn queue completes") + T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue") + T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue") + T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue") + T.eq(rolls(), crolls(), label .. "full native turn RNG identical") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability") + end + -- Menu opening does not bypass the party/bag controller or consume items. + for _, choice in ipairs({ "party", "item", "run" }) do + local a, g, bb = boot(version) + local c, cg, cc = boot(version) + T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted") + local indices = cc:menuActions() + for i, value in ipairs(indices) do + if value == choice or (choice == "party" and value == "pkmn") then + cc.menuIndex = i + end + end + press(cg, cc, "a") + -- Native cursor is selected by the player; the public API addresses the + -- action by name, so it need not move that display cursor. + local sv, cv = view(a), view(c) + sv.menuIndex = nil + cv.menuIndex = nil + T.same(sv, cv, label .. choice .. " matches native input") + T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue") + end + api, game, b = boot(version) + local first = assert(api:snapshot()) + local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) } + for _, raw in ipairs(bads) do + reject(api, game, raw, "", label .. "malformed intent") + end + for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do + reject( + api, + game, + { id = value, revision = first.revision, kind = "menu", choice = "fight" }, + "integer", + label .. "unsafe ID" + ) + reject( + api, + game, + { id = 1, revision = value, kind = "menu", choice = "fight" }, + "revision", + label .. "unsafe revision" + ) + end + local malformed = { + { kind = "mystery" }, + { kind = "menu", choice = "catch" }, + { kind = "menu", choice = {} }, + { kind = "move", slot = "1" }, + { kind = "move", slot = 0 }, + { kind = "move", slot = 5 }, + { kind = "target", target = -1 }, + { kind = "target", target = 4 }, + { kind = "mimic", index = 0 }, + { kind = "mimic", index = 5 }, + { kind = "safari", action = "mud" }, + { kind = "safari", action = false }, + { kind = "back", extra = true }, + { kind = "move", slot = 1, idOverride = 3 }, + } + for _, fields in ipairs(malformed) do + local raw = { id = 1, revision = first.revision } + for k, v in pairs(fields) do + raw[k] = v + end + reject(api, game, raw, "", label .. "strict intent fields") + end + local current = intent(api, 1, "menu", { choice = "fight" }) + T.eq(api:submit(current), true, label .. "rejected IDs were not consumed") + reject(api, game, current, "replayed", label .. "accepted ID cannot replay") + current.id = 0 + reject(api, game, current, "integer", label .. "ID zero is invalid") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon.hp = b.player.mon.hp - 1 + reject(api, game, current, "stale", label .. "changed HP invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1]) + reject(api, game, current, "stale", label .. "identical replacement move invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon = Merge.deepCopy(b.player.mon) + reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + game.stack.states[2] = { battle = b } + reject(api, game, current, "stale", label .. "new covering screen invalidates revision") + reject( + api, + game, + intent(api, 3, "move", { slot = 1 }), + "covered", + label .. "fresh covered snapshot still cannot act" + ) + game.stack.states[2] = nil + for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do + b.phase = phase + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "active", + label .. "wrong phase " .. phase + ) + end + b.phase = "menu" + for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do + b[flag] = true + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "controller", + label .. "automatic controller " .. flag + ) + b[flag] = nil + end + for _, flag in ipairs({ "result", "dead", "waitingUI" }) do + b[flag] = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag) + b[flag] = nil + end + b.kind = "link" + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused") + b.kind = "wild" + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "locked", + label .. flag .. " automatic lock" + ) + b.player[flag] = nil + end + b.player.ally = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused") + b.player.ally = nil + b.player.mon.hp = 0 + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused") + b.player.mon.hp = 30 + b.phase = "moveSelect" + for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + local power = def.power + if guard == "pp" then + mv.pp = 0 + end + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + def.power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = mv.id + end + if guard == "swap" then + b.moveSwapIndex = 1 + end + reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action") + mv.pp = 20 + b.player.disabledSlot = nil + b.player.tauntTurns = nil + b.player.tormented = nil + b.player.lastMove = nil + b.moveSwapIndex = nil + def.power = power + end + reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused") + b.moveSwapIndex = 1 + T.eq(send(api, 3, "back"), true, label .. "back cancels reorder") + T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared") + T.eq(b.phase, "menu", label .. "back to menu") + reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action") + game.stack.states = {} + reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused") +end +local function doubles(api, game, b) + math.randomseed(54321) + b.double = true + b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save)) + local foe = Pokemon.new(game.data, "FIXMON_B", 10) + b.enemyParty = b.enemyParty or { b.enemy.mon } + b.enemyParty[2] = foe + b:placeBattler(3, BattleState.makeBattler(game.data, foe, false)) + b:syncSides() +end +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. " doubles: " + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + doubles(api, game, b) + doubles(control, cgame, cb) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT") + press(cgame, cb, "a") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets") + press(cgame, cb, "a") + T.eq(b.phase, "targetSelect", label .. "native targeting phase") + reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot") + local offered = intent(api, 3, "target", { target = 3 }) + b.targetChoices = { unpack(b.targetChoices) } + reject(api, game, offered, "stale", label .. "selector identity changed") + T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected") + cb.targetIndex = 2 + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "position maps to correct native cursor") + T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe") + T.eq(b.choosingSlot, 2, label .. "second chooser") + T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu") + press(cgame, cb, "b") + T.same(view(api), view(control), label .. "back matches native B") + T.eq(b.pendingActions[0], nil, label .. "pending first action cleared") + T.eq(b.chosenTargets[0], nil, label .. "pending target cleared") + -- Native B from target returns to moves, then from moves to action menu. + T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again") + T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again") + T.eq(send(api, 7, "back"), true, label .. "cancel target") + T.eq(b.pendingMove, nil, label .. "pending move cleared") + T.eq(b.targetChoices, nil, label .. "target selector cleared") + T.eq(b.phase, "moveSelect", label .. "back to moves") + T.eq(send(api, 8, "back"), true, label .. "back to menu") + T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action") + T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move") + local stale = intent(api, 11, "target", { target = 1 }) + b.pendingMove = Merge.deepCopy(b.pendingMove) + reject(api, game, stale, "stale", label .. "pending move identity changed") + reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move") + b.pendingMove = b.player.curMoves[1] + b.pendingMove.pp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP") + b.pendingMove.pp = 20 + b:battlerAt(1).mon.hp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused") + b:battlerAt(1).mon.hp = 30 + T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target") + T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT") + T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move") + T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported") + T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner") + T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn") + -- A surviving right flank still owns a usable menu. + api, game, b = boot(version) + doubles(api, game, b) + b:placeBattler(0, nil) + T.eq(api:snapshot().active, 2, label .. "missing-left chooser") + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT") + T.eq(b.phase, "moveSelect", label .. "right-only moves") + -- Mimic belongs to its queued user, even when that is the right flank. + b.phase = "mimicSelect" + local user = b:battlerAt(2) + local target = b.enemy + b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] } + b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } } + b.mimicIndex = 1 + T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user") + local picked = intent(api, 2, "mimic", { index = 1 }) + b.mimicCtx.target = b:battlerAt(3) + reject(api, game, picked, "stale", label .. "Mimic source identity changed") + b.mimicCtx.target = target + T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy") + T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source") + T.eq(b.phase, "messages", label .. "Mimic resumes queue") + T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved") +end + +-- Safari uses its native model, including Platinum's own action implementation. +for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do + for _, action in ipairs({ "ball", "bait", "rock", "run" }) do + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + for _, battle in ipairs({ b, cb }) do + battle.safari = { balls = 5 } + battle.safariCatchRate = 80 + battle.baitFactor = 0 + battle.escapeFactor = 0 + if version == "platinum" then + battle.gen4Safari = require("src.battle.Gen4Safari").init() + end + end + T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action) + cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action] + press(cgame, cb, "a") + T.same(view(api), view(control), version .. ": Safari matches native " .. action) + T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw") + local old = intent(api, 2, "safari", { action = "ball" }) + reject(api, game, old, "", version .. ": cannot repeat Safari action during messages") + b.phase = "menu" + b.result = nil + b.safari.balls = 0 + reject( + api, + game, + intent(api, 2, "safari", { action = "run" }), + "balls", + version .. ": depleted Safari belongs to automatic exit" + ) + b.safari.balls = 1 + reject( + api, + game, + intent(api, 2, "menu", { choice = "fight" }), + "safari", + version .. ": regular menu cannot bypass Safari" + ) + end +end + +-- BW eligibility goes through the native read-only rules, not the snapshot's +-- deliberately partial disabled flag. +for _, version in ipairs({ "black", "white" }) do + for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do + local api, game, b = boot(version) + b.phase = "moveSelect" + b.bw = { field = {}, sides = { {}, {} } } + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + if guard == "choice" then + game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 } + b.player.items = game.data.items + b.player.mon.item = "CHOICE" + b.player.bwChoiceLock = "FIX_CUT" + b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 } + elseif guard == "imprison" then + b.enemy.bwImprison = true + b.enemy.curMoves = { { id = mv.id, pp = 2 } } + elseif guard == "gravity" then + b.bw.field.gravity = 3 + def.gravityBanned = true + elseif guard == "heal" then + b.player.bwHealBlock = 3 + def.healMove = true + else + b.player.bwSkyDropped = b.enemy + end + reject( + api, + game, + intent(api, 1, "move", { slot = 1 }), + "", + version .. ": native BW restriction " .. guard + ) + end +end + +-- Cross-facade atomicity, consumption and recovery are deliberately tested +-- with native boundary overrides; the real-input cases above verify gameplay. +do + local api, game, b, other = boot("crystal") + reject( + api, + game, + { id = 1, revision = 1, kind = "menu", choice = "fight" }, + "snapshot", + "unobserved context" + ) + local native = b.chooseMenu + local executions = 0 + local nested, mid + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.chooseMenu = function(self, choice) + executions = executions + 1 + nested = { other:submit(otherRequest) } + mid = other:snapshot() + return native(self, choice) + end + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted") + T.eq(nested[1], nil, "reentrant other mod rejected") + T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason") + T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "mid-dispatch revision cannot act after return" + ) + T.eq(executions, 1, "one native dispatch") + T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence") + b.chooseMenu = function() + executions = executions + 1 + return true + end + local held = intent(other, 2, "menu", { choice = "fight" }) + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command") + reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.chooseMenu = function() + executions = executions + 1 + b.phase = "messages" + error("native boom") + end + local accepted, err = api:submit(raw) + T.eq(accepted, nil, "native exception reported") + T.check( + err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil, + "native error explains consumed ID" + ) + reject(api, game, raw, "replayed", "partial failure cannot replay") + T.eq(Intents.state(game).busy, false, "error clears shared busy flag") + b.phase = "menu" + b.chooseMenu = native + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure") +end + +do + local api, game, b, other, loader = boot("red") + local original = b.menuBattler + local request = intent(api, 1, "menu", { choice = "fight" }) + local nested + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.menuBattler = function(self) + nested = { other:submit(otherRequest) } + return original(self) + end + T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds") + T.eq(nested[1], nil, "validation reentry rejected") + T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable") + b.menuBattler = function(self) + game.stack.states[2] = {} + return original(self) + end + local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" })) + T.eq(ok, nil, "preflight context change refused") + T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason") + -- The intentionally overridden validator above changed the stack itself; + -- test the refusal separately from the normal no-side-effects contract. + game.stack.states[2] = nil + b.menuBattler = original + T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.menuBattler = function(self) + raw.choice = "run" + raw.id = 999 + return original(self) + end + T.eq(api:submit(raw), true, "intent copied before native validation") + T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice") + b.menuBattler = original + local _, newGame, newBattle = boot("blue") + loader.game = newGame + reject( + api, + newGame, + { id = 3, revision = raw.revision, kind = "back" }, + "replayed", + "consumption survives Game replacement" + ) + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game") + T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game") +end + +-- Accepted calls can enter normal refusal/forced-action paths too. They do +-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT. +for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do + local api, game, b = boot("crystal", mode == "trainerRun") + local control, cgame, cb = boot("crystal", mode == "trainerRun") + for _, bb in ipairs({ b, cb }) do + if mode == "struggle" then + for _, mv in ipairs(bb.player.curMoves) do + mv.pp = 0 + end + end + if mode == "trapping" then + bb.player.trappingTurns = 2 + end + if mode == "ghost" then + bb.ghost = true + end + end + local choice = mode == "trainerRun" and "run" or "fight" + T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted") + cb.menuIndex = mode == "trainerRun" and 4 or 1 + press(cgame, cb, "a") + local actual, expected = view(api), view(control) + actual.menuIndex = nil + expected.menuIndex = nil + T.same(actual, expected, mode .. ": preserves native branch") + T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved") +end + +do + local api, game, b = boot("red") + local raw = intent(api, 1, "menu", { choice = "fight" }) + local rev = raw.revision + raw.extra = "typo" + reject(api, game, raw, "field", "invalid field is observational") + T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision") + raw.extra = nil + local native = b.chooseMenu + b.chooseMenu = function() + return nil, "native declined" + end + local ok, err = api:submit(raw) + T.eq(ok, nil, "native refusal returned") + T.eq(err, "native declined", "native refusal reason retained") + reject(api, game, raw, "replayed", "native invocation consumes even when it declines") + b.chooseMenu = native + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock") + local _, otherGame = boot("blue") + game.stack.states[1] = otherGame.stack.states[1] + reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled") +end +do + local api, game, b = boot("red") + local owner = Runtime.currentMod + Runtime.currentMod = "probe" + b.chooseMenu = function() + T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution") + error("scope failure") + end + local ok = send(api, 1, "menu", { choice = "fight" }) + T.eq(ok, nil, "scoped native error returned") + T.eq(Runtime.currentMod, "probe", "native error restores caller attribution") + Runtime.currentMod = owner +end +do + local api, game, b, other, loader = boot("emerald") + local callbackCount, nested, mid = 0, nil, nil + local held = intent(other, 1, "menu", { choice = "fight" }) + loader.apis.probe.events:on("battle.turn_started", function() + callbackCount = callbackCount + 1 + T.eq(Runtime.currentMod, nil, "real event uses native engine scope") + mid = other:snapshot() + nested = { other:submit(held) } + end) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT") + T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn") + T.eq(callbackCount, 1, "one native turn-start callback") + T.eq(nested[1], nil, "real event cannot recursively submit") + T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "real event snapshot expires after dispatch" + ) + T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope") +end +local api = boot("red", false, 1) +T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions") +GameVersion.set(oldVersion) +T.finish("battle actions") diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua new file mode 100644 index 000000000..acf55273d --- /dev/null +++ b/tests/engine/battle_snapshots.lua @@ -0,0 +1,613 @@ +-- ROM-free contract tests through the real mod loader and BattleState. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local oldVersion, oldLove = GameVersion.get(), love +love = nil + +-- Exercise the real loader resolver, including Lua 5.1's in-progress marker. +local cachedGame = package.loaded["src.core.Game"] +package.loaded["src.core.Game"] = nil +T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game") +T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched") +package.loaded["src.core.Game"] = true +T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable") +local injected = {} +T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence") +package.loaded["src.core.Game"] = injected +T.eq(Loader._game({}, true), injected, "live cached Game is available") +package.loaded["src.core.Game"] = cachedGame + +local function boot(version, level) + GameVersion.set(version) + local ds = GameVersion.generation() >= 4 + local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL" + local species, move = + { name = "Pikachu", types = { "ELECTRIC" } }, + { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { name = "Poké Ball", ball = "POKE_BALL" }, + Z_EXTRA = { name = "Extra" }, + A_EXTRA = { name = "Earlier" }, + }, + } + if ds then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local function mon(nick) + return { + species = s, + nickname = nick, + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 }, + moves = { { id = m, pp = 29, ppUps = 1 } }, + isEgg = false, + extension = { flag = false }, + } + end + local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe") + if GameVersion.generation() > 1 then + for _, raw in ipairs({ a, b, e, f }) do + raw.stats.spatk, raw.stats.spdef = 14, 15 + end + end + local party = { a, b } + local function battler(raw) + return { + mon = raw, + name = raw.nickname, + species = raw.species, + curStats = raw.stats, + curTypes = { "ELECTRIC" }, + curMoves = raw.moves, + stages = {}, + } + end + local game = { + data = data, + save = { + party = party, + inventory = { + [item] = 3, + Z_EXTRA = 2, + A_EXTRA = 1, + ZERO = 0, + }, + bagOrder = { item, item, "ZERO" }, + }, + stack = { states = {} }, + } + local btl = setmetatable({ + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(a), + enemy = battler(e), + enemyParty = { e, f }, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + }, BattleState) + btl:placeBattler(0, btl.player) + btl:placeBattler(1, btl.enemy) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = level or 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [[return function(mod) + assert(type(mod.battle.snapshot)=="function") + mod.exports.api=mod + mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10, + accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"}) + end]], + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" } + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function() + return resolves + end +end + +-- Freeze the whole graph as table identities + raw entries, including cycles. +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local entries = {} + graph[value] = entries + for key, child in pairs(value) do + entries[key] = child + visit(child) + end + end + visit(root) + return function() + for tbl, entries in pairs(graph) do + for key, value in pairs(entries) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if entries[key] == nil then + return false + end + end + end + return true + end +end + +local function forbidden() + error("snapshot crossed into gameplay") +end +local oldRandom = math.random +math.random = forbidden +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, b, battler, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") + if api.supports then + T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") + T.eq(api:supports("battle", "submit"), true, label .. "additive input submission") + T.eq(resolves(), 0, label .. "probe does not resolve Game") + end + T.eq(api.battle, api.battle, label .. "stable facade") + T.eq(type(api.battle.submit), "function", label .. "additive submit method") + local no, reason = api.battle:snapshot() + T.eq(no, nil, label .. "no battle") + T.eq(reason, "no battle", label .. "no battle reason") + game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } } + T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle") + game.stack.states = { b } + b.rng = forbidden + b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP = + forbidden, forbidden, forbidden, forbidden + b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden + b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden + local unchanged = freeze(game) + local snap = api.battle:snapshot() + T.check(unchanged(), label .. "complete game graph unchanged") + T.eq(snap.revision, 1, label .. "first revision") + T.eq(snap.prompt, "menu", label .. "menu phase") + T.eq(snap.double, false, label .. "single battle") + T.eq(snap.active, 0, label .. "left actor") + T.eq(snap.kind, "wild", label .. "wild kind") + T.eq(snap.turn, 0, label .. "before first turn") + T.eq(snap.catchable, true, label .. "single wild target") + T.eq(#snap.battlers, 2, label .. "both occupied slots") + T.eq(snap.player.species, "PIKACHU", label .. "portable species") + T.eq(snap.player.name, "Left", label .. "battler display name") + T.eq(snap.player.hp, 19, label .. "logical HP") + T.eq(snap.player.maxHp, 23, label .. "max HP") + T.eq(snap.player.status, "poison", label .. "portable status") + T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack") + T.eq( + snap.player.stats.specialDefense, + GameVersion.generation() == 1 and 12 or 15, + label .. "special defense" + ) + T.eq(snap.player.partyIndex, 1, label .. "party slot by identity") + T.eq(snap.party[1].active, true, label .. "lead active") + T.eq(snap.party[2].active, false, label .. "bench inactive") + T.eq(snap.party[2].slot, 2, label .. "party slot one based") + T.eq(snap.moves[1].id, "TACKLE", label .. "portable move") + T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum") + T.eq(snap.moves[1].pp, 29, label .. "current PP") + T.eq(snap.moves[1].disabled, false, label .. "unrestricted move") + T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy") + T.eq(#snap.items, 3, label .. "positive inventory deduplicated") + T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order") + T.eq(snap.items[1].count, 3, label .. "inventory quantity") + T.eq(snap.items[1].ball, true, label .. "ball classification") + T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted") + T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order") + T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview") + T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility") + T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable") + snap.player.hp = 0 + snap.player.native.hp = 0 + snap.player.native.extension.flag = true + snap.moves[1].pp = 0 + snap.items[1].count = 0 + snap.party[1].stats.hp = 0 + snap.player.types[1] = "WATER" + snap.player.stages.attack = 6 + local clean = api.battle:snapshot() + T.eq(clean.revision, 1, label .. "caller writes do not alter revision") + T.eq(clean.player.hp, 19, label .. "caller writes isolated") + T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics") + T.eq(clean.moves[1].pp, 29, label .. "move tables isolated") + T.eq(clean.items[1].count, 3, label .. "item tables isolated") + T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated") + T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated") + T.eq(clean.player.stages.attack, nil, label .. "stages isolated") + + b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 } + T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded") + b.player.mon.hp = 18 + local changed = api.battle:snapshot() + T.eq(changed.revision, 2, label .. "HP changes revision") + T.eq(changed.player.hp, 18, label .. "new logical HP visible") + T.eq(clean.player.hp, 19, label .. "old result remains detached") + b.turnCount = 1 + T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates") + local rev = api.battle:snapshot().revision + game.stack.states[#game.stack.states + 1] = { battle = b } + T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked") + T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision") + table.remove(game.stack.states) + local cases = { + { "intro", "locked" }, + { "messages", "locked" }, + { "moveSelect", "moves" }, + { "targetSelect", "target" }, + { "mimicSelect", "mimic" }, + { "finished", "locked" }, + } + for _, c in ipairs(cases) do + b.phase = c[1] + T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1]) + end + b.phase = "messages" + b.current = { text = "Current text" } + b.msgPrompt = true + b.msgPromptWait = 3 + T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay") + b.msgPromptWait = 0 + T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance") + T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source") + T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message") + game.data.font = + { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } } + b.shown = { { 128, 129 }, { 130 } } + local text = api.battle:snapshot() + T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message") + b.shown[2][2] = 128 + T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision") + T.same(text.message, { "AB", "C" }, label .. "older visible message detached") + b.shown[1][1] = 999999 + T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed") + game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } } + T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read") + local unicode = package.loaded["src.translation.UnicodeFont"] + package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } } + b.shown = { { -1 } } + T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read") + package.loaded["src.translation.UnicodeFont"] = unicode + game.data.font = { source = "Gen2 scaffold" } + b.shown = { { 65, 66 } } + T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read") + game.stack.states[2] = { battle = b } + T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message") + game.stack.states[2] = nil + b.shown = nil + b.current.choice = function() end + T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller") + b.current = nil + b.msgPrompt = nil + b.phase = "menu" + for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do + b[flag] = true + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b[flag] = nil + end + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b.player[flag] = nil + end + b.bw = true + b.player.bwSkyDropped = true + T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock") + b.bw = nil + b.player.bwSkyDropped = nil + b.safari = { balls = 3 } + T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind") + T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count") + T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt") + b.safari.balls = 0 + T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls") + b.safari = nil + b.demo = true + T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind") + b.demo = nil + b.ghost = true + T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind") + b.ghost = nil + b.menuIndex = 2 + b.moveIndex = 3 + b.moveSwapIndex = 1 + local cursors = api.battle:snapshot() + T.eq(cursors.menuIndex, 2, label .. "menu cursor") + T.eq(cursors.moveIndex, 3, label .. "move cursor") + T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor") + b.menuIndex = 1 + T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision") + for _, flag in ipairs({ "ghost", "noCatch", "result" }) do + b[flag] = true + T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable") + b[flag] = nil + end + b.kind = "trainer" + T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable") + b.kind = "wild" + b.phase = "moveSelect" + for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "pp" then + b.player.curMoves[1].pp = 0 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + game.data.moves[ids[2]].power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = ids[2] + end + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction") + b.player.disabledSlot = nil + b.player.curMoves[1].pp = 29 + b.player.tauntTurns = nil + game.data.moves[ids[2]].power = 40 + b.player.tormented = nil + b.player.lastMove = nil + end + -- Native alias keys may be split during merging; a name patch must not + -- rename a portable ID. Later registrations still appear in fresh reads. + game.data.pokemon[ids[1]].name = "Renamed presentation" + T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity") + local originalMoves = b.player.curMoves + b.player.curMoves = { { id = "MOVE_9000", pp = 8 } } + T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved") + b.player.curMoves = originalMoves + b.bw = { field = {}, sides = { {}, {} } } + T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown") + b.player.disabledSlot = 1 + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction") + b.player.disabledSlot = nil + b.bw = nil + b.player.curMoves[1].ppUps = 99 + T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine") + b.player.curMoves[1].ppUps = -1 + T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped") + b.player.curMoves[1].ppUps = 1 + -- Transform changes the in-battle view while the stored party keeps its moves/stats. + game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" } + b.player.curMoves = { { id = "COPIED", pp = 5 } } + b.player.curStats = { hp = 23, attack = 90, special = 80 } + b.player.species = "COPIED_SPECIES" + b.player.curTypes = { "WATER" } + local transformed = api.battle:snapshot() + T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move") + T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move") + T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats") + T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved") + T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate") + T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved") + -- All four native positional slots, including a missing left flank. + b.double = true + b:placeBattler(2, battler(game.save.party[2])) + b:placeBattler(3, battler(b.enemyParty[2])) + b.choosingSlot = 2 + b.phase = "menu" + local double = api.battle:snapshot() + T.eq(double.double, true, label .. "doubles flag") + T.eq(#double.battlers, 4, label .. "four occupied battlers") + T.eq(double.active, 2, label .. "second chooser") + T.eq(double.player.name, "Right", label .. "player follows chooser") + T.eq(double.player.partyIndex, 2, label .. "second chooser party index") + T.eq(double.catchable, false, label .. "cannot aim at two wild foes") + T.eq(double.party[2].active, true, label .. "both player slots active") + b.phase = "targetSelect" + b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player } + b.targetIndex = 2 + T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions") + T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor") + b:placeBattler(0, nil) + b:placeBattler(1, nil) + b.choosingSlot = nil + b.phase = "menu" + local missing = api.battle:snapshot() + T.eq(missing.active, 2, label .. "right flank after left faint") + T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased") + T.eq(missing.catchable, true, label .. "one wild foe remaining") + T.eq(#missing.battlers, 2, label .. "missing slots omitted") + T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots") + b.phase = "mimicSelect" + b.mimicMoves = { { id = ids[2], slot = 3 } } + b.mimicIndex = 1 + local mimic = api.battle:snapshot() + T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID") + T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot") + b.phase = "menu" + local ally = b.sides[1].battlers[2] + ally.ally = true + b.ally = { party = { ally.mon } } + local withAlly = api.battle:snapshot() + T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party") + T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked") + T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked") + ally.ally = nil + b.ally = nil + b.kind = "link" + b.playerParty = { game.save.party[2] } + local link = api.battle:snapshot() + T.eq(#link.party, 1, label .. "link uses clamped battle party") + T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant") + T.eq(link.prompt, "locked", label .. "link controller owns input") + b.kind = "wild" + b.playerParty = nil + -- Snapshot diagnostics discard executable values and remain cycle-safe. + local raw = game.save.party[2] + raw.extension.self = raw.extension + raw.extension.fn = forbidden + raw.extension[{}] = "hidden" + setmetatable(raw.extension, { __index = { secret = true } }) + local cyc = api.battle:snapshot() + T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied") + T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped") + T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped") + T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value") + T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision") + local last = cyc.revision + game.stack.states = {} + T.eq(api.battle:snapshot(), nil, label .. "battle exit") + game.stack.states = { b } + T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision") + last = api.battle:snapshot().revision + local replacement = setmetatable(Merge.deepCopy(b), BattleState) + game.stack.states = { replacement } + T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision") + -- A previously captured facade follows the loader's current Game. + local other = { data = game.data, save = game.save, stack = game.stack } + loader._game = function() + return other + end + last = api.battle:snapshot().revision + loader._game = function() + return game + end + T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision") +end +math.random = oldRandom + +-- Exercise constructors and a native damage action with the committed fixture +-- dataset, rather than asserting only hand-built record shapes. +love = require("tests.love_stub") +local Fixtures = require("tests.modkit.fixtures") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local function native(version, trainer) + local api, loader = boot(version) + love = require("tests.love_stub") + math.randomseed(12345) + local data = Fixtures.fresh() + Font.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + local game = { + data = data, + save = save, + input = { + wasPressed = function() + return false + end, + }, + stack = { + states = {}, + push = function() end, + pop = function() end, + top = function(self) + return self.states[#self.states] + end, + }, + } + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + game.stack.states = { b } + loader._game = function() + return game + end + local calls = 0 + b.rng = function(_, high) + calls = calls + 1 + return high + end + return api, game, b, function() + return calls + end +end +for _, version in ipairs(GameVersion.ORDER) do + for _, trainer in ipairs({ false, true }) do + local api, game, b, calls = native(version, trainer) + local unchanged = freeze(game) + local rngBefore = calls() + local oldLoveRandom, oldLuaRandom = love.math.random, math.random + love.math.random, math.random = forbidden, forbidden + local Runtime = require("src.mods.Runtime") + local oldEmit, oldCall = Runtime.emit, Runtime.call + Runtime.emit, Runtime.call = forbidden, forbidden + local loaded = {} + for name in pairs(package.loaded) do + loaded[name] = true + end + local s = api.battle:snapshot() + for _ = 1, 10 do + T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable") + end + local noNewModules = true + for name in pairs(package.loaded) do + if not loaded[name] then + noNewModules = false + end + end + T.check(noNewModules, version .. ": snapshot loads no modules") + Runtime.emit, Runtime.call = oldEmit, oldCall + T.check(unchanged(), version .. ": native constructor graph unchanged") + T.eq(calls(), rngBefore, version .. ": native RNG unchanged") + T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind") + T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record") + T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP") + T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves") + T.eq(s.catchable, not trainer, version .. ": constructor catchability") + love.math.random, math.random = oldLoveRandom, oldLuaRandom + -- The same native action on an independently constructed control battle + -- must produce the same HP, PP, queue and RNG consumption. + local _, _, control, controlCalls = native(version, trainer) + b:performMove(b.player, b.enemy, b.player.curMoves[1]) + control:performMove(control.player, control.enemy, control.player.curMoves[1]) + T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected") + T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected") + T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected") + T.eq(#b.queue, #control.queue, version .. ": action queue unaffected") + local later = api.battle:snapshot() + T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage") + T.check(later.revision > s.revision, version .. ": native damage changes revision") + end +end +local api = boot("crystal", 1) +T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader") +GameVersion.set(oldVersion) +love = oldLove +T.finish("battle snapshots") diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua new file mode 100644 index 000000000..583ba2234 --- /dev/null +++ b/tests/engine/compat_module_aliases.lua @@ -0,0 +1,39 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local C=require('src.core.ModCompat') +C.install() +T.eq(require('src.world.gen2.WorldAPI'),require('src.world.WorldAPI'),'world API alias preserves object identity') +if loadfile('src/world/OptionalFollower.lua') then + T.eq(require('src.world.gen2.Follower'),require('src.world.OptionalFollower'),'Gen2 follower alias preserves object identity') +else + T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') +end +local names=require('src.mods.Gen1Compat') +for name in pairs(names.UNAVAILABLE)do + if names.hasProvider(name)then + local ok,module=pcall(require,name) + T.check(ok,'concrete host provider loads '..name) + T.eq(type(module),'table','concrete host provider remains a module '..name) + T.eq(names.unavailable(name),nil,'existing provider is not marked unavailable '..name) + T.eq(require(name),module,'existing provider retains require identity '..name) + T.check(names.hasProvider(name),'provider remains available '..name) + else + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + local again,detail=pcall(require,name) + T.check(not again and reason==detail,'repeat import retains the exact diagnostic '..name) + end +end + +local providers=package.loaders or package.searchers +local provided={marker='native-late-provider'} +local late=function(name)if name=='src.import.pmd.Sprite'then return function()return provided end end end +providers[#providers+1]=late +T.eq(require('src.import.pmd.Sprite'),provided,'late concrete provider outranks the missing-module descriptor') +T.check(names.hasProvider('src.import.pmd.Sprite'),'loaded provider is recognized') +T.eq(names.unavailable('src.import.pmd.Sprite'),nil,'loaded provider has no missing-module diagnostic') +table.remove(providers);package.loaded['src.import.pmd.Sprite']=nil +T.finish() diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua new file mode 100644 index 000000000..4a1fac628 --- /dev/null +++ b/tests/engine/decoded_script_profile.lua @@ -0,0 +1,427 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Hooks = require("src.mods.Hooks") +local Events = require("src.mods.Events") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local Decoded = require("src.script.DecodedScript") +local C2 = require("src.script.Gen2ScriptVM") +local C3 = require("src.script.Gen3ScriptVM") +local function fixture(gen, scripts) + Version.set(gen == 2 and "gold" or "firered") + local data = require("tests.fixtures.upstream_mods.data").fresh() + data.map_scripts = { source = gen == 3 and "RomExtractorGen3" or "RomExtractorGen2", scripts = scripts, maps = {} } + local game = { data = data, save = { flags = {}, party = {}, gen3Vars = {} } } + local world = { map = { id = "NATIVE" } } + local runner = Runner.new(game, world) + local hooks, decoded = Hooks.new(), Hooks.new() + hooks._decodedHooks = decoded + Runtime.install(Events.new(), hooks, {}) + return game, runner, (gen == 2 and C2 or C3).compile(data, "ENTRY"), hooks, decoded +end +for _, gen in ipairs({ 2, 3 }) do + local set, finish = gen == 2 and "setval" or "setvar", gen == 2 and "endall" or "end" + local row = gen == 2 and { set, 4 } or { set, 0x4002, 4 } + local game, runner, rows, native, decoded = fixture(gen, { ENTRY = { row, { finish } } }) + local calls, order, expected = 0, {}, 4 + native:wrap("script.command", function(next, ctx, op, args) + order[#order + 1] = op + T.eq(ctx.script, rows, "native callback retains the original compiled script identity") + return next(ctx, op, args) + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 0, "native-only runs never dispatch decoded callbacks") + local baseline = table.concat(order, ",") + order = {} + decoded:wrap("script.command", function(next, ctx, op, ir) + calls = calls + 1 + local result = next(ctx, op, ir) + if op == set then + T.eq( + gen == 2 and ctx.g2Var or ctx.save.gen3Vars[0x4002], + expected, + "post-next observes the native command effects" + ) + end + return result + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 2, "one decoded callback per source command") + T.eq(table.concat(order, ","), baseline, "native command order is unchanged with a transparent profile") + local prepared = Decoded.prepare(rows) + T.same(prepared, rows, "transparent compiler trace preserves every native row and argument") + native:removeOwner(nil) -- no changes: test-owned native callback remains installed + local replacement = Hooks.new() + replacement._decodedHooks = decoded + Runtime.install(Events.new(), replacement, {}) + decoded:wrap("script.command", function(next, ctx, op, ir) + if op == set then + local copy = {} + for k, v in pairs(ir) do + copy[k] = v + end + copy[gen == 2 and 2 or 3] = 151 + return next(ctx, op, copy) + end + return next(ctx, op, ir) + end, 100) + expected = 151 + local editedContext = runner:makeContext() + runner:exec(rows, editedContext) + T.eq( + gen == 2 and editedContext.g2Var or game.save.gen3Vars[0x4002], + 151, + "edited decoded operands reach native execution" + ) + T.eq(row[gen == 2 and 2 or 3], 4, "hook rewrites never mutate imported source rows") +end +-- Calls lower to several rows; a wrapper surrounds the complete command once. +local game, runner, rows, native, decoded = fixture(3, { + ENTRY = { { "call", "SUB" }, { "setvar", 0x4010, 7 }, { "end" } }, + SUB = { { "setvar", 0x4011, 8 }, { "return" } }, +}) +local seen = {} +decoded:wrap("script.command", function(next, ctx, op, ir) + seen[#seen + 1] = op + if op == "setvar" and ir[2] == 0x4011 then + return next(ctx, "addvar", { "addvar", 0x4011, 9 }) + end + return next(ctx, op, ir) +end) +runner:exec(rows, runner:makeContext()) +T.same( + seen, + { "call", "setvar", "return", "setvar", "end" }, + "multi-row call, edited opcode and return keep decoded order" +) +T.eq(game.save.gen3Vars[0x4011], 9, "opcode replacements execute their own lowering") +T.eq(game.save.gen3Vars[0x4010], 7, "recompilation retains active call return addresses") +-- Zero-row loadword supplies a later std. A veto skips the entire grouped command. +local game2, run2, rows2, _, dec2 = + fixture(3, { ENTRY = { + { "nop" }, + { "loadword", 0, "TEXT_A" }, + { "setvar", 0x4020, 3 }, + { "end" }, + } }) +local count = 0 +dec2:wrap("script.command", function(next, ctx, op, ir) + count = count + 1 + if op == "setvar" then + return + end + if op == "loadword" then + ir[3] = "TEXT_B" + end + return next(ctx, op, ir) +end) +run2:exec(rows2, run2:makeContext()) +T.eq(count, 4, "zero-row operations are visible exactly once") +T.eq(game2.save.gen3Vars[0x4020], nil, "veto skips native effects") +-- The actual Loader routes only the explicitly selected mod onto the decoded bus. +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Loader = require("src.mods.Loader") +Version.set("firered") +local fs = SDK.memfs({ + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row) + if op=='setvar' then row.value=99 end + return next(ctx,op,row) + end) + mod.events:on('pokemon.before_give',function(gift) + mod.exports.map=gift.ctx.overworld.map.id + gift.level=20;gift.nickname='PROFILE' + end) + end]], +}) +local data = require("tests.fixtures.upstream_mods.data").fresh() +data.maps.MAP_G04_N03 = { id = "MAP_G04_N03", group = 4, number = 3 } +local loader = Loader.new({ fs = fs, dev = false }) +T.eq(loader:load(data), true, "actual Loader accepts an external per-mod profile") +T.eq(loader.hooks.chains["script.command"], nil, "selected hook does not alter the native chain") +T.check(loader._decodedHooks.chains["script.command"] ~= nil, "selected hook registers on its private bus") +data.map_scripts = + { source = "RomExtractorGen3", scripts = { ENTRY = { { "setvar", 0x4002, 4 }, { "end" } } }, maps = {} } +local g = { data = data, save = { flags = {}, party = {}, gen3Vars = {} }, mods = loader } +local world = { map = { id = "MAP_G04_N03", def = data.maps.MAP_G04_N03 } } +local r = Runner.new(g, world) +r:exec(C3.compile(data, "ENTRY"), r:makeContext()) +T.eq(g.save.gen3Vars[0x4002], 99, "named row edit travels through Loader and compiler to native state") +local gift = { ctx = r:makeContext(), species = "CHARMANDER", level = 5 } +loader.events:emit("pokemon.before_give", gift) +T.eq(loader.exports.probe.map, "OAKS_LAB", "profile translates the verified native lab slot") +T.eq(world.map.id, "MAP_G04_N03", "native map identity is never renamed") +T.eq(gift.level, 20, "profile gift edits reach the existing gift event") +T.eq(gift.nickname, "PROFILE", "profile nickname edits commit") +loader:_rollback("probe") +T.eq(loader._decodedHooks.chains["script.command"], nil, "rollback retires the per-mod decoded registration") + +-- Every visit starts from original IR, even when an earlier visit rewrote it. +do + local g, r, program, _, bus = fixture(3, { + ENTRY = { { "setvar", 0x4030, 0 }, { "goto", "LOOP" } }, + LOOP = { + { "addvar", 0x4030, 1 }, + { "setvar", 0x4031, 4 }, + { "compare_var_to_value", 0x4030, 3 }, + { "goto_if", 0, "LOOP" }, + { "end" }, + }, + }) + local values = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setvar" and ir[2] == 0x4031 then + values[#values + 1] = ir[3] + if #values == 1 then + ir[3] = 151 + elseif #values == 2 then + return + end + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.same(values, { 4, 4, 4 }, "loops offer original source operands after both rewrites and vetoes") + T.eq(g.save.gen3Vars[0x4030], 3, "rewritten loop retains its branch and completion") + T.eq(g.save.gen3Vars[0x4031], 4, "transparent later visit restores the original command") +end +-- A vetoed compile-time text load must not leak into a later callstd. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "loadword", 0, "TEXT_A" }, { "callstd", 4 }, { "end" } } }) + local shown = 0 + local Commands = require("src.script.Commands") + local old = Commands.g3_show_text + Commands.g3_show_text = function() + shown = shown + 1 + end + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "loadword" then + return + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + Commands.g3_show_text = old + T.eq(shown, 0, "veto removes compile-time text state from subsequent lowering") +end +-- Gen-2 selected profile keeps the fourth decoded row and lossless byte edits. +do + Version.set("gold") + local files = { + ["options.lua"] = "return {mods={probe=true}}", + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,args,cmd) + if op=='setval' then + assert(cmd.op=='setval' and cmd.value==4 and args[1]==4) + args[1]=77 + mod.exports.retained=next + end + return next(ctx,op,args,cmd) + end) + mod.events:once('pokemon.before_give',function(gift)mod.exports.gifts=(mod.exports.gifts or 0)+1 end) + end]=], + } + local d = require("tests.fixtures.upstream_mods.data").fresh() + local l = Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = "decoded" } }) + T.eq(l:load(d), true, "constructor profile works without editing a manifest or options file") + d.map_scripts = { source = "RomExtractorGen2", scripts = { ENTRY = { { "setval", 4 }, { "endall" } } }, maps = {} } + local g = { data = d, save = { flags = {}, party = {} } } + local r = Runner.new(g, {}) + local ctx = r:makeContext() + r:exec(C2.compile(d, "ENTRY"), ctx) + T.eq(ctx.g2Var, 77, "Gen-2 raw byte edits are decoded before native lowering") + T.raises(function() + l.exports.probe.retained() + end, "only valid during", "retained next cannot replay a completed command") + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + T.eq(l.exports.probe.gifts, 1, "profiled once subscription retires on the original native event bus") +end +-- Deep source operands never escape into a retained callback record. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "nop", { nested = 1 } }, { "end" } } }) + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "nop" then + ir[2].nested = 2 + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.eq(g.data.map_scripts.scripts.ENTRY[1][2].nested, 1, "nested source operands remain unchanged") +end + +-- Gift records include the cartridge species number without changing native +-- event records; numeric edits and variable-backed gifts resolve through data. +do + Version.set("firered") + local d = require("tests.fixtures.upstream_mods.data").fresh() + d.constants.speciesOrder = { [4] = "CHARMANDER", [151] = "MEW" } + local files = { + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.events:on('pokemon.before_give',function(gift) + assert(gift.speciesId==4) + gift.species=151;gift.level=120;gift.nickname='NUMERIC' + end) + end]=], + } + local l = Loader.new({ fs = SDK.memfs(files), dev = false }) + assert(l:load(d)) + local g = { data = d, save = { gen3Vars = { [0x4002] = 4 } } } + local ctx = { game = g, save = g.save, script = { { "g3_give_pokemon", 0x4002, 5, 0 } }, pc = 1 } + local gift = { ctx = ctx, species = "CHARMANDER", level = 5 } + local nativeSeen + l.events:on("pokemon.before_give", function(row) + nativeSeen = row + end, -100, "native") + l.events:emit("pokemon.before_give", gift) + T.eq(gift.species, "MEW", "numeric profiled gift species resolves through this dataset") + T.eq(gift.level, 100, "decoded gift level stays in the supported range") + T.eq(g.save.gen3Vars[0x4002], 151, "variable-backed native gift retains the edited species number") + T.eq(nativeSeen, gift, "native listeners retain the exact original gift record") + T.eq(gift.speciesId, nil, "profile-only species metadata never leaks into native event records") +end + +-- Hook and group scopes survive a real runner suspension across updates. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "delay", 2 }, { "setvar", 0x4050, 9 }, { "end" } } }) + local phases = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "before" + end + local value = next(ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "after" + end + return value + end) + r:run(program) + T.eq(r:isRunning(), true, "profiled native delay suspends the actual runner") + T.same(phases, { "before" }, "hook remains before next while native command is suspended") + T.eq(g.save.gen3Vars[0x4050], nil, "later operation cannot run during the delay") + r:update() + r:update() + T.eq(r:isRunning(), false, "native updates finish the profiled delay") + T.same(phases, { "before", "after" }, "post-next executes exactly once after resumption") + T.eq(g.save.gen3Vars[0x4050], 9, "native continuation executes after resumption") +end + +-- Provenance must not keep a retired dataset alive through the compiler cache. +local weak, retained +local function retired(keepRows) + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local refs = setmetatable({ g.data, program }, { __mode = "v" }) + return refs, keepRows and program or nil +end +weak = retired(false) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "private provenance releases an unowned dataset") +T.eq(weak[2], nil, "private provenance releases an unowned compiled program") +weak, retained = retired(true) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "a retained native row list cannot retain the entire source dataset") +T.raises(function() + Decoded.prepare(retained) +end, "retired", "decoded execution refuses a retired source cohort") + +do + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + g.data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = { { "end" } } }, maps = {} } + T.raises(function() + Decoded.prepare(program) + end, "retired", "a replaced source pool retires old compiled provenance") +end +-- Swallowing an error below next cannot retry partial native side effects. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local Commands = require("src.script.Commands") + local original, count = Commands.g3_setvar, 0 + Commands.g3_setvar = function() + count = count + 1 + error("owned native failure") + end + bus:wrap("script.command", function(next, ctx, op, ir) + pcall(next, ctx, op, ir) + end) + T.raises(function() + r:exec(program, r:makeContext({ source = { strict = true } })) + end, "did not complete", "a mod cannot swallow a failing native group and restart it") + Commands.g3_setvar = original + T.eq(count, 1, "native side effects occur at most once across a swallowed failure") +end + +for _, gen in ipairs({ 2, 3 }) do + local compiler = gen == 2 and C2 or C3 + local g, r, program, _, bus = fixture( + gen, + { ENTRY = gen == 2 and { { "setval", 4 }, { "endall" } } or { { "setvar", 0x4070, 4 }, { "end" } } } + ) + T.eq(compiler.compile(g.data, "ENTRY", true), program, "public compiler continues to ignore surplus arguments") + local original, calls = compiler.compile, 0 + compiler.compile = function(data, entry) + calls = calls + 1 + return original(data, entry) + end + program = compiler.compile(g.data, "ENTRY") + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setval" then + ir[2] = 7 + elseif op == "setvar" then + ir[3] = 7 + end + return next(ctx, op, ir) + end) + local ctx = r:makeContext() + r:exec(program, ctx) + T.eq( + gen == 2 and ctx.g2Var or g.save.gen3Vars[0x4070], + 7, + "a transparent public compiler wrapper cannot suppress decoded callbacks" + ) + T.eq(calls, 1, "private tracing does not replay public compiler wrappers") + compiler.compile = original +end + +-- The decoded Gen-2 call contract runs its subroutine before post-next code. +do + local g, r, program, _, bus = fixture(2, { + ENTRY = { { "scall", "SUB" }, { "setval", 7 }, { "endall" } }, + SUB = { { "setval", 9 }, { "pause", 2 }, { "end" } }, + }) + local observed + bus:wrap("script.command", function(next, ctx, op, ir) + local result = next(ctx, op, ir) + if op == "scall" then + observed = ctx.g2Var + end + return result + end) + r:run(program) + T.eq(observed, nil, "Gen-2 call hook remains suspended with its subroutine") + T.eq(r:isRunning(), true, "nested native wait keeps the caller suspended") + r:update() + r:update() + local ctx = r.ctx + T.eq(observed, 9, "Gen-2 post-next sees the completed subroutine state") + T.eq(ctx.g2Var, 7, "the caller continues after its decoded call hook closes") +end +T.finish() diff --git a/tests/engine/example_gallery_acceptance.lua b/tests/engine/example_gallery_acceptance.lua new file mode 100644 index 000000000..85c280cf8 --- /dev/null +++ b/tests/engine/example_gallery_acceptance.lua @@ -0,0 +1,2 @@ +-- The gallery's actual load/hook assertions must run in the ROM-free engine tier. +dofile('tests/mod_examples_tests.lua') diff --git a/tests/engine/gate_fingerprint.lua b/tests/engine/gate_fingerprint.lua index 731996016..85fb65a72 100644 --- a/tests/engine/gate_fingerprint.lua +++ b/tests/engine/gate_fingerprint.lua @@ -9,8 +9,8 @@ -- itself, and would silently make two builds of the same engine refuse to -- link. Here it flips one hex string and fails. -- --- Regenerate deliberately with scripts/test.sh --bless after recording the --- intended parity change; never automatically. +-- Regenerate deliberately after auditing each changed surface section; see +-- tests/goldens/fixture_fingerprint.md. Never update expected values automatically. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -37,6 +37,40 @@ local golden = readGolden(GOLDEN) T.check(golden ~= nil, "the committed fixture fingerprint golden exists: " .. GOLDEN) T.eq(actual, golden, "fixture fingerprint matches the committed golden") +-- Section pins make a registry addition reviewable without treating a whole +-- digest change as permission to bless unrelated species or move changes. +local sectionFile = assert(io.open("tests/goldens/fixture_fingerprint_sections.txt", "r")) +local expectedSections = {} +for line in sectionFile:lines() do + local name, expected = line:match("^(%S+)%s+(%x+)$") + if name then expectedSections[name] = expected end +end +sectionFile:close() +local sectionCount = 0 +for name, body in Fingerprint.surface(data, {}):gmatch("%[([^%]]+)%]([^%[]*)") do + sectionCount = sectionCount + 1 + T.eq(Fingerprint.digest("[" .. name .. "]" .. body), expectedSections[name], + "audited fixture surface section: " .. name) + expectedSections[name] = nil +end +T.eq(sectionCount, 7, "the fixture has seven populated fingerprint sections") +T.eq(next(expectedSections), nil, "all committed section pins were checked") + +-- Platinum's four dynamic-power effects were added in 3252aec. They are +-- native full handlers, and removing any one must change link compatibility. +local sinnoh = { + "POWER_BASED_ON_LOW_SPEED", "INCREASE_POWER_WITH_MORE_HP", + "DOUBLE_POWER_WHEN_BELOW_HALF", "DOUBLE_POWER_HEAL_SLEEP", +} +for _, id in ipairs(sinnoh) do + T.eq(data.move_effects[id].kind, "full", "Sinnoh effect is registered: " .. id) + local copy = T.fixtures.fresh() + local copyRun = T.sdk.loadNone({ data = copy }) + copy.move_effects[id] = nil + T.neq(Fingerprint.compute(copy, {}), actual, "omitting " .. id .. " changes compatibility") + copyRun.release() +end + -- determinism: same data, same digest. A fingerprint that folded a table -- address or a pairs() order would pass once and fail in CI. T.eq(Fingerprint.compute(data, {}), actual, "the fingerprint is stable within a process") diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47cc..d3bbee83b 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -111,13 +111,11 @@ local DEBT = { ["hook:encounter.fishing"] = "M5", ["hook:render.zones"] = "M9", - ["hook:trainer.party"] = "M7", ["hook:ui.pc.items"] = "M8", ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", ["event:player.warped"] = "M5", - ["event:pokemon.before_give"] = "M7", ["event:pokemon.evolved"] = "M7", ["event:pokemon.level_up"] = "M7", ["event:pokemon.move_learned"] = "M7", diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua new file mode 100644 index 000000000..687ad29c8 --- /dev/null +++ b/tests/engine/gen2_apricorn_content.lua @@ -0,0 +1,289 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Authored IR, not cartridge scripts or dialogue. Exercise the real loader, +-- native VM, menus, flags, inventory and observer boundaries together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack');local Flags=require('src.script.Gen2Flags') +local P=require('src.world.Gen2ApricornPrograms');local Merge=require('src.mods.Merge') +local Commands=require('src.script.Commands');local Sound=require('src.core.Sound') +local oldText,oldSound=Commands.show_text,Sound.play +Commands.show_text=function()end;Sound.play=function()end -- external IO only +local function working(version)return version=='crystal' and 80 or 79 end +local function fixture(version) + local d=T.fixtures.fresh() + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + d.items[key]={id=key,name='APRICORN '..i,pocket='ITEM'} + end + d.items.ITEM_173={id='ITEM_173',name='BERRY',pocket='ITEM'} + for i=201,202 do + local key=string.format('ITEM_%03d',i) + d.items[key]={id=key,name='BALL '..i,pocket='BALL',ball=true} + end + local ask={{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'}} + local scripts={ASK=ask,GAVE={{'setflag',working(version)},{'end'}},CANCEL={{'setval',0},{'end'}}, + WAIT={{'setval',98},{'end'}},FULL={{'setval',99},{'end'}},DONE={{'end'}}} + local pending,rows={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + rows[i]={apricorn=key,ball='ITEM_201',index=i} + if i>1 then ask[#ask+1]={'ifequal',110+i,'CHOOSE'..i}end + scripts['CHOOSE'..i]={{'setevent',599+i},{'sjump','GAVE'}} + pending[#pending+1]={'checkevent',599+i};pending[#pending+1]={'iftrue','DELIVER'..i} + scripts['DELIVER'..i]={{'checkflag',working(version)},{'iftrue','WAIT'}, + version=='crystal' and {'verbosegiveitemvar',201,22} or {'verbosegiveitem',201,1}, + {'iffalse','FULL'},{'clearevent',599+i},{'sjump','DONE'}} + end + ask[#ask+1]={'setevent',600};ask[#ask+1]={'sjump','GAVE'} + for i=1,7 do pending[#pending+1]={'checkitem',110+i};pending[#pending+1]={'iftrue','ASK'}end + pending[#pending+1]={'end'} + scripts.PENDING=pending;scripts.OVERLAPPING=Merge.deepCopy(pending) + d.map_scripts={scripts=scripts,apricornBalls=rows,apricornInfo={complete=true}, + apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}} + return d +end +local function load(d,sources,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + for _,entry in ipairs(sources or{})do + local id,source=entry[1],entry[2];local path='mods/'..id + files[path..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=mode and mode[1] or 2, + content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files[path..'/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths[#paths+1]=path + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,inventory) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + return {data=d,save={inventory=inventory or{},flags={}},stack=stack,overworld={}} +end +local function run(g,key) + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,key)));return runner +end +local function press(g,key) + g.input={wasPressed=function(_,k)return k==key end};g.stack:update(0) +end +local recipe=[[{apricorn='BERRY',ball='BALL_202',event=5000,index=8}]] +local source=[[ + local p=mod.content["apricorns"] + assert(p:supports()) + assert(p:get('APRICORN_1').apricorn=='APRICORN_1') + local v=p:get('APRICORN_1');v.ball='LEAK' + assert(p:get('APRICORN_1').ball=='BALL_201') + local n=0;for id,row in p:each()do n=n+1;row.event=65534 end;assert(n==7) + p:register('BERRY_RECIPE',]]..recipe..[[) + p:patch('APRICORN_1',{ball='BALL_202',event=5100,index=1}) + mod.exports.seen={} + mod.exports.stop=mod.events:on("apricorn.converted",function(e) + mod.exports.seen[#mod.exports.seen+1]={apricorn=e.apricorn,ball=e.ball,event=e.event} + e.apricorn='LEAK';e.ball='LEAK';e.event=123;return false + end) +]] +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local originalScripts=d.map_scripts.scripts + local originalRows=d.map_scripts.apricornBalls + local oldCompiled=assert(VM.compile(d,'PENDING')) + local r=load(d,{{'recipes',source}},mode);local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' native registry and event registration load') + if #r.errors==0 then + local e=r.loader.exports.recipes + T.eq(#d.map_scripts.apricornBalls,8,label..' actual selector receives all eight native recipes') + T.eq(d.map_scripts.apricornBalls[8].apricorn,'ITEM_173',label..' semantic input resolves to a real native item byte') + T.check(d.map_scripts.scripts~=originalScripts,label..' projection changes VM cache identity') + T.check(VM.compile(d,'PENDING')~=oldCompiled,label..' an already compiled root is recompiled') + T.eq(originalScripts.ASK[3][2],112,label..' original native branches are not overwritten') + local g=game(d,{ITEM_173=3}) + local runner=run(g,'OVERLAPPING') + T.eq(g.stack:top().items[1].label,'BERRY x3',label..' overlapping native membership root accepts the eighth item') + press(g,'a') + if version=='crystal'then press(g,'down');press(g,'down');press(g,'a')end + local quantity=version=='crystal' and 3 or 1 + T.eq(runner:isRunning(),false,label..' actual native selection finishes') + T.eq(runner.ctx.g2Var,173,label..' VM retains actual item byte without fake ordinal remapping') + T.eq(g.save.g2KurtApricorns,quantity,label..' native handover quantity survives projection') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' native selection sets the authored word-sized event') + T.eq(g.save.flags[Flags.scriptFlag(working(version))],true,label..' native GAVE working flag is preserved') + run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,nil,label..' working native branch defers delivery') + T.eq(#e.seen,0,label..' waiting job cannot emit a converted event') + require('src.script.Gen2Daily').onNewDay(g.save) + T.eq(g.save.flags[Flags.scriptFlag(working(version))],nil,label..' actual daily reset clears native work') + g.save.inventory.ITEM_202=99;run(g,'PENDING') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' actual bag refusal retains the job event') + T.eq(#e.seen,0,label..' refused native gift cannot emit a converted event') + g.save.inventory.ITEM_202=nil;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,quantity,label..' actual VM gives the authored ball in confirmed quantity') + T.eq(g.save.inventory.ITEM_201,nil,label..' template ball is not accidentally awarded') + T.eq(g.save.flags[Flags.eventFlag(5000)],false,label..' real native clear precedes completion') + T.eq(#e.seen,1,label..' successful native delivery emits exactly once') + local observed=e.seen[1] + T.eq(observed.apricorn,mode[1]==3 and 'BERRY' or 'ITEM_173',label..' observer receives the API-specific item identity') + T.eq(observed.ball,mode[1]==3 and 'BALL_202' or 'ITEM_202',label..' observer receives the actual delivered ball') + T.eq(observed.event,5000,label..' observer receives the actual cleared event') + run(g,'PENDING');T.eq(#e.seen,1,label..' cleared event prevents duplicate completion') + g=game(d,{ITEM_173=3});runner=run(g,'PENDING');press(g,'a') + if version=='crystal'then + press(g,'b');T.eq(g.save.inventory.ITEM_173,3,label..' quantity cancellation consumes nothing') + press(g,'b') + else -- Gold/Silver have already confirmed their one-item transaction + T.eq(g.save.inventory.ITEM_173,2,label..' Gold/Silver remain one-at-a-time') + end + if version=='crystal'then + T.eq(g.save.flags[Flags.eventFlag(5000)],nil,label..' cancellation cannot create a pending job') + end + local api=e.api.content.apricorns + T.check(not pcall(function()api:patch('APRICORN_1',{event=5300})end),label..' frozen content refuses late writes') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' unloading reloads cleanly') + T.eq(d.map_scripts.scripts,originalScripts,label..' disabling restores original native script table') + T.eq(d.map_scripts.apricornBalls,originalRows,label..' disabling restores original native recipe table') + finish(clean) + end +end +V.set('crystal') +for _,bad in ipairs({ + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=65535,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=65536})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=-1,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=0/0,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=math.huge})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=601,index=8})", + "p:register('BAD',{apricorn='APRICORN_2',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='MISSING',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='MISSING',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=8,typo=true})", + "p:patch('APRICORN_1',{apricorn=mod.DELETE})", + "local x={};x.apricorn=x;p:register('BAD',x)", + "p:register('BAD',"..recipe..");error('entry failure')", +})do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'broken',"local p=mod.content.apricorns;p:patch('APRICORN_1',{event=5100});"..bad}}) + T.check(#r.errors>0,'invalid authored recipe is attributed to its owner: '..bad) + T.eq(d.map_scripts.scripts,scripts,'failed owner cannot leak projected native scripts') + T.eq(d.map_scripts.apricornBalls,rows,'failed owner rolls back prior valid recipe patches') + finish(r) +end +do + local d=fixture('crystal') + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'})]]}}) + T.eq(#r.errors,0,'semantic native item references may be provided by a later mod') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].apricorn,'ITEM_174', + 'forward item reference resolves before actual native projection') + local g=game(d,{ITEM_174=1});local runner=run(g,'PENDING');press(g,'a');press(g,'a') + T.eq(runner.ctx.g2Var,174,'later native item executes through actual selection byte') + T.eq(g.save.flags[Flags.eventFlag(5200)],true,'forward recipe enters its actual pending event') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'});error('supplier failed')]]}}) + T.check(#r.errors>=2,'failed item supplier also attributes unresolved recipe to its owner') + T.eq(d.items.ITEM_174,nil,'failed supplier leaves no actual native item behind') + T.eq(d.map_scripts.scripts,scripts,'failed forward recipe leaves native script identity intact') + T.eq(d.map_scripts.apricornBalls,rows,'failed forward recipe leaves native table intact') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'no_byte',[[ + mod.content.items:register('AUTHORED_FRUIT',{id='AUTHORED_FRUIT',name='Authored fruit',price=0,pocket='ITEM'}) + mod.content.apricorns:register('UNINDEXED', + {apricorn='AUTHORED_FRUIT',ball='BALL_202',event=5200,index=8}) + ]]}}) + T.check(#r.errors>0,'an arbitrary semantic item cannot fabricate a native ROM byte') + T.eq(d.items.AUTHORED_FRUIT,nil,'unindexed recipe failure rolls back its item registration') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'a_first',[[ + mod.content.apricorns:register('FIRST',{apricorn='BERRY',ball='BALL_202',event=5200,index=8}) + ]]},{'z_conflict',[[ + mod.content.apricorns:register('SECOND',{apricorn='ITEM_173',ball='ITEM_201',event=5300,index=9}) + ]]}}) + T.check(#r.errors>0,'semantic/native aliases cannot register the same byte twice') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].event,5200, + 'later conflicting owner rolls back without discarding the earlier valid owner') + T.eq(#d.map_scripts.apricornBalls,8,'duplicate alias never enters native menu') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_supplier',[[ + mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0}) + mod.content.apricorns:register('BAD',{apricorn='BERRY',ball='MISSING',event=5200,index=8}) + ]]},{'b_consumer',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5300,index=9})]]}}) + T.check(#r.errors>=2,'late recipe failure revalidates consumers after its item supplier rolls back') + T.eq(d.items.ITEM_174,nil,'late validation rolls back supplier item content') + T.eq(d.map_scripts.scripts,scripts,'cascading forward-reference failure leaves no projected script table') + T.eq(d.map_scripts.apricornBalls,rows,'cascading failure leaves pristine native rows') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'removed',[[mod.content.apricorns:remove('APRICORN_1') + mod.exports.count=0;mod.events:on("apricorn.converted",function()mod.exports.count=mod.exports.count+1 end)]]}}) + T.eq(#r.errors,0,'removal projects native membership and pending branches') + T.eq(#d.map_scripts.apricornBalls,6,'removed recipe cannot be newly selected') + local g=game(d,{ITEM_111=2});run(g,'PENDING') + T.eq(g.stack:top().name,'map','removed item no longer opens the native selector') + g.save.flags[Flags.eventFlag(600)]=true;g.save.g2KurtApricorns=2;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_201,2,'removed recipe still delivers an already pending native job') + T.eq(r.loader.exports.removed.count,1,'retired pending job completes only after native gift') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'recipes',source}}) + local scripts=d.map_scripts.scripts;local label + for key in pairs(scripts)do if key:match('^engine:apricorn:')then label=key;break end end + scripts[label][1]={'setval',77};local external=scripts[label] + d.map_scripts.apricornBalls[1].ball='ITEM_201' + finish(r);local clean=load(d) + T.eq(d.map_scripts.scripts[label],external,'reload preserves an externally edited generated label') + T.eq(d.map_scripts.apricornBalls[1].ball,'ITEM_201','reload preserves externally edited native rows') + T.eq(#d.map_scripts.apricornBalls,7,'one external edit cannot retain unchanged mod-only recipes on unload') + T.eq(d.map_scripts.apricornBalls[2].event,nil,'unload restores unchanged original records beside external edits') + finish(clean) +end +do + local d=fixture('crystal');local r=load(d,{{'recipes',source}}) + local added=d.map_scripts.apricornBalls[8];added.ball='ITEM_201' + table.remove(d.map_scripts.apricornBalls,2) + finish(r);local clean=load(d) + T.eq(#d.map_scripts.apricornBalls,7,'unload preserves external deletion and an externally edited addition') + T.eq(d.map_scripts.apricornBalls[1].event,nil,'other authored edits still retire beside an external deletion') + T.eq(d.map_scripts.apricornBalls[7],added,'external edit gives the added native record independent ownership') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'isolation',[[local p=mod.content["apricorns"];assert(not p:supports()) + assert(p:get('APRICORN_1')==nil);p:register('NO_WRITE',]]..recipe..[[) + assert(not p:supports())]]}}) + T.eq(#r.errors,0,version..' unsupported handle is readable and reports unavailable') + T.eq(d.map_scripts.scripts,scripts,version..' cannot mutate a lookalike native script pool') + T.eq(d.map_scripts.apricornBalls,rows,version..' cannot acquire Gen2 native recipes') + finish(r) +end +V.set('crystal') +do + local d=fixture('crystal');d.map_scripts.apricornPrograms=nil + local r=load(d,{{'oldcache',"mod.content.apricorns:register('NEW',"..recipe..")"}}) + T.check(#r.errors>0,'old cache reports missing native Kurt bindings to the writing owner') + finish(r) + r=load(d,{{'observer',"mod.events:on('apricorn.converted',function()end)"}}) + T.eq(#r.errors,0,'an observer alone cannot prevent an older cache from booting') + finish(r) +end +Commands.show_text=oldText;Sound.play=oldSound;V.set(original) +T.finish('native apricorn registry, actual Kurt VM transactions, completion observers and reload isolation') diff --git a/tests/engine/gen2_apricorn_native.lua b/tests/engine/gen2_apricorn_native.lua new file mode 100644 index 000000000..5a15f1da0 --- /dev/null +++ b/tests/engine/gen2_apricorn_native.lua @@ -0,0 +1,192 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local E=require('src.import.RomExtractorGen2');local Writer=require('src.import.LuaWriter') +local A=require('src.world.Gen2Apricorns');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +local function extractor(version,bytes,address,length) + address=address or 0x4000 + local raw=string.rep('\0',address)..string.char(unpack(bytes)) + if length and #raw0 and 3-count or nil,version..' actual bag toss removes exactly the confirmed quantity') + T.eq(g.save.inventory.ITEM_119,1,version..' selection cannot consume another apricorn') + T.eq(g.stack:top().name,'map',version..' actual Menu/Runner returns to its native owner') + g,r=gameFor(version,{ITEM_117=3});g.save.g2KurtApricorns=0 + press(g,'down');press(g,'a') + T.eq(r.ctx.g2Var,0,version..' explicit CANCEL returns false') + T.eq(g.save.inventory.ITEM_117,3,version..' CANCEL preserves all bag items') + T.eq(g.save.g2KurtApricorns,0,version..' CANCEL cannot retain a previous quantity') + g,r=gameFor(version,{}) + T.eq(r:isRunning(),false,version..' empty native bag does not open an empty menu') + T.eq(r.ctx.g2Var,0,version..' empty native bag returns false') +end +do + local g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'a');press(g,'b') + T.eq(r:isRunning(),true,'Crystal quantity cancel returns to the actual apricorn menu') + T.eq(g.stack:top().items[1].label,'AUTHORED APRICORN x3','quantity cancel restores native list') + T.eq(g.save.inventory.ITEM_117,3,'quantity cancel does not silently take one apricorn') + press(g,'down');press(g,'a') + T.eq(#g.stack:top().items,1,'Crystal asks for quantity even when only one is held') + press(g,'a') + T.eq(r.ctx.g2Var,119,'selection after quantity cancel can choose a different actual item') + T.eq(g.save.inventory.ITEM_117,3,'reselection preserves the original apricorn stack') + T.eq(g.save.inventory.ITEM_119,nil,'reselection consumes the confirmed other apricorn') + g,r=gameFor('crystal',{ITEM_117=2});press(g,'a');g.save.inventory.ITEM_117=nil;press(g,'a') + T.eq(r.ctx.g2Var,0,'concurrent native bag removal cannot create a paid Kurt job') + T.eq(g.save.g2KurtApricorns,0,'failed revalidation cannot mint a job quantity') + g,r=gameFor('crystal',{ITEM_117=3});press(g,'a');press(g,'down');press(g,'down');press(g,'a') + local event=Flags.eventFlag(604);g.save.flags={[event]=true};g.save.inventory.ITEM_202=97 + local function collect() + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,'COLLECT')));return runner + end + collect() + T.eq(g.save.inventory.ITEM_202,97,'actual verbosegiveitemvar refuses the whole overflowing ball stack') + T.eq(g.save.flags[event],true,'failed native ball delivery preserves the recipe event') + g.save.inventory.ITEM_202=96;collect() + T.eq(g.save.inventory.ITEM_202,99,'actual native delivery grants the confirmed three-ball quantity') + T.eq(g.save.flags[event],false,'successful native script clears its real event') + collect();T.eq(g.save.inventory.ITEM_202,99,'native event guard prevents a duplicate ball collection') + g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'down');press(g,'a');press(g,'b') + T.eq(g.stack:top().index,2,'Crystal quantity cancel retains the selected apricorn cursor') + press(g,'b') + T.eq(r.ctx.g2Var,0,'cancelling the restored list cancels the native transaction') + T.eq(g.save.inventory.ITEM_119,1,'cancelling restored list cannot remove the selected apricorn') + g,r=gameFor('crystal',{ITEM_117=200});press(g,'a') + T.eq(#g.stack:top().items,99,'native Crystal quantity caps at99 for an oversized historical stack') + T.eq(g.stack:top().maxVisible,5,'quantity input uses bounded scrolling instead of extending offscreen') + press(g,'up');press(g,'a') + T.eq(g.save.g2KurtApricorns,99,'actual Menu wrap can confirm the native maximum quantity') + T.eq(g.save.inventory.ITEM_117,101,'capped native handover removes no unconfirmed excess') + local rows,inventory={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i);rows[i]={index=i,apricorn=key,ball='ITEM_202'};inventory[key]=1 + end + g,r=gameFor('crystal',inventory,rows) + T.eq(#g.stack:top().items,8,'full native list retains seven recipes and CANCEL') + T.eq(g.stack:top().maxVisible,4,'Crystal recipe list uses four visible scrolling rows') + press(g,'up') + T.eq(g.stack:top().index,8,'actual recipe cursor wraps to CANCEL') + T.eq(g.stack:top().scroll,4,'actual recipe scroll keeps wrapped CANCEL visible') + press(g,'a');T.eq(r.ctx.g2Var,0,'visible wrapped CANCEL exits the native special') +end +for _,version in ipairs({'prism','polishedcrystal'})do + local g,r=gameFor(version,{ITEM_085=2},{{index=1,apricorn='ITEM_085',ball='ITEM_202'}}) + press(g,'a');press(g,'b') + T.eq(r.ctx.g2Var,85,version..' unreviewed hack retains its legacy quantity-cancel protocol') + T.eq(g.save.g2KurtApricorns,1,version..' hack quantity-cancel behavior is not silently converted to Crystal') + T.eq(g.save.inventory.ITEM_085,1,version..' hack legacy selection still consumes its original single item') +end +Commands.show_text=oldText;require('src.core.Sound').play=oldSound;V.set(original) +T.finish('native ApricornBalls importer, actual Kurt menus, cartridge quantities and native ball delivery') diff --git a/tests/engine/gen2_apricorn_programs_import.lua b/tests/engine/gen2_apricorn_programs_import.lua new file mode 100644 index 000000000..4154e8726 --- /dev/null +++ b/tests/engine/gen2_apricorn_programs_import.lua @@ -0,0 +1,59 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local E=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local original=V.get() +local Importer=require('src.import.RomImporter');local P=require('src.world.Gen2ApricornPrograms') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local names={'Kurt1.AskApricorn','Kurt1.GotLureBall','Kurt1.GaveKurtApricorns','Kurt1.Cancel'} +local function extractor(version,missing,outside) + local symbols={} + for i,name in ipairs(names)do if i~=missing then + symbols[name]={1,outside==i and 0x8000 or outside==-i and 0x100 or 0x4000+i} + end end + return E.new(string.rep('\0',0x8000),version,{symbols=symbols}) +end +local function pool()return{scripts={},queue={}}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local q=pool();local e=extractor(version) + local roles=e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' all native Kurt entry points enter the real decoder queue') + for _,role in ipairs({'ask','pending','gave','cancel'})do + T.check(type(roles[role])=='string',version..' '..role..' retains the actual native script key') + end + e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' repeated roots do not duplicate native decode work') + for i=1,4 do + q=pool();T.eq(extractor(version,i):gen2QueueApricornPrograms(q),nil,version..' missing role cannot advertise partial bindings') + T.eq(#q.queue,0,version..' missing role is discovered before any queue mutation') + q=pool();T.eq(extractor(version,nil,i):gen2QueueApricornPrograms(q),nil,version..' invalid bank address is rejected') + T.eq(#q.queue,0,version..' invalid role cannot add a partial native queue') + q=pool();T.eq(extractor(version,nil,-i):gen2QueueApricornPrograms(q),nil,version..' home-bank engine code is not native script bytecode') + T.eq(#q.queue,0,version..' home-bank rejection precedes queue mutation') + end + local marker=markerFor(version) + T.check(marker:find('gen2%-apricorn%-programs%-v1:')~=nil,version..' cache includes decoded Kurt binding revision') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-apricorn%-programs%-v1:','')) + local report=Importer.readyReport(version) + T.eq(report.ok,false,version..' old recipe-only cache cannot bypass native program reimport') + T.check(report.why:find('another version',1,true)~=nil,version..' cache revision is checked before generated-file readiness') +end +for _,version in ipairs({'prism','polishedcrystal','red','emerald'})do + local q=pool();T.eq(extractor(version):gen2QueueApricornPrograms(q),nil,version..' foreign layout has no guessed Kurt bindings') + T.eq(#q.queue,0,version..' foreign decoder queue is unchanged') + T.check(not markerFor(version):find('gen2%-apricorn%-programs%-v1:'),version..' unrelated import marker is unchanged') +end +for _,ask in ipairs({{},{{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false}, + {{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false,{'sjump','GAVE'}}})do + local d={map_scripts={apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}, + scripts={ASK=ask,PENDING={},GAVE={},CANCEL={}},apricornBalls={{apricorn='ITEM_111',ball='ITEM_201'}}}} + local ok,description,why=pcall(P.describe,d) + T.eq(ok,true,'malformed native selection reports a binding error without crashing mod-free load') + T.eq(description,nil,'malformed native selection cannot produce a writable native projection') + T.check(type(why)=='string','malformed selection explains the unavailable native target') +end +V.set(original) +T.finish('native Kurt program import queue, completeness, cache revisions and malformed signatures') diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua new file mode 100644 index 000000000..2b5162754 --- /dev/null +++ b/tests/engine/gen2_decoration_content.lua @@ -0,0 +1,194 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Native=require('src.world.Gen2Decorations');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Stack=require('src.core.StateStack');local TextBox=require('src.render.TextBox') +local Merge=require('src.mods.Merge') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='CANCEL',flag=0,sprite=0}, + [1]={type=1,name='STORE BED',action='PUT_AWAY_BED',flag=0,sprite=0}, + [2]={type=2,name='SYNTHETIC',action='SET_UP_BED',flag=500,sprite=0}, + [3]={type=2,name='OTHER',action='SET_UP_BED',flag=501,sprite=44}, + [4]={type=1,name='STORE DOLL',action='PUT_AWAY_DOLL',flag=0,sprite=0}, + [5]={type=5,name='MODEL',action='SET_UP_DOLL',flag=502,sprite=18}, + [6]={type=1,name='CONSOLE',action='SET_UP_CONSOLE',flag=503,sprite=20}, + [7]={type=1,name='MAP',action='SET_UP_POSTER',flag=504,sprite=22}, + [8]={type=1,name='RUG',action='SET_UP_CARPET',flag=505,sprite=255}, + [9]={type=1,name='PLANT',action='SET_UP_PLANT',flag=506,sprite=24}, + [10]={type=6,name='MODEL',action='SET_UP_BIG_DOLL',flag=507,sprite=26}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=2,poster=7}, + giftsComplete=true,gifts={2,3,5},sourceNames={[5]='MODEL'},species={[5]='SPECIES_001'}}, + decorationPrograms={posters={[7]='POSTER'},null='NULL',ornament='ORNAMENT',big='BIG', + texts={put='PUT',empty='EMPTY',setup='SET',replace='REPLACE',same='SAME',sidePut='SIDE_PUT',sideSetup='SIDE_SET'}}, + scripts={CALLBACK={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'endcallback'}}, + DESC={{'describedecoration',1}},POSTER={{'setval',70},{'end'}},NULL={{'setval',71},{'end'}}, + ORNAMENT={{'setval',72},{'end'}},BIG={{'setval',73},{'end'}}, + PC={{'special',0,'PlayersHousePC'},{'iftrue','RELOAD'},{'end'}},RELOAD={{'warp','NONE',0,0,0},{'end'}}}} + d.text={PUT='{RAM:wStringBuffer3}',SET='{RAM:wStringBuffer3}',REPLACE='{RAM:wStringBuffer3}/{RAM:wStringBuffer4}', + SAME='SYNTHETIC SAME',EMPTY='SYNTHETIC EMPTY',SIDE_SET='SYNTHETIC SIDE SET',SIDE_PUT='SYNTHETIC SIDE PUT'} + d.pokemon.SPECIES_001={name='RENAMED'} + return d +end +local function load(d,code,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/deco/manifest.json']=Json.encode({id='deco',name='Synthetic decorations',version='1.0.0', + api=mode and mode[1]or 2,content_format=mode and mode[2]or 'native',entry='main.lua',affects_link=false}) + files['mods/deco/main.lua']='local mod=...;mod.exports.api=mod;'..code;paths={'mods/deco'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + local g={data=d,save={flags={},inventory={}},stack=stack,input={wasPressed=function()return false end}} + g.overworld={map={id='ROOM'},player={cellX=4,cellY=5,facing='left'},paint={},reloaded=0} + function g.overworld:replaceBlock(x,y,n)self.paint[x..','..y]=n end + function g.overworld:reloadMap(id)assert(id=='ROOM');self.reloaded=self.reloaded+1;Native.visibility({game=g,save=g.save});Native.tiles({game=g,save=g.save,overworld=self})end + return g +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local function choose(g,label) + local menu=g.stack:top() + for i,row in ipairs(menu.items)do if row.label==label then menu.index=i;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0) + g.input={wasPressed=function()return false end};return end end + error('missing menu choice '..label) +end +local function dismiss(g) + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local before=d.map_scripts.decorations + local r=load(d,[[ + local p=mod.content["decorations"] + assert(p:supports()) + local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) + p:patch('deco:2',{sprite=88}) + p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) + ]],mode) + local label=version..' API '..mode[1]..' '..mode[2] + T.eq(#r.errors,0,label..' actual Loader accepts native DECO registry') + T.eq(d.map_scripts.decorations[2].sprite,88,label..' patch reaches actual native table') + T.eq(d.map_scripts.decorations[200].flag,900,label..' new byte reaches actual native table') + local g=game(d);T.eq(Native.state(d,g.save).bed,2,label..' initializes cartridge default once') + T.check(Native.give(d,g.save,200),label..' give owns authored record through actual event') + T.eq(g.save.flags[Flags.eventFlag(900)],true,label..' ownership is the authored flag') + T.check(Native.choose(d,g.save,200),label..' owned authored placement succeeds') + run(g,'CALLBACK') + T.eq(g.overworld.paint['3,2'],77,label..' actual VM callback paints authored block') + T.eq(g.overworld.paint['0,2'],88,label..' actual VM callback paints patched bed') + T.eq(g.save.flags[Flags.eventFlag(1857)],true,label..' empty console object is hidden') + T.eq(g.save.flags[Flags.eventFlag(716)],true,label..' placed poster enables native event') + finish(r) + local clean=load(d) + T.eq(d.map_scripts.decorations,before,label..' disabling restores pristine table identity') + T.eq(d.map_scripts.decorations[200],nil,label..' disabling removes owned addition') + finish(clean) + end + local d=fixture();local g=game(d) + T.eq(Native.choose(d,g.save,3),false,version..' unowned placement fails') + T.eq(Native.give(d,g.save,0),false,version..' CANCEL cannot be awarded') + T.eq(Native.give(d,g.save,1),false,version..' PUT header cannot be awarded') + T.check(Native.giveFlag(d,g.save,1),version..' actual gift ordinal awards real DECO byte') + T.eq(Native.owns(d,g.save,3),true,version..' gift ownership reaches event bit') + T.eq(Native.name(d,5),'RENAMED DOLL',version..' imported species identity follows actual name') + d.map_scripts.decorations[5].name='AUTHORED';T.eq(Native.name(d,5),'AUTHORED DOLL',version..' authored name wins old imported source') + for id=2,10 do Native.give(d,g.save,id)end + local state=Native.state(d,g.save) + state.leftOrnament=5;state.rightOrnament=5 + T.eq(Native.choose(d,g.save,5,'left'),false,version..' already placed doll is not a change') + T.eq(state.rightOrnament,5,version..' already placed doll does not mutate opposite side') + state.leftOrnament=0 + T.eq(Native.choose(d,g.save,5,'left'),true,version..' owned doll moves sides') + T.eq(state.rightOrnament,0,version..' real move clears opposite duplicate') + Native.choose(d,g.save,6);Native.choose(d,g.save,8);Native.choose(d,g.save,10) + local callback=run(g,'CALLBACK') + T.eq(g.save.gen2VarSprites[0],20,version..' console writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[1],18,version..' left doll writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[3],26,version..' big doll writes actual native variable sprite') + T.eq(g.overworld.paint['0,2'],0,version..' zero is valid block for nonzero placed DECO') + T.eq(g.overworld.paint['0,1'],0,version..' carpet offsets wrap at byte boundary') + T.eq(g.overworld.paint['1,1'],1,version..' carpet second offset wraps at byte boundary') + local desc=run(g,'DESC') + T.eq(desc.ctx.g2Var,72,version..' native describedecoration jumps to imported ornament script') + T.eq(g.stringBuffers[3],'AUTHORED DOLL',version..' native description fills real buffer three') + -- Real menus, native VM, TextBox and StateStack; only render/audio are harness IO. + g=game(fixture());Native.give(g.data,g.save,3);Native.give(g.data,g.save,5) + local pc=run(g,'PC') + T.check(pc:isRunning(),version..' native house-PC script waits for menu closure') + T.eq(pc.ctx.g2Var,nil,version..' native result is not guessed before selection') + choose(g,'DECORATION');choose(g,'BED');choose(g,'OTHER BED') + T.eq(Native.state(g.data,g.save).bed,2,version..' selection waits for original feedback box') + T.eq(g.stringBuffers[3],'SYNTHETIC BED',version..' replacement feedback buffers old name') + T.eq(g.stringBuffers[4],'OTHER BED',version..' replacement feedback buffers new name') + dismiss(g) + T.eq(g.save.decorations.bed,3,version..' acknowledging feedback commits native placed byte') + T.eq(g.overworld.reloaded,0,version..' furniture remains unchanged before PC exit') + choose(g,'ORNAMENT');choose(g,'RENAMED DOLL') + local prompt=g.stack:top();prompt.done=true;prompt:update(0) + choose(g,'RIGHT SIDE');dismiss(g) + T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') + choose(g,'EXIT') + T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') + choose(g,'LOG OFF') + T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') + T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') + T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') + T.eq(g.overworld.player.cellX,4,version..' NONE reload retains current cell rather than zero coordinates') + T.eq(g.overworld.paint['0,2'],44,version..' reload callback uses final selected bed') + T.eq(g.save.gen2VarSprites[2],18,version..' reload callback uses selected right doll') + g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} + g.overworld.player.facing='down';run(g,'FACE') + T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') + g=game(fixture());pc=run(g,'PC');g.input={wasPressed=function(_,key)return key=='b'end};g.stack:update(0) + T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') + T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') +end +V.set('crystal') +for _,code in ipairs({ + "p:patch('deco:2',{sprite=256})", "p:patch('deco:2',{flag=65535})", "p:patch('deco:2',{type=0})", + "p:patch('deco:2',{action='GUESS'})", "p:patch('deco:2',{sprite=0/0})", "p:patch('deco:2',{flag=math.huge})", + "p:patch('deco:02',{sprite=3})", "p:patch('deco:256',{sprite=3})", "p:patch('deco:2',{unknown=true})", + "p:patch('deco:2',{sprite=44});error('entry failed')", +})do + local d=fixture();local r=load(d,'local p=mod.content["decorations"];'..code) + T.check(#r.errors>0,'invalid or failed decoration contribution is rejected') + T.eq(d.map_scripts.decorations[2].sprite,0,'failed entry rolls back all native decoration writes') + finish(r) +end +do + local d=fixture();local original=d.map_scripts.decorations + original['2']=Merge.deepCopy(original[2]) + local r=load(d,[[local p=mod.content["decorations"];p:remove('deco:2');p:patch('deco:3',{sprite=66}); + p:register('deco:200',{type=1,name='EXTERNAL',flag=901,action='SET_UP_PLANT',sprite=77})]]) + T.eq(d.map_scripts.decorations[2],nil,'remove clears numeric DECO alias') + T.eq(d.map_scripts.decorations['2'],nil,'remove clears string DECO alias') + d.map_scripts.decorations[200].sprite=99 + finish(r);local clean=load(d) + T.eq(d.map_scripts.decorations[2].sprite,0,'reload restores removed native numeric record') + T.eq(d.map_scripts.decorations['2'].sprite,0,'reload restores removed native string alias') + T.eq(d.map_scripts.decorations[3].sprite,44,'reload retires an unchanged owned patch') + T.eq(d.map_scripts.decorations[200].sprite,99,'reload preserves externally edited authored record independently') + finish(clean) +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local d=fixture();if version=='prism'or version=='polishedcrystal'then d.map_scripts.decorationInfo.complete=false end + local before=d.map_scripts.decorations + local r=load(d,[[local p=mod.content["decorations"];assert(not p:supports());local v,why=p:patch('deco:2',{sprite=99});assert(v==nil and why)]]) + T.eq(#r.errors,0,version..' unsupported native decoration target declines explicitly') + T.eq(d.map_scripts.decorations,before,version..' unsupported generation does not write native namespace') + finish(r) +end +V.set(old);T.finish('native Gen2 decoration content, room callbacks and house PC') diff --git a/tests/engine/gen2_decoration_facade.lua b/tests/engine/gen2_decoration_facade.lua new file mode 100644 index 000000000..108caedad --- /dev/null +++ b/tests/engine/gen2_decoration_facade.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local D=require('src.core.gen2.Decorations') +local N=require('src.world.Gen2Decorations') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local F=require('src.script.Gen2Flags') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='BACK',flag=0,sprite=0}, + [40]={type=1,name='STORE',flag=0,action='PUT_AWAY_BED',sprite=0}, + [41]={type=2,name='ONE',flag=620,action='SET_UP_BED',sprite=0}, + [42]={type=2,name='TWO',flag=621,action='SET_UP_BED',sprite=22}, + [80]={type=1,name='STORE DOLL',flag=0,action='PUT_AWAY_DOLL',sprite=0}, + [81]={type=5,name='MODEL',flag=622,action='SET_UP_DOLL',sprite=31}, + [90]={type=1,name='RUG',flag=623,action='SET_UP_CARPET',sprite=255}, + [91]={type=1,name='SCREEN',flag=624,action='SET_UP_CONSOLE',sprite=33}, + [92]={type=4,name='MODEL',flag=625,action='SET_UP_POSTER',sprite=34}, + [93]={type=6,name='MODEL',flag=626,action='SET_UP_BIG_DOLL',sprite=35}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=41,poster=92}, + giftsComplete=true,gifts={42,81},species={[81]='SPECIES_001',[92]='SPECIES_001',[93]='SPECIES_001'}, + sourceNames={[81]='MODEL',[92]='MODEL',[93]='MODEL'}}, + decorationPrograms={texts={setup='SET',put='PUT',empty='EMPTY',same='SAME',replace='REPLACE',noChoice='NONE'}}, + scripts={ROOM={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'end'}}}} + d.pokemon.SPECIES_001={name='LIVE'} + d.text={SET='A:{RAM:wStringBuffer3}{DONE}',PUT='B:{RAM:wStringBuffer3}{PROMPT}', + EMPTY='C',SAME='D',REPLACE='E:{RAM:wStringBuffer3}\fF:{RAM:wStringBuffer4}{DONE}',NONE='G'} + return d +end +local function load(d,code) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/test/manifest.json']=Json.encode({id='test',name='Synthetic',version='1.0.0',api=2,entry='main.lua',affects_link=false}) + files['mods/test/main.lua']='local mod=...;mod.exports.deco=require("src.core.gen2.Decorations");'..code + paths={'mods/test'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function done(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local pristine=d.map_scripts.decorations + local r=load(d,[[ + assert(mod.exports.deco.ATTRIBUTES[41].sprite==0) + mod.content.decorations:patch('deco:42',{name='PATCHED',sprite=72}) + mod.content.decorations:register('deco:201',{type=1,name='AUTHORED',flag=900,action='SET_UP_BED',sprite=73}) + mod.content.decorations:remove('deco:41') + ]]) + T.eq(#r.errors,0,version..' real Loader imports public contract before entry executes') + T.eq(D,require('src.compat.gen2.Decorations'),version..' legacy module identity is the adapter') + T.check(D~=N,version..' incompatible signature is not a native alias') + T.eq(D.attributes(41),nil,version..' removed record never resurrects from retail fallback') + T.eq(D.ATTRIBUTES[41],nil,version..' current catalogue agrees with registry removal') + T.eq(D.attributes('42'),d.map_scripts.decorations[42],version..' string byte reads the one actual native record') + T.eq(D.name(42),'PATCHED BED',version..' merged name reaches public function') + T.eq(D.name('81'),'LIVE DOLL',version..' string identity still resolves imported species') + T.eq(D.name(81,function(base)return base=='MODEL'and 'CUSTOM'end),'CUSTOM DOLL',version..' legacy resolver is honored') + T.eq(D.name(92),'LIVE POSTER',version..' poster grammar') + T.eq(D.name(93),'BIG LIVE',version..' large doll grammar') + T.eq(D.name(999),'',version..' invalid byte has no invented name') + local save={flags={}};local state=D.state(save) + T.eq(state,save.decorations,version..' public state is the actual native placed-byte table') + T.same(state,{bed=41,poster=92},version..' defaults come from ROM despite non-retail identities') + T.same(D.state({decorations={}}),{},version..' explicitly empty native placement stays empty') + local events=D.events(save) + T.eq(D.owns(events,201),false,version..' new decoration starts unowned') + T.check(D.give(events,201),version..' numeric public event protocol awards authored record') + T.eq(save.flags[F.eventFlag(900)],true,version..' award reaches canonical native save flag') + T.eq(N.owns(d,save,201),true,version..' native ownership sees legacy award') + T.eq(D.give(events,0),false,version..' cancel cannot award flag zero') + T.eq(D.give(events,40),false,version..' put-away header cannot award flag zero') + T.eq(save.flags[F.eventFlag(0)],nil,version..' rejected award does not mutate map buffer') + T.eq(D.idForFlag(0),42,version..' gift ordinal follows native non-retail mapping') + T.check(D.giveFlag(events,1),version..' native ordinal awards doll') + T.eq(D.idForFlag(-1),nil,version..' invalid ordinal does not guess') + T.check(D.give(save,'42'),version..' direct native-save argument also grants ownership') + T.eq(N.owns(d,save,42),true,version..' direct award reaches actual native ownership') + local category=D.ownedCategories(events)[1] + T.same(category.members,{42,201},version..' categories contain actual merged IDs without removed member') + T.same(D.rows(events,category),{42,201,40,0},version..' owned members then actual header then cancel') + T.same(D.rows(events,{id=40,members={2}}),{42,201,40,0},version..' legacy plain category shape resolves native members') + T.eq(D.idFor(201),'deco:201',version..' canonical registry identity') + T.eq(D.NOTHING_TO_CHOOSE,'G',version..' feedback derives from actual ROM text ID') + local seen={};local owner={} + local count=D.registerInto({register=function(_,id,row,who)seen[id]=row;T.eq(who,owner,version..' seed keeps ownership')end},nil,owner) + T.eq(count,10,version..' seed uses current rows including additions and removals') + seen['deco:42'].name='DETACHED' + T.eq(D.name(42),'PATCHED BED',version..' seeding another registry cannot mutate native catalogue') + local changed,pages=D.apply(state,'42') + T.eq(changed,true,version..' public apply replaces placed native byte') + T.eq(state.bed,42,version..' numeric string never writes a string into SRAM state') + T.same(pages,{'E:','F:PATCHED BED'},version..' replacement uses both actual pages; removed old name is empty') + changed,pages=D.apply(state,42) + T.eq(changed,false,version..' same placement is not a change') + T.same(pages,{'D'},version..' same-placement feedback uses ROM role') + changed,pages=D.apply(state,40) + T.check(changed,version..' put-away changes filled slot') + T.same(pages,{'B:PATCHED BED'},version..' put-away names old item and removes end marker') + changed,pages=D.apply(state,40) + T.eq(changed,false,version..' empty put-away is not a change') + T.same(pages,{'C'},version..' empty feedback') + changed,pages=D.apply(state,201) + T.check(changed,version..' setup in empty slot') + T.same(pages,{'A:AUTHORED'},version..' setup uses original role with new item in buffer3') + changed,pages=D.apply(state,81,nil) + T.eq(changed,false,version..' cancelled side prompt has no placement') + T.same(pages,{},version..' cancelled side prompt has no feedback') + state.rightOrnament=81 + changed,pages=D.apply(state,81,'left') + T.check(changed,version..' chosen doll side changes') + T.eq(state.rightOrnament,81,version..' apply keeps separate legacy clearOtherSide contract') + D.clearOtherSide(state,81,'left');T.eq(state.rightOrnament,0,version..' explicit clear removes opposite duplicate') + state.console=91;state.bigDoll=93;state.carpet=90 + local paint={};local game={data=d,save=save};local world={replaceBlock=function(_,x,y,block)paint[x..','..y]=block end} + local runner=Runner.new(game,world);runner:run(assert(VM.compile(d,'ROOM'))) + for _,row in ipairs(D.tiles(state))do T.eq(paint[row.x..','..row.y],row.block,version..' actual VM tiles agree with public layout')end + T.eq(paint['0,2'],73,version..' legacy placement is visible to actual room callback') + T.eq(paint['0,1'],0,version..' wrapped carpet block remains a valid zero byte') + for _,row in ipairs(D.visibility(state))do + T.eq(save.flags[F.eventFlag(row.flag)],row.hidden,version..' actual room visibility matches public plan') + if not row.hidden then T.eq(save.gen2VarSprites[row.sprite],row.byte,version..' public sprite byte matches native callback')end + end + T.eq(D.posterVisible(state),true,version..' poster visibility agrees with native event') + T.eq(save.flags[F.eventFlag(716)],true,version..' native poster callback reads same placed state') + d.text.SET='H:{RAM:wStringBuffer3}{DONE}' + state.bed=0;local _,fresh=D.apply(state,42) + T.same(fresh,{'H:PATCHED BED'},version..' text edits after binding are live') + d._portableScriptText={SET={}};state.bed=0 + local _,masked=D.apply(state,42) + T.same(masked,{},version..' explicitly removed script text does not fall back') + done(r) + T.eq(D.attributes(42),nil,version..' teardown retires data binding') + T.eq(events:set(900,false),false,version..' retained numeric adapter cannot mutate after unload') + T.eq(save.flags[F.eventFlag(900)],true,version..' stale adapter leaves prior save intact') + local nextData=fixture();nextData.map_scripts.decorationInfo.defaults={bed=42} + local clean=load(nextData) + T.same(D.state({}),{bed=42},version..' next native dataset supplies its own defaults') + T.eq(D.attributes(201),nil,version..' old mod addition does not leak into new dataset') + T.eq(events:set(900,false),false,version..' old adapter cannot become live after rebinding') + V.set('red');V.set(version) + T.eq(D.attributes(42),nil,version..' edition switch and return cannot revive stale binding') + done(clean) + local reset=load(d) + T.eq(d.map_scripts.decorations,pristine,version..' reload preserves real registry rollback identity') + T.eq(D.attributes(41),pristine[41],version..' removed record returns only when native registry restores it') + done(reset) +end +V.set('crystal') +local unsupported={map_scripts={decorations={[2]={name='NOT IMPORTED'}}}} +T.eq(D.useRegistry(unsupported),false,'partial cache does not enable legacy native facade') +T.eq(D.state({}),nil,'missing native defaults do not invent retail bedroom') +T.same(D.CATEGORIES,{},'unbound catalogue has no copied retail categories') +V.set(original) +T.finish('native Gen2 decoration facade') diff --git a/tests/engine/gen2_decoration_import.lua b/tests/engine/gen2_decoration_import.lua new file mode 100644 index 000000000..fe2409fb1 --- /dev/null +++ b/tests/engine/gen2_decoration_import.lua @@ -0,0 +1,113 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Native=require('src.world.Gen2Decorations') +local S=require('src.save_convert.Gen2Save');local Json=require('src.link.Json') +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={DecorationAttributes={1,0x4000},DecorationNames={1,0x401e}, + PokemonNames={1,0x4300},InitDecorations={1,0x4100},DecorationIDs={1,0x4200}, + wDecoBed={1,0xd000},wDecoPoster={1,0xd003},DecorationDesc_PosterPointers={1,0x4400}, + ['DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript']={1,0x4500}, + ['DecorationDesc_GiantOrnament.BigDollScript']={1,0x4501},DecorationDesc_NullPoster={1,0x4502}, + PutAwayTheDecoText={1,0x4600},NothingToPutAwayText={1,0x4602},SetUpTheDecoText={1,0x4604}} + -- Authored test cartridge, not a retail attribute catalogue. + put(0x4000,string.char(1,0,0,0,0,0,1,1,2,0,0,0,2,2,1,0x34,0x12,0, + 1,1,8,0,0,0,4,1,7,0x35,0x12,77)) + put(0x401e,string.char(128,0x50,129,0x50,130,0x50)) + put(0x4100,string.char(0x3e,2,0xea,0,0xd0,0x3e,4,0xea,3,0xd0,0xc9)) + put(0x4200,string.char(2,4,255));put(0x4300,string.char(131,0x50)) + put(0x4400,string.char(4,3,0x45,255));put(0x4500,string.char(0x91,0x91,0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols}) + function e:readSourceTable(k)assert(k=='charmap');return{['128']='X',['129']='Y',['130']='Z',['131']='Q'}end + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local rows,info=e:gen2Decorations() + T.check(info.complete,version..' native attribute boundary defines valid table') + T.eq(info.rowsRead,5,version..' reader imports exact authored row count') + T.eq(rows[0].name,'X',version..' DECO zero remains in actual native table') + T.eq(rows[2].name,'Z',version..' names use actual character decoder') + T.eq(rows[2].flag,0x1234,version..' event identity is native little-endian word') + T.eq(rows[2].sprite,0,version..' block zero survives real import') + T.eq(rows[4].name,'Q',version..' species name comes from cartridge indexed table') + T.eq(info.species[4],'SPECIES_001',version..' raw species identity has no National Dex inference') + T.eq(info.defaults.bed,2,version..' bed default comes from native init operands') + T.eq(info.defaults.poster,4,version..' poster default comes from native init operands') + T.eq(#info.gifts,2,version..' gift ordinal table is ROM-derived') + T.eq(info.gifts[2],4,version..' gift ordinal retains actual DECO byte') + local q={scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0} + local roles=e:gen2QueueDecorationPrograms(q) + T.eq(#q.queue,4,version..' actual queue includes three description roles and poster') + T.check(roles.texts.setup:match('^TEXT_S'),version..' native feedback enters shared text import') + T.check(e._gen2ScriptTexts[roles.texts.setup]~=nil,version..' feedback text is registered for actual extractor') + e:gen2QueueDecorationPrograms(q);T.eq(#q.queue,4,version..' duplicate queue calls are idempotent') + local n=1;while n<=#q.queue do local job=q.queue[n];e:gen2DecodeScript(job[1],job[2],job[3],q);n=n+1 end + T.eq(q.desyncs,0,version..' actual bytecode decoder accepts queued description programs') + for _,edit in ipairs({ + function(s)s.DecorationNames[2]=0x401f end, + function(s)s.DecorationNames[1]=2 end, + function(s)s.DecorationAttributes=nil end, + function(s,put)put(0x4000,string.char(0))end, + function(s,put)put(0x4002,string.char(15))end, + function(s,put)put(0x4003,string.char(255,255))end, + function(s,put)put(0x401e,string.rep(string.char(128),129))end, + function(s)s.PokemonNames=nil end, + })do + local bad,why=fixture(version,edit):gen2Decorations() + T.eq(why.complete,false,version..' malformed attribute/name input cannot become ready') + T.eq(next(bad),nil,version..' malformed attribute/name input publishes no prefix') + end + local _,missing=fixture(version,function(s)s.InitDecorations=nil end):gen2Decorations() + T.eq(missing.complete,true,version..' missing init does not erase valid attribute table') + T.eq(missing.defaultsComplete,false,version..' missing init cannot invent retail defaults') + local _,wrong=fixture(version,function(s)s.wDecoBed[2]=0xd100 end):gen2Decorations() + T.eq(wrong.defaultsComplete,false,version..' init stores must target actual WRAM symbols') + local _,badGifts=fixture(version,function(_,put)put(0x4200,string.rep(string.char(2),256))end):gen2Decorations() + T.eq(badGifts.giftsComplete,false,version..' unterminated gifts cannot be claimed complete') + T.eq(badGifts.gifts,nil,version..' malformed gifts publish no prefix') + q={scripts={},queue={}} + T.eq(fixture(version,function(s)s.DecorationDesc_NullPoster=nil end):gen2QueueDecorationPrograms(q),nil,version..' missing description role declines whole binding') + T.eq(#q.queue,0,version..' missing role is rejected before queue mutation') + q={scripts={},queue={}} + T.eq(fixture(version,function(_,put)put(0x4400,string.char(4,0,0))end):gen2QueueDecorationPrograms(q),nil,version..' invalid poster pointer declines binding') + T.eq(#q.queue,0,version..' invalid poster table leaves queue untouched') + -- Resolve the precise native NONE pair, without accepting an unknown map. + e=fixture(version);function e:gen2MapIndex()return{}end;function e:gen2Trainers()return{}end + local p={scripts={TEST={{'warp',0,0,0,0},{'warpfacing',2,0,0,0,0},{'warp',0,1,0,0},{'blackoutmod',0,0}}}} + e:gen2ResolveScriptMapIds({}, {},p) + T.eq(p.scripts.TEST[1][2],'NONE',version..' only actual NONE warp becomes reload') + T.eq(p.scripts.TEST[2][3],'NONE',version..' warpfacing uses its own map-pair slot') + T.eq(p.scripts.TEST[3][2],0,version..' unknown map is not turned into a reload') + T.eq(p.scripts.TEST[4][2],0,version..' non-warp zero pair is not reinterpreted') + -- Independent manifest WRAM addresses verify the save codec's physical order. + local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(Json.decode(f:read('*a')));f:close() + S.setLayout(version);S.setCharmap({['128']='A'}) + local d=T.fixtures.fresh();local save={player={name='A'},decorations={bed=1,carpet=2,plant=3,poster=4, + console=5,leftOrnament=6,rightOrnament=7,bigDoll=255}} + local raw=S.encode(save,d) + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + for _,key in ipairs(Native.slots)do + local symbol='wDeco'..key:sub(1,1):upper()..key:sub(2) + local address=manifest.symbols[symbol][2];local offset=0x2009+address-anchor + T.eq(raw:byte(offset+1),save.decorations[key],version..' export matches independent '..symbol..' address') + end + local decoded=S.decode(raw,d) + for _,key in ipairs(Native.slots)do T.eq(decoded.decorations[key],save.decorations[key],version..' import preserves exact '..key..' byte')end + local round=S.encode(decoded,d,raw);local warnings=S.decode(round,d).warnings + local checksumOk=true;for _,w in ipairs(warnings)do if w:match('checksum')then checksumOk=false end end + T.check(checksumOk,version..' decoration export retains valid native checksum') + local saved=decoded.decorations;decoded.decorations=nil + local untouched=S.encode(decoded,d,raw) + for i=0,7 do T.eq(untouched:byte(S.OFFSETS.decorations+i+1),raw:byte(S.OFFSETS.decorations+i+1),version..' absent legacy state preserves imported decoration template')end + decoded.decorations=saved;decoded.decorations.bed=256 + T.check(not pcall(S.encode,decoded,d,raw),version..' invalid placed byte cannot wrap and corrupt native export') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);T.eq(fixture(version):gen2Decorations(),nil,version..' unreviewed layout imports no stock catalogue') + S.setLayout(version);T.eq(S.OFFSETS.decorations,nil,version..' unknown save layout has no guessed decoration offset') +end +V.set(old);S.setLayout(old);T.finish('native Gen2 decoration ROM producers and eight-byte SRAM codec') diff --git a/tests/engine/gen2_landmark_content.lua b/tests/engine/gen2_landmark_content.lua new file mode 100644 index 000000000..d7476c38d --- /dev/null +++ b/tests/engine/gen2_landmark_content.lua @@ -0,0 +1,232 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Native=require('src.world.Gen2Landmarks');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Gear=require('src.ui.PokegearMenu');local TownMap=require('src.ui.TownMap') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local function extractor(version,order) + local records={} + for i=0,95 do records[#records+1]=string.char(i%160,(i+20)%144,0,0x50)end + local raw=string.rep('\0',0x4000)..table.concat(records) + raw=raw..string.rep('\0',0x5000-#raw)..string.char(0x80,0x80,0x50)..string.rep('\0',0x2ffd) + local e=E.new(raw,version,{symbols={Landmarks={1,0x4000}},landmarkOrder=order}) + function e:readSourceTable(key)assert(key=='charmap');return{['128']='A'}end -- authored source-table IO + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local order=Native.order(version);local count=version=='crystal' and 96 or 95 + T.eq(#order,count,version..' exact native landmark count includes the final ship') + T.eq(order[30],version=='crystal' and 'LANDMARK_BATTLE_TOWER' or 'LANDMARK_ROUTE_40',version..' Duellturm insertion is edition-specific') + T.eq(order[#order],'LANDMARK_FAST_SHIP',version..' final identity remains the native ship') + local e=extractor(version);local rows=e:gen2Landmarks() + T.eq(rows[count-1].id,'LANDMARK_FAST_SHIP',version..' real ROM reader reaches final native row') + T.eq(rows[count-1].index,count-1,version..' importer records actual map-header byte') + T.eq(rows[count-1].name,'AA',version..' actual native text decoder supplies display name') + T.eq(rows[count-1].x,(count-1)%160,version..' raw OAM x coordinate is not converted to a tile cell') + T.eq(rows[count-1].y,(count+19)%144,version..' raw OAM y coordinate is unchanged') + T.eq(rows[count],nil,version..' stock import cannot read beyond its actual landmark count') + order[1]='LEAK';T.eq(Native.order(version)[1],'LANDMARK_SPECIAL',version..' identity views are detached') + local town=e:gen2TownMap() + T.eq(town.landmarks[count-1].id,'LANDMARK_FAST_SHIP',version..' missing picture does not discard valid script names') + T.eq(town.landmarkOrder[count],'LANDMARK_FAST_SHIP',version..' actual town-map output carries identity order') + T.eq(town.johto,nil,version..' missing picture cannot invent fallback map art') + local marker=markerFor(version) + T.check(marker:find('gen2%-landmark%-identities%-v1:')~=nil,version..' new native identity cache revision is present') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-landmark%-identities%-v1:','')) + T.eq(Importer.readyReport(version).ok,false,version..' old truncated identity-less cache requires reimport') +end +for _,version in ipairs({'prism','polishedcrystal','red'})do + T.eq(#Native.order(version),0,version..' unreviewed layout cannot acquire stock landmark identities') + T.eq(Native.order(version,{landmarkOrder={'AUTHORED_ZERO','AUTHORED_ONE'}})[2],'AUTHORED_ONE',version..' explicit manifest identities remain authoritative') +end +T.eq(extractor('crystal',{'DUPLICATE','DUPLICATE'}):gen2Landmarks(),nil,'invalid declared identities cannot silently fall back to retail count') +T.eq(extractor('crystal',{'ONLY'}):gen2Landmarks()[1],nil,'explicit custom count bounds actual ROM reads') +local function fixture(version) + local d=T.fixtures.fresh();local pallet=version=='crystal' and 47 or 46 + d.field.townMap={johto='qa-johto-'..version,kanto='qa-kanto-'..version,playerIcon='qa-player-'..version, + landmarkOrder=Native.order(version),landmarks={ + [0]={id='LANDMARK_SPECIAL',index=0,name='NOWHERE',x=0,y=0}, + [1]={id='LANDMARK_NEW_BARK_TOWN',index=1,name='LOCAL HOME',x=20,y=32}, + [2]={id='LANDMARK_ROUTE_29',index=2,name='LOCAL ROAD',x=40,y=48}, + [pallet]={id='LANDMARK_PALLET_TOWN',index=pallet,name='LOCAL PALLET',x=72,y=80}, + [pallet-1]={id='LANDMARK_SILVER_CAVE',index=pallet-1,name='LOCAL CAVE',x=88,y=96}}} + d.maps.HOME={id='HOME',landmark=1};d.maps.CAVE={id='CAVE',landmark=pallet-1} + d.maps.PALLET_TOWN={id='PALLET_TOWN',landmark=pallet};d.maps.EXTRA={id='EXTRA',landmark=200} + d.map_scripts={scripts={HERE={{'getcurlandmarkname',0},{'end'}},THERE={{'getlandmarkname',200,0},{'end'}}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if source then + files['mods/landmarks/manifest.json']=Json.encode({id='landmarks',name='Landmarks',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/landmarks/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths={'mods/landmarks'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,map) + return {data=d,save={inventory={}},overworld={map={id=map,def=d.maps[map]}}, + stack={pop=function()end},input={wasPressed=function()return false end}} +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local oldImage,oldDraw=love.graphics.newImage,love.graphics.draw +local paints={} +love.graphics.newImage=function(path)return{path=path,getDimensions=function()return 160,144 end}end +love.graphics.draw=function(image,x,y)paints[#paints+1]={path=image.path,x=x,y=y}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + do + local d=fixture(version);local before=d.field.townMap.landmarks;local clean=load(d) + T.eq(#clean.errors,0,version..' no-mod native boot stays valid') + T.eq(d.field.townMap.landmarks,before,version..' no-mod load leaves native target identity untouched') + finish(clean) + end + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content["landmarks"] + assert(p:supports()) + assert(p:get('LANDMARK_ROUTE_29').name=='LOCAL ROAD') + local v=p:get('LANDMARK_ROUTE_29');v.name='LEAK' + for _,row in p:each()do row.x=999 end + assert(p:get('LANDMARK_ROUTE_29').x==40) + p:patch('LANDMARK_NEW_BARK_TOWN',{name='AUTHORED\nHOME',x=52,y=68}) + p:register('LANDMARK_EXTRA',{name='AUTHORED EXTRA',x=100,y=104,index=200}) + p:remove('LANDMARK_ROUTE_29') + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public landmark records load through real mod API') + T.eq(d.field.townMap.landmarks[1].name,'AUTHORED\nHOME',label..' native numeric row receives authored text') + T.eq(d.field.townMap.landmarks[2],nil,label..' removed row cannot survive by fallback') + T.eq(d.field.townMap.landmarks[200].landmarkId,'LANDMARK_EXTRA',label..' new identity binds actual byte200') + T.eq(before[1].name,'LOCAL HOME',label..' source native row is not mutated') + local g=game(d,'HOME');run(g,'HERE') + T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED HOME',label..' actual current-landmark VM reads authored native name') + run(g,'THERE');T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED EXTRA',label..' actual named-landmark VM reads registered byte') + local screen=TownMap.new(g) + T.eq(screen.mode,'gen2',label..' real map controller consumes native table') + T.eq(screen.byMap.HOME.px,48,label..' actual map marker applies native OAM origin once') + T.eq(screen.byMap.HOME.py,56,label..' native marker y keeps raw coordinate units') + T.eq(screen.byMap.PALLET_TOWN.region,'kanto',label..' first actual Kanto byte selects Kanto') + T.eq(screen.byMap.CAVE.region,'johto',label..' preceding cave remains on Johto') + T.eq(screen.byMap.EXTRA.name,'AUTHORED EXTRA',label..' native map controller consumes added marker') + local text + local gear=setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end, + text=function(_,value)text=value end},{__index=Gear}) + paints={};gear:drawMap() + T.eq(text,'AUTHORED HOME',label..' actual Pokegear label reads the same authored name') + T.eq(paints[1].path,d.field.townMap.johto,label..' actual Pokegear paints the correct Johto image') + T.eq(paints[2].x,44,label..' actual Pokegear player sprite consumes authored OAM x') + T.eq(paints[2].y,52,label..' actual Pokegear player sprite consumes authored OAM y') + g.overworld.map={id='PALLET_TOWN',def=d.maps.PALLET_TOWN};paints={};gear:drawMap() + T.eq(paints[1].path,d.field.townMap.kanto,label..' Pokegear correctly paints Gold/Silver first Kanto town too') + local api=r.loader.exports.landmarks.api.content.landmarks + T.check(not pcall(function()api:patch('LANDMARK_EXTRA',{name='LATE'})end),label..' content freezes after load') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,label..' disabling restores original numeric table identity') + T.eq(d.field.townMap.landmarks[200],nil,label..' disabling removes the authored byte') + finish(clean) + end +end +V.set('crystal') +for _,source in ipairs({ + "p:register('BAD',{name='BAD',x=1,y=1,index=256})", + "p:register('BAD',{name='BAD',x=-1,y=1,index=200})", + "p:register('BAD',{name='BAD',x=0/0,y=1,index=200})", + "p:register('BAD',{name='BAD',x=1,y=math.huge,index=200})", + "p:register('BAD',{name='BAD',x=1,y=1,index=200,typo=true})", + "p:patch('LANDMARK_NEW_BARK_TOWN',{name=mod.DELETE})", + "local x={};x.name=x;p:register('BAD',x)", + "error('entry failed')", +})do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,"local p=mod.content.landmarks;p:patch('LANDMARK_NEW_BARK_TOWN',{name='FAIL'});"..source) + T.check(#r.errors>0,'invalid native record is attributed: '..source) + T.eq(d.field.townMap.landmarks,before,'failed entry rolls back prior valid landmark edits') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[local p=mod.content.landmarks + p:register('Z_NEW',{name='Z',x=10,y=20,index=200}) + p:register('A_NEW',{name='A',x=30,y=40,index=200}) + p:register('A_SHADOW',{name='SHADOW',x=99,y=99,index=1}) + ]]) + T.eq(#r.errors,0,'reference-compatible duplicate index claims are deterministic') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','native identity wins its own slot') + T.eq(d.field.townMap.landmarks[200].name,'A','lexically earlier newcomer wins other slot') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{index=201})]]) + T.eq(#r.errors,0,'reference record may move to an unused native index') + T.eq(d.field.townMap.landmarks[201].landmarkId,'LANDMARK_NEW_BARK_TOWN','moved identity reaches new native index') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','reference order remains fallback for unclaimed original slot') + finish(r) +end +do + local d=fixture('crystal');d.field.townMap.landmarks['2']=d.field.townMap.landmarks[2] + d.map_scripts.scripts.ZERO={{'getlandmarkname',0,0},{'end'}} + local before=d.field.townMap.landmarks;local r=load(d,[[local p=mod.content.landmarks + p:remove('LANDMARK_ROUTE_29');p:patch('LANDMARK_SPECIAL',{name='AUTHORED ZERO'}) + ]]) + T.eq(#r.errors,0,'numeric/string native aliases and byte zero remain supported') + T.eq(d.field.townMap.landmarks[2],nil,'removal drops numeric native slot') + T.eq(d.field.townMap.landmarks['2'],nil,'string fallback cannot resurrect removed landmark') + local g=game(d,'HOME');run(g,'ZERO') + T.eq(g.stringBuffers[3],'AUTHORED ZERO','actual native script names byte zero rather than treating it as absent') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,'unload restores original numeric/string alias layout') + T.eq(before['2'],before[2],'restored aliases retain their original shared record') + finish(clean) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_PALLET_TOWN',{index=48})]]) + T.eq(#r.errors,0,'moving the logical first Kanto landmark is a native byte edit') + T.eq(Native.kantoStart(d),48,'region boundary consumes authored landmark index metadata') + local g=game(d,'PALLET_TOWN');local screen=TownMap.new(g) + T.eq(screen.byMap.PALLET_TOWN.region,'johto','town map follows the same authored boundary') + paints={};setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end,text=function()end},{__index=Gear}):drawMap() + T.eq(paints[1].path,d.field.townMap.johto,'actual Pokegear background consumes authored boundary') + finish(r) +end +do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{name='OWN'}) + mod.content.landmarks:register('EXTRA',{name='EXTRA',x=100,y=100,index=200})]]) + local row=d.field.townMap.landmarks[1];row.name='EXTERNAL' + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks[1],row,'reload preserves externally edited native record identity') + T.eq(row.name,'EXTERNAL','reload preserves external text') + T.eq(d.field.townMap.landmarks[200],nil,'external edit does not retain unchanged owned addition') + T.eq(d.field.townMap.landmarks[2],before[2],'unrelated original record identity restores') + finish(clean) +end +do + local d=T.fixtures.fresh();d.field=nil + local r=load(d,[[mod.content.landmarks:register('NEW',{name='NEW',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,'indexed authored names can create a native script target without map art') + T.eq(d.field.townMap.landmarks[200].name,'NEW','new native target is readable') + finish(r);local clean=load(d) + T.eq(d.field,nil,'unload retires exclusively owned rows, town and field namespaces') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content.landmarks;assert(not p:supports());assert(p:get('LANDMARK_ROUTE_29')==nil) + p:register('NO_WRITE',{name='NO',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,version..' absent native target is reported through public handle') + T.eq(d.field.townMap.landmarks,before,version..' cannot mutate lookalike Gen2 town map') + finish(r) +end +love.graphics.newImage=oldImage;love.graphics.draw=oldDraw;V.set(original) +T.finish('native landmark identities, real map/gear/name consumers, reload ownership and edition boundaries') diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua new file mode 100644 index 000000000..5824fb69b --- /dev/null +++ b/tests/engine/gen2_mom_import.lua @@ -0,0 +1,104 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if name=='markerFor'then markerFor=value;break end;if not name then break end end +assert(markerFor) +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, + ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, + ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + PhoneContacts={1,0x4400},SpecialPhoneCallList={1,0x4418},NonTrainerCallerNames={1,0x4500}, + GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} + put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, + 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) + put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) + put(0x4200,string.char(0x91,0x91)) + put(0x440c,string.char(0,1));put(0x4502,string.char(0x10,0x45));put(0x4510,string.char(128,0x50)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + function e:readSourceTable(key)assert(key=='charmap');return{['128']='Q'}end + return e +end +local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) + T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + local phone=e:gen2PhoneContacts(nil) + T.eq(phone[1].contactNumber,1,version..' actual non-trainer contact identity survives localized name extraction') + T.eq(phone[1].class,0,version..' actual imported Mom contact retains native non-trainer class') + T.eq(#rows.random,2,version..' random count comes from its end symbol') + T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') + T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') + T.eq(rows.ladder[2].kind,2,version..' kind distinguishes real DECO byte from native item') + T.eq(rows.ladder[2].item,14,version..' row retains actual byte rather than a guessed species') + T.eq(rows.step,17,version..' random threshold is read from actual instructions, not a retail literal') + T.eq(#q.queue,2,version..' both original phone scripts enter native decode queue') + T.check(e._gen2ScriptTexts[rows.rewardTexts[1]]~=nil,version..' native reward prose reaches shared text importer') + e:gen2MomShopping(q);T.eq(#q.queue,2,version..' repeated import queues each script once') + for _,job in ipairs(q.queue)do e:gen2DecodeScript(job[1],job[2],job[3],q)end + T.eq(q.desyncs,0,version..' native queued scripts decode without desync') + T.check(markerFor(version):find('gen2%-mom%-shopping%-native%-v1:')~=nil,version..' new cache requires purchase data') + for _,edit in ipairs({ + function(s)s.MomItems_1=nil end, + function(s)s['MomItems_2.End']=nil end, + function(s)s['MomItems_1.End'][2]=0x4011 end, + function(s)s['MomItems_1.End'][1]=2 end, + function(s)s['MomItems_2.End'][2]=0x400f end, + function(s,put)put(0x4000,string.char(255,255,255))end, + function(s,put)put(0x4003,string.char(255,255,255))end, + function(s,put)put(0x4006,string.char(3))end, + function(s,put)put(0x4007,string.char(0))end, + function(s,put)put(0x4104,string.char(0))put(0x4107,string.char(0))put(0x410a,string.char(0))end, + function(s)s.hMoneyTemp[2]=0xff81 end, + function(s)s['CheckBalance_MomItem2.check_have_2300']=nil end, + function(s)s['Mom_GetScriptPointer.DollScript']=nil end, + function(s)s['Mom_GetScriptPointer.ItemScript'][2]=0x100 end, + })do + local bad=fixture(version,edit);local q=pool();local result=bad:gen2MomShopping(q) + T.eq(result.complete,false,version..' malformed or incomplete input cannot claim a native purchase binding') + T.eq(result.random,nil,version..' malformed binding publishes no partial retail table') + T.eq(#q.queue,0,version..' invalid binding does not partially mutate script queue') + end +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + local q=pool();T.eq(fixture(version):gen2MomShopping(q),nil,version..' unreviewed layout has no inferred retail shopping') + T.eq(#q.queue,0,version..' unrelated script queue stays unchanged') + T.check(not markerFor(version):find('gen2%-mom%-shopping%-native%-v1:'),version..' unrelated cache remains unchanged') +end +local Service=require('src.world.Gen2PhoneService');local Writer=require('src.import.LuaWriter') +local function extractor(version,palette,compact) + local raw=string.rep('\0',0x8000) + local function put(at,v)raw=raw:sub(1,at)..string.char(v)..raw:sub(at+2)end + put(0x4000,2);put(0x4001,0x40) + local header=compact and {1,2,0,0x50,1,1,palette}or{1,1,2,0,0x50,1,1,palette,0} + for i,v in ipairs(header)do put(0x4001+i,v)end + local layout=compact and {mapHeaderBytes=7,mapHeaderAttrAt=2,mapHeaderTilesetAt=0,mapHeaderEnvAt=1,mapHeaderLandmarkAt=4,mapHeaderMusicAt=5,mapHeaderPaletteAt=6,mapHeaderFishAt=-1,mapAttributesBank=2}or{} + return E.new(raw,version,{symbols={MapGroupPointers={1,0x4000},Native_MapAttributes={compact and 2 or 1,0x5000}},layout=layout}) +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for raw=0,255 do + local e=extractor(version,raw);local _,labels=e:gen2MapIndex();local header=assert(labels.Native) + T.eq(header.palette,raw%16,'native header retains the unchanged palette nibble') + T.eq(header.paletteBits,raw,'native header retains the complete real byte') + T.eq(header.phoneService,raw<16,'native header decodes the actual no-service nibble') + T.eq(Service.available({mapPalette=header.palette,mapPaletteBits=header.paletteBits,phoneService=header.phoneService}),raw<16,'runtime consumes the native imported no-service verdict') + end + for _,raw in ipairs({1,0x11,0xF4})do + local e=extractor(version,raw);local maps={NATIVE={id='NATIVE',label='Native',width=1,height=1,warps={}}};local encoded + -- Input table/file output are IO seams. Header lookup, map lowering and + -- Lua serialization are the actual importer, including false booleans. + function e:readSourceTable(name)return name=='maps'and maps or{}end + function e:write(name,value)if name=='maps'then encoded=Writer.encode(value)end end + e:extractMapsFromRom();local decoded=assert(loadstring(assert(encoded)))().NATIVE + T.eq(decoded.mapPalette,raw%16,'full map lowering preserves the palette rule') + T.eq(decoded.mapPaletteBits,raw,'full map lowering retains header bits') + T.eq(decoded.phoneService,raw<16,'serialization does not lose an explicit no-service false') + end +end +V.set(old);T.finish('native Mom table boundaries, threshold instruction operands, script queues and cache revision') diff --git a/tests/engine/gen2_mom_shopping.lua b/tests/engine/gen2_mom_shopping.lua new file mode 100644 index 000000000..a140cce25 --- /dev/null +++ b/tests/engine/gen2_mom_shopping.lua @@ -0,0 +1,201 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Mom=require('src.world.Gen2MomShopping');local Native=require('src.world.Gen2Decorations') +local Flags=require('src.script.Gen2Flags');local Runner=require('src.script.ScriptRunner') +local W=require('src.world.OverworldController');local Stack=require('src.core.StateStack') +local Map=require('src.world.Map');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local S=require('src.save_convert.Gen2Save') +local E=require('src.import.RomExtractorGen2');local J=require('src.link.Json') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device IO +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.afterBattle,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function data() + local d=T.fixtures.fresh() + d.items.ITEM_017={id='ITEM_017',index=17,name='SYNTHETIC A',pocket='ITEM'} + d.items.ITEM_018={id='ITEM_018',index=18,name='SYNTHETIC B',pocket='ITEM'} + d.map_scripts={momShopping={complete=true,step=17, + random={{trigger=0,cost=3,kind=1,item=17},{trigger=0,cost=5,kind=1,item=18}}, + ladder={{trigger=100,cost=20,kind=1,item=17},{trigger=200,cost=70,kind=2,item=14},{trigger=500,cost=30,kind=1,item=18}}, + programs={item='ITEM_PHONE',doll='DOLL_PHONE'},rewardTexts={[0]='REWARD0',[1]='REWARD1',[2]='REWARD2',[3]='REWARD3'}}, + phone={[3]={class=0,contactNumber=1,name='SYNTHETIC MOM'}}, + decorations={[14]={type=1,name='SYNTHETIC DOLL',action='SET_UP_DOLL',flag=900,sprite=18}}, + decorationInfo={complete=true},scripts={BANK={{'special',0,'BankOfMom'},{'end'}},ITEM_PHONE={{'setevent',901},{'end'}},DOLL_PHONE={{'setevent',902},{'end'}}}} + d.text={REWARD0='SYNTHETIC {PLAYER} TOTAL {NUM}',REWARD1='SYNTHETIC SOME {PLAYER} TOTAL {NUM}', + REWARD2='SYNTHETIC HALF {PLAYER} TOTAL {NUM}',REWARD3='SYNTHETIC ALL {PLAYER} TOTAL {NUM}'} + return d +end +local function save(balance) + return{flags={},money=0,g2MomMoney=balance or 0,inventory={},pcItems={},party={},player={name='QA'},pokedex={seen={},owned={}}} +end +local function buy(d,s,opts) + opts=opts or{};opts.phoneService=opts.phoneService~=false;return Mom.tryBuy(d,s,opts) +end +local function game(d,s) + local def={id='QA_MOM',generation=2,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + local ts={id='FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + d.maps.QA_MOM=def + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,facing='down'},scriptMoves={},pendingScripts={},npcs={}},{__index=W}) + local stack=setmetatable({states={w}},{__index=Stack}) -- world already entered in this fixture + local g={data=d,save=s,stack=stack,world=w,overworld=w,input={wasPressed=function()return false end}};w.runner=Runner.new(g,w) + debug.setupvalue(W.afterBattle,slot,g);return g,w +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for mode=0,3 do + for _,coin in ipairs({1,2})do + local s=save(100);s.money=10;s.g2MomSavingMode=mode + local quarters=({[0]=0,1,2,4})[mode];local award=Mom.reward(s,900*coin) + T.eq(s.g2MomMoney,100+225*coin*quarters,version..' native mode '..mode..' sends actual quarters to Mom') + T.eq(s.money,10+225*coin*(4-quarters),version..' wallet receives only its actual share') + T.eq(award.total,900*coin,version..' displayed total remains complete prize') + T.eq(award.mode,mode,version..' native text uses actual savings mode') + end + end + local s=save(999999);s.g2MomSaving=true + T.eq(Mom.reward(s,900).mode,0,version..' maxed savings disables skimming') + T.eq(s.money,900,version..' maxed savings sends all quarters to wallet') + s=save(999998);s.g2MomSaving=true;s.money=999998 + Mom.reward(s,900) + T.eq(s.g2MomMoney,999999,version..' near-cap Mom account saturates') + T.eq(s.money,999999,version..' wallet saturates separately') + s=save();s.g2MomSaving=true;s.flags.ENGINE_MOM_SAVING_MONEY=false + T.eq(Mom.mode(s),0,version..' explicit native flag overrides legacy saving boolean') + s.g2MomSavingMode=2;T.eq(Mom.mode(s),2,version..' HALF bit survives an unset SOME bit') + s.flags.ENGINE_MOM_SAVING_MONEY=true;T.eq(Mom.mode(s),3,version..' native setflag changes only bit zero') + Mom.setSaving(s,false);T.eq(Mom.mode(s),0,version..' bank stop clears saving mode') + Mom.openBank(s);Mom.setSaving(s,true) + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' bank opening reaches native active flag') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' bank start reaches native SOME flag') + local previous=save();previous.g2MomBankOpen=true;previous.g2MomSaving=true + Mom.prepareBank(previous) + T.eq(previous.flags.ENGINE_MOM_ACTIVE,true,version..' earlier native preview account migrates to active flag') + T.eq(previous.flags.ENGINE_MOM_SAVING_MONEY,true,version..' earlier native preview saving choice migrates to native flag') + previous.flags.ENGINE_MOM_ACTIVE=false;Mom.prepareBank(previous) + T.eq(previous.g2MomBankOpen,nil,version..' explicit native reset overrides legacy open boolean') + local legacy=data();legacy.map_scripts.phone[3].contactNumber=nil;legacy.map_scripts.phone[3].trainer=1 + T.eq(Mom.caller(legacy),3,version..' original non-trainer contact byte remains supported') + local d=data();s=save() + local bankGame,bankWorld=game(d,s) + local bank=Runner.new(bankGame,bankWorld);bank:run(assert(require('src.script.Gen2ScriptVM').compile(d,'BANK'))) + local box=bankGame.stack:top();box.done=true;box:update(0) + local yes=bankGame.stack:top();bankGame.input={wasPressed=function(_,key)return key=='a'end};yes.index=1;yes:update(0);bankGame.input={wasPressed=function()return false end};for _=1,20 do if bankGame.stack:top()==yes then yes:update(0)end end + local feedback=bankGame.stack:top();feedback.done=true;bankGame.input={wasPressed=function(_,key)return key=='a'end};feedback:update(0) + T.eq(bank:isRunning(),false,version..' actual BankOfMom UI completes first saving choice') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' actual BankOfMom UI writes native SOME flag') + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' actual BankOfMom UI opens native account') + T.eq(Mom.mode(s),1,version..' actual BankOfMom UI affects reward allocation mode') + local d=data();s=save(100) + local purchase=buy(d,s) + T.eq(purchase.kind,1,version..' first ladder purchase is native item') + T.eq(s.pcItems.ITEM_017,1,version..' native item byte resolves current dataset') + T.eq(s.g2MomMoney,80,version..' price deducted once') + T.eq(s.g2MomShopping.whichItem,1,version..' ladder advances only on delivery') + T.eq(s.g2MomShopping.whichSet,0,version..' ladder uses native zero set') + T.eq(purchase.caller,3,version..' Mom caller is current native contact identity') + s.g2MomMoney=200;purchase=buy(d,s) + T.eq(s.flags[Flags.eventFlag(900)],true,version..' doll purchase grants actual ownership flag') + T.eq(s.g2MomMoney,130,version..' doll price is deducted') + T.eq(s.g2MomShopping.whichItem,2,version..' doll advances ladder once') + T.eq(s.decorations,nil,version..' doll ownership does not place it in the room') + local g,w=game(d,s);local r=Runner.new(g,w);r:run(purchase.script) + T.eq(s.flags[Flags.eventFlag(902)],true,version..' doll notification runs native compiled role') + s=save(101);purchase=buy(d,s) + T.check(purchase~=nil,version..' ladder takes precedence over random threshold') + s=save(34);s.g2MomShopping={whichItem=255,triggerBalance=17} + local called=0;purchase=buy(d,s,{random=function(n)called=called+1;return n-1 end}) + T.eq(purchase.item,18,version..' exact multiple selects current random table row') + T.eq(called,1,version..' random purchase consumes one bounded roll') + T.eq(s.g2MomShopping.triggerBalance,51,version..' consumed exact threshold advances once') + T.eq(s.g2MomShopping.whichSet,2,version..' native random set is selected row plus one') + T.eq(s.g2MomShopping.whichItem,255,version..' random buy cannot consume ladder index') + T.eq(s.g2MomMoney,29,version..' random purchase deducts its own price') + s=save(35);s.g2MomShopping={whichItem=255,triggerBalance=17} + T.eq(buy(d,s),nil,version..' overshot multiple cannot buy') + T.eq(s.g2MomShopping.triggerBalance,51,version..' overshot threshold advances above current balance') + s=save(100);d.field.pcItemCap=1;s.pcItems.ITEM_018=99 + T.eq(buy(d,s),nil,version..' full PC rejects new stack') + T.eq(s.g2MomMoney,100,version..' refusal does not spend money') + T.eq(s.g2MomShopping.whichItem,0,version..' refusal does not advance ladder') + s.pcItems={ITEM_017=98};T.check(buy(d,s)~=nil,version..' existing stack with room accepts purchase') + T.eq(s.pcItems.ITEM_017,99,version..' item reaches last slot in actual stack') + s=save(100);s.pcItems.ITEM_017=99 + T.eq(buy(d,s),nil,version..' a full same-item stack needs another native PC stack') + s=save(200);s.g2MomShopping={whichItem=1,triggerBalance=17};s.pcItems.ITEM_018=99 + T.check(buy(d,s)~=nil,version..' doll ownership is independent of PC capacity') + s=save(100);T.eq(buy(d,s,{phoneService=false}),nil,version..' no reception suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' no reception cannot consume threshold or initialize purchase state') + T.eq(buy(d,s,{queued=true}),nil,version..' occupied native map-reentry queue suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' occupied queue cannot mutate purchase state') + d.map_scripts.scripts.ITEM_PHONE=nil + T.eq(buy(d,s),nil,version..' missing original phone program cannot grant a silent purchase') + T.eq(s.pcItems.ITEM_017,nil,version..' missing native program leaves inventory unchanged') + -- Actual battle win uses this account split and the cartridge-selected text. + d=data();require('src.battle.TypeChart').load(d);s=save(10) + s.party={Pokemon.new(d,'FIXMON_A',10)};s.g2MomSavingMode=1 + g,w=game(d,s) + local battle=Battle.newTrainer(g,'OPP_FIX_YOUNGSTER',1);battle.participants={} + for _,mon in ipairs(battle.enemyParty)do mon.hp=0 end + battle.enemyIndex=#battle.enemyParty;battle.enemy.mon=battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + T.eq(battle.result,'win',version..' real battle native win branch completes') + T.check(s.g2MomMoney>10,version..' real battle win actually increases Mom account') + T.eq(battle.g2MomAward.mode,1,version..' real battle records applied savings mode') + local found=false + for _,row in ipairs(battle.queue)do if row.text and row.text:find('SYNTHETIC SOME QA TOTAL ',1,true)then found=true end end + T.check(found,version..' real battle presents actual imported SOME reward text') + -- Actual world post-battle path commits once and waits behind native dialogue. + d=data();s=save(200);s.party={Pokemon.new(d,'FIXMON_A',10)} + s.g2MomShopping={whichItem=1,triggerBalance=17};g,w=game(d,s) + battle={kind='trainer',leveledUp={}} + w:afterBattle('win',battle) + T.eq(s.g2MomMoney,130,version..' actual trainer return purchases eligible doll') + T.check(w.g2MomCallPending~=nil,version..' actual trainer return defers original notification') + w:afterBattle('win',battle);T.eq(s.g2MomMoney,130,version..' repeated callback cannot buy twice') + w.pendingScripts={{script={{'end'}}}} + T.eq(w:checkMomShoppingCall(),false,version..' trainer after-dialogue queue keeps phone waiting') + w.pendingScripts={};w.player.moving=true + T.eq(w:checkMomShoppingCall(),false,version..' native phone does not interrupt a partial movement') + w.player.moving=false;g.stack:push({name='dialogue'}) + T.eq(w:checkMomShoppingCall(),false,version..' actual overlay owner keeps phone waiting') + g.stack:pop();T.eq(w:checkMomShoppingCall(),true,version..' idle native world queues original doll script') + T.eq(s.g2CurCaller,3,version..' deferred native call sets actual caller') + T.eq(w:checkMomShoppingCall(),false,version..' notification queues once') + local entry=w.pendingScripts[1];T.check(entry~=nil,version..' actual native FIFO receives notification') + if entry then w.runner:run(entry.script,entry.extra)end + T.eq(s.flags[Flags.eventFlag(902)],true,version..' actual queued original role executes') + T.eq(s.g2CurCaller,nil,version..' completed native call releases caller') + T.eq(s.g2CallCycles,0,version..' completed native call resets receive timer') + -- Physical SRAM offsets checked against the independent shipped symbols. + S.setLayout(version);S.setCharmap({['128']='A'}) + s.g2MomMoney=123456;s.g2MomSavingMode=2;s.g2MomSaving=true;s.g2MomBankOpen=true + s.flags.ENGINE_MOM_SAVING_MONEY=false;s.flags.ENGINE_MOM_ACTIVE=true + s.g2MomShopping={whichItem=2,whichSet=1,triggerBalance=98765} + local bytes=S.encode(s,d);local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(J.decode(f:read('*a')));f:close() + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + local function offset(key)return 0x2009+manifest.symbols[key][2]-anchor end + local function three(at)local a,b,c=bytes:byte(at+1,at+3);return a*65536+b*256+c end + T.eq(three(offset('wMomsMoney')),123456,version..' Mom account uses actual three-byte SRAM field') + T.eq(bytes:byte(offset('wMomSavingMoney')+1),130,version..' saving mode and active bit preserve native byte') + T.eq(bytes:byte(offset('wWhichMomItem')+1),2,version..' native ladder index exports at independent address') + T.eq(three(offset('wMomItemTriggerBalance')),98765,version..' native threshold exports at independent address') + local back=S.decode(bytes,d) + T.eq(back.g2MomMoney,123456,version..' native balance round-trips') + T.eq(Mom.mode(back),2,version..' HALF mode round-trips without becoming SOME') + T.eq(back.g2MomBankOpen,true,version..' bank active state round-trips') + T.eq(back.g2MomShopping.whichItem,2,version..' ladder progress round-trips') + T.eq(back.g2MomShopping.whichSet,1,version..' random row round-trips') + T.eq(back.g2MomShopping.triggerBalance,98765,version..' threshold progress round-trips') + local again=S.encode(back,d,bytes);local decoded=S.decode(again,d) + local good=true;for _,warning in ipairs(decoded.warnings)do if warning:find('checksum')then good=false end end + T.check(good,version..' Mom fields remain covered by valid native checksum') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local s=save(10);T.eq(Mom.reward(s,900),nil,version..' native GSC account split is isolated') + T.eq(Mom.tryBuy(data(),s,{phoneService=true}),nil,version..' foreign edition cannot buy retail GSC gifts') + S.setLayout(version);T.eq(S.OFFSETS.momMoney,nil,version..' unreviewed SRAM layout has no guessed Mom fields') +end +Sound.play=oldSound;debug.setupvalue(W.afterBattle,slot,oldGame);V.set(original);S.setLayout(original) +T.finish('native GSC Mom accounts, purchases, trainer return, phone queue and SRAM') diff --git a/tests/engine/gen2_phone_book.lua b/tests/engine/gen2_phone_book.lua new file mode 100644 index 000000000..06f472afd --- /dev/null +++ b/tests/engine/gen2_phone_book.lua @@ -0,0 +1,140 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local Gear=require('src.ui.PokegearMenu') +local function data() + local d=T.fixtures.fresh();d.map_scripts={phone={},scripts={STUB={{'end'}}}} + for id=1,24 do d.map_scripts.phone[id]={name='CONTACT'..id,class=1,trainer=1, + receive='STUB',receiveTime=7,call='STUB',callTime=7,group=1,number=1} end + d.map_scripts.phone[1]={name='MOM',contactNumber=1} + d.map_scripts.phone[4]={name='ELM',contactNumber=4} + return d +end +local function same(actual,expected,label) + T.eq(#actual,#expected,label..' length') + for i,id in ipairs(expected) do T.eq(actual[i],id,label..' slot'..i) end +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);local d,save=data(),{} + local label=version..': ' + T.eq(Book.remainingSlots(d,save,2),8,label..' absent permanent numbers reserve two slots') + T.eq(Book.remainingSlots(d,save,1),9,label..' a permanent number does not reserve against itself') + T.eq(Book.has(save,0),true,label..' native empty-byte membership semantics retained') + local trainerIds={2,3,5,6,7,8,9,10} + for _,id in ipairs(trainerIds) do T.eq(Book.add(d,save,id),true,label..' ordinary number admitted within unreserved run') end + T.eq(Book.add(d,save,11),false,label..' ninth trainer cannot consume missing permanent slots') + T.eq(Book.add(d,save,1),true,label..' Mom can occupy her reserved slot') + T.eq(Book.add(d,save,4),true,label..' Elm can occupy his reserved slot') + same(Book.contacts(save),{2,3,5,6,7,8,9,10,1,4},label..' insertion order reaches all ten slots') + T.eq(Book.has(save,0),false,label..' full native array contains no empty byte') + local ok,why=Book.add(d,save,2);T.eq(ok,false,label..' duplicate refused');T.eq(why,'already',label..' duplicate reason retained') + T.eq(Book.add(d,save,0),false,label..' zero never becomes a registered contact') + T.eq(Book.add(d,save,255),false,label..' absent contact cannot become callable') + T.eq(Book.canDelete(d,1),false,label..' Mom is permanent in the native UI') + T.eq(Book.canDelete(d,4),false,label..' Elm is permanent in the native UI') + T.eq(Book.canDelete(d,2),true,label..' trainer number is deletable') + T.eq(Book.remove(save,5),true,label..' script deletion succeeds') + T.eq(Book.contacts(save)[3],0,label..' script deletion leaves a physical hole') + T.eq(Book.contacts(save)[4],6,label..' script deletion does not reorder later slots') + T.eq(Book.add(d,save,11),true,label..' next addition occupies the first hole') + T.eq(Book.contacts(save)[3],11,label..' reusing a hole keeps remaining order') + T.eq(Book.deleteAt(save,2),true,label..' Pokegear deletion succeeds') + same(Book.contacts(save),{2,11,6,7,8,9,10,1,4,0},label..' only Pokegear deletion compacts') + T.eq(save.g2Phone[3],nil,label..' deletion updates the existing numeric set') + local detached=Book.contacts(save);detached[1]=24 + T.eq(Book.contacts(save)[1],2,label..' returned slot arrays are detached') + save.g2Phone[23]=true + T.eq(Book.contacts(save)[10],23,label..' old/native set writes are adopted without reordering') + save.g2Phone[11]=nil + T.eq(Book.contacts(save)[2],0,label..' external native deletion is not resurrected') + local selected=Calls.availableCallers(d,save,{group=2,number=2},'DAY') + same(selected,{2,6,7,8,9,10,23},label..' actual incoming selector walks physical registration order') + -- Actual imported-opcode compiler and native ScriptRunner, including the + -- askforphonenumber result branches. Only the yes/no prompt is an IO seam. + local fresh={};local game={data=d,save=fresh,overworld={}} + local serial=0 + local function run(rows) + serial=serial+1;local key='BOOK'..serial + d.map_scripts.scripts[key]=rows + local lowered=assert(require('src.script.Gen2ScriptVM').compile(d,key)) + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(lowered) + T.eq(runner:isRunning(),false,label..' native book script returns') + return runner.ctx + end + local ctx=run({{'addcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native add/check writes the book') + T.eq(Book.contacts(fresh)[1],7,label..' VM and UI share native slot ownership') + ctx=run({{'delcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual native delete clears membership') + ctx=run({{'checkcellnum',0},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native checkcellnum sees an empty slot') + local Commands=require('src.script.Commands');local ask=Commands.ask + local accepted=true;Commands.ask=function(c)c.lastCheck=accepted end + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual accepted ask returns CONTACT_GOT') + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual duplicate ask returns CONTACTS_FULL') + accepted=false;ctx=run({{'askforphonenumber',9},{'end'}}) + T.eq(ctx.g2Var,2,label..' actual refused ask returns CONTACT_REFUSED') + T.eq(fresh.g2Phone[9],nil,label..' refusal does not mutate native membership') + Commands.ask=ask +end +V.set('crystal') +do + local d=data();d.map_scripts.phone[1]={class=1,name='TRAINER1'} + d.map_scripts.phone[4]={class=1,name='TRAINER4'} + d.map_scripts.phone[12]={name='AUTHORED MOM',contactNumber=1} + d.map_scripts.phone[14]={name='AUTHORED ELM',contactNumber=4} + T.eq(Book.canDelete(d,1),true,'native trainer identity wins over the old Mom slot') + T.eq(Book.canDelete(d,4),true,'native trainer identity wins over the old Elm slot') + T.eq(Book.canDelete(d,12),false,'explicit relocated Mom identity is permanent') + T.eq(Book.canDelete(d,14),false,'explicit relocated Elm identity is permanent') + d.constants.phoneContactOrder={};d.map_scripts.phone[1]={name='CUSTOM'} + T.eq(Book.canDelete(d,1),true,'authored empty identity metadata does not receive retail guesses') +end +do + local d=data();local save={g2Phone={[7]=true,[2]=true,[5]=true}} + same(Book.contacts(save),{2,5,7,0,0,0,0,0,0,0},'legacy set receives deterministic initial slot order') + save.g2PhoneSlots={7,2,2,0,0,0,0,0,0,0} + same(Book.contacts(save),{7,2,5,0,0,0,0,0,0,0},'stored order survives and duplicate slots are repaired') + local gear=setmetatable({game={data=d,save=save},contact=1},{__index=Gear}) + local rows=gear:contacts();T.eq(#rows,10,'actual native card exposes ten physical slots') + T.eq(rows[1].id,7,'actual native card does not sort away registration order') + T.eq(rows[4].id,0,'empty native row is not callable') + T.eq(rows[4].name,'----------','empty native row is displayed explicitly') + local empty=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(empty[1].id,0,'empty book cannot invent a Mom registration') + T.eq(empty[4].id,0,'empty book cannot invent an Elm registration') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0 + gear.game.input={wasPressed=function(_,value)return key==value end} + gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local sound=Sound.play;Sound.play=function()end + gear.contact=4;key='a';gear:update(0) + T.eq(gear.phoneSubmenu,nil,'real native A input cannot call an empty slot') + gear.contact=2;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[2],nil,'real native DELETE input removes the selected physical slot') + same(Book.contacts(save),{7,5,0,0,0,0,0,0,0,0},'real Pokegear delete compacts the shared book') + Sound.play=sound +end +do + local d=data();local save={g2Phone={},g2PhoneSlots={2,3,5,6,7,8,9,10,1,4}} + for _,id in ipairs(save.g2PhoneSlots)do save.g2Phone[id]=true end + local gear=setmetatable({game={data=d,save=save},contact=10,gfx={}},{__index=Gear}) + local drawn={};gear.drawStrip=function()end;gear.textbox=function()end + gear.printBoxText=function()end;gear.drawModeArrow=function()end + gear.text=function(_,name,_,y)drawn[name]=y end + local Font=require('src.render.Font');local old={Font.draw,Font.drawCode,Font.beginTint,Font.endTint} + local graphics=love.graphics;love.graphics={setColor=function()end,rectangle=function()end} + Font.draw=function(name,_,y)drawn[name]=y/8 end + Font.drawCode=function()end;Font.beginTint=function()end;Font.endTint=function()end + gear:drawPhone() + T.eq(drawn.ELM,11,'actual native drawing keeps selected slot ten visible') + T.eq(drawn.MOM,10,'actual native drawing retains slot nine beside it') + T.eq(drawn.CONTACT2,nil,'scrolling does not draw hidden slot one') + Font.draw,Font.drawCode,Font.beginTint,Font.endTint=unpack(old);love.graphics=graphics +end +V.set(original) +T.finish('native ordered phone slots, permanent reservations, actual VM and Pokegear') diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua new file mode 100644 index 000000000..fbf148c8a --- /dev/null +++ b/tests/engine/gen2_phone_call_selection.lua @@ -0,0 +1,78 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-call-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Calls=require('src.script.Gen2Commands') +local V=require('src.core.GameVersion');local original=V.get() +local bit=require('bit') +local consumed=0 +local function unused()consumed=consumed+1;return 0 end +T.eq(Calls.chooseRandomCaller(nil,unused),nil,'missing candidate list has no caller') +T.eq(Calls.chooseRandomCaller({},unused),nil,'empty candidate list has no caller') +T.eq(consumed,0,'empty selection consumes no random byte') +for n=1,10 do + local callers={};for i=1,n do callers[i]=100+i end + for byte=0,255 do + local used=0 + local got=Calls.chooseRandomCaller(callers,function()used=used+1;return byte end) + -- Independent bytewise SWAP / AND $1f / division remainder oracle. + local swapped=bit.band(bit.bor(bit.lshift(byte,4),bit.rshift(byte,4)),255) + local expected=callers[bit.band(swapped,31)%n+1] + T.eq(got,expected,'native byte mapping n='..n..' byte='..byte) + T.eq(used,1,'native choice consumes exactly one byte') + end +end +local old=math.random +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + local data={map_scripts={scripts={},phone={}}} + local ids={9,7,11} + for i,id in ipairs(ids) do + local label='NATIVE_RECEIVE_'..version..'_'..id + data.map_scripts.phone[id]={receive=label,receiveTime=2,group=2,number=1} + data.map_scripts.scripts[label]={{'setval',80+i},{'end'}} + end + local function save() + return {g2LastCallMinute=0,g2Phone={[9]=true,[7]=true,[11]=true}, + g2PhoneSlots={9,7,11,0,0,0,0,0,0,0}} + end + local map={group=1,number=1,phoneService=true} + local function randomBytes(bytes) + local used=0 + math.random=function(a,b) + T.eq(a,0,'phone RNG requests an unsigned byte lower bound') + T.eq(b,255,'phone RNG requests an unsigned byte upper bound') + used=used+1;assert(bytes[used]~=nil,'unexpected phone RNG draw') + return bytes[used] + end + return function()return used end + end + -- Byte $10 swaps to $01, choosing slot two, not sorted numeric ID order. + local draws=randomBytes({0,16});local s=save() + local id,rows=Calls.rollIncomingCall(data,s,map,'DAY',30,false) + T.eq(id,7,version..' actual incoming path selects the native saved-slot sample') + T.eq(draws(),2,version..' gate and caller choice consume one byte each') + T.eq(s.g2CallCycles,0,version..' accepted call resets the receive cycle') + T.eq(s.g2LastCallMinute,30,version..' accepted call restarts at the actual minute') + local runner=require('src.script.ScriptRunner').new({data=data,save=s},{}) + runner:run(rows) + T.eq(runner.ctx.g2Var,82,version..' selected native receive program actually executes') + draws=randomBytes({128});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' failed coin flip suppresses choice') + T.eq(draws(),1,version..' failed coin flip consumes no caller-choice byte') + T.eq(s.g2CallCycles,1,version..' failed coin flip still consumes timer expiry') + draws=randomBytes({0});s=save() + T.eq(Calls.rollIncomingCall(data,s,{phoneService=false,mapPalette=0x10},'DAY',30,false),nil,version..' missing service suppresses choice') + T.eq(draws(),1,version..' missing service consumes no caller-choice byte') + draws=randomBytes({0});s=save();s.g2Phone={};s.g2PhoneSlots={} + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' empty native book suppresses choice') + T.eq(draws(),1,version..' empty native book consumes no caller-choice byte') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,true),nil,version..' entrance blocks before timer and RNG') + T.eq(draws(),0,version..' entrance consumes no random bytes') + T.eq(s.g2LastCallMinute,0,version..' entrance preserves pending timer') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',1,false),nil,version..' unexpired timer suppresses gate and selection') + T.eq(draws(),0,version..' unexpired timer consumes no random bytes') +end +math.random=old;V.set(original) +T.finish('native phone caller byte selection, saved order, timer gates and actual receive programs') diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua new file mode 100644 index 000000000..044b6896d --- /dev/null +++ b/tests/engine/gen2_phone_content.lua @@ -0,0 +1,174 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local C=require('src.script.Gen2Commands');local Gear=require('src.ui.PokegearMenu') +local function fixture() + local d=T.fixtures.fresh() + d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','PHONE_JOEY','PHONE_UNUSED'} + d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.maps.PALLET_TOWN={group=1,number=1};d.maps.ROUTE_31={group=1,number=2} + d.trainers={OPP_LOCALIZED={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phoneInfo={size=4},phone={ + [1]={name='NATIVE MOM',contactNumber=1,group=1,number=1,call='OUT',callTime=7,receive='IN',receiveTime=0}, + [2]={class=1,trainer=1,group=1,number=2,call='OUT',callTime=7,receive='IN',receiveTime=7}, + },scripts={OUT={{'setval',41},{'end'}},IN={{'setval',42},{'end'}}}} + return d +end +local function load(d,source,api,format) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone/manifest.json']=Json.encode({id='phone',name='Phone content',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/phone/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r) Loader.teardown();r.release() end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local native=d.map_scripts.phone[2] + local r=load(d,[[ + assert(mod.content.phone_contacts:supports()) + local p=mod.content["phone_contacts"] + assert(p:get('PHONE_MOM').number==1) + assert(p:get('PHONE_MOM').map=='PALLET_TOWN') + assert(p:get('PHONE_MOM').callee=='OUT' and p:get('PHONE_MOM').caller=='IN') + assert(p:get('PHONE_JOEY').class=='YOUNGSTER') + assert(p:get('PHONE_JOEY').member=='JOEY1') + local row=p:get('PHONE_MOM');row.name='LEAK' + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + for id,value in p:each() do value.name='LEAK' end + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + p:patch('PHONE_MOM',{name='AUTHORED MOM',map='ROUTE_31',callerTime=7}) + p:register('PHONE_EXTRA',{name='EXTRA',map='PALLET_TOWN',calleeTime=7,callerTime=7,calleeKey='OUT',callerKey='IN'}) + mod.exports.index=p:get('PHONE_EXTRA').index + p:remove('PHONE_JOEY') + ]],mode[1],mode[2]) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real loader accepts semantic contacts') + local e=r.loader.exports.phone;local index=e and e.index + T.eq(index,4,label..' added contact skips all native holes') + T.eq(C.phoneName(d,1),'AUTHORED MOM',label..' native name consumer uses authored row') + T.eq(d.map_scripts.phone[1].contactNumber,1,label..' contact number stays separate') + T.eq(d.map_scripts.phone[1].number,2,label..' actual map number changes') + T.eq(d.map_scripts.phone[1].receiveTime,7,label..' native incoming time changes') + T.eq(C.phoneName(d,2),nil,label..' native removed contact stays absent') + T.eq(native.trainer,1,label..' source row remains unchanged') + if index then + local game={data=d,save={g2Phone={[index]=true}},overworld={}} + local gear=setmetatable({game=game},{__index=Gear}) + T.eq(#gear:contacts(),10,label..' actual native phone book preserves ten physical slots') + T.eq(gear:contacts()[1].id,index,label..' registered addition occupies the first native slot') + T.eq(gear:contacts()[2].id,0,label..' unused native slot cannot invent a callable contact') + for _,direction in ipairs({'call','receive'}) do + local rows=direction=='call' and C.phoneCallScript(d,index) or C.phoneReceiveScript(d,index) + T.check(rows~=nil,label..' '..direction..' compiles through native VM') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,label..' '..direction..' executes correct native direction') + end + T.eq(C.availableCallers(d,game.save,d.maps.ROUTE_31,'DAY')[1],index,label..' native incoming selector sees added contact') + T.eq(#C.availableCallers(d,game.save,d.maps.PALLET_TOWN,'DAY'),0,label..' same-map incoming suppression remains native') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' disabling/reloading succeeds') + T.eq(C.phoneName(d,1),'NATIVE MOM',label..' disabling restores base name') + T.eq(C.phoneName(d,2),'LOCAL JOEY',label..' disabling restores native trainer') + T.eq(d.map_scripts.phone[4],nil,label..' disabling removes added numeric row') + finish(clean) + end +end +V.set('crystal') +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + local row=p:get('PHONE_MOM');row.calleeKey='IN';p:override('PHONE_MOM',row) + ]]) + T.eq(#r.errors,0,'read/edit/write accepts an explicit script key alongside the prior alias') + T.eq(d.map_scripts.phone[1].call,'IN','explicit calleeKey owns the effective outgoing script') + finish(r) +end +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:patch('PHONE_MOM',{callee='IN',caller='OUT',callerTime=7}) + ]]) + T.eq(#r.errors,0,'caller and callee aliases are accepted') + T.eq(d.map_scripts.phone[1].call,'IN','callee alias replaces previous outgoing key') + T.eq(d.map_scripts.phone[1].receive,'OUT','caller alias replaces previous incoming key') + local p=r.loader.exports.phone.api.content.phone_contacts + T.eq(p:get('PHONE_MOM').callee,'IN','public callee alias reflects the effective native key') + T.eq(p:get('PHONE_MOM').caller,'OUT','public caller alias reflects the effective native key') + finish(r) +end +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:register('FIRST',{name='FIRST',index=255}) + p:register('SECOND',{name='SECOND'}) + assert(p:get('SECOND').index==4) + p:remove('SECOND') + p:register('THIRD',{name='THIRD'}) + assert(p:get('THIRD').index==5) + p:patch('PHONE_JOEY',{name='TRANSLATED'}) + ]]) + T.eq(#r.errors,0,'explicit high index and removed additions retain reservations') + T.eq(C.phoneName(d,255),'FIRST','native byte upper bound can be used explicitly') + T.eq(C.phoneName(d,4),nil,'added then removed contact leaves no callable row') + T.eq(C.phoneName(d,5),'THIRD','next addition cannot steal removed contact index') + T.eq(d.map_scripts.phone[2].class,1,'display override retains canonical trainer identity') + T.eq(d.map_scripts.phone[2].trainer,1,'display override retains trainer member') + local api=r.loader.exports.phone.api.content.phone_contacts + T.check(not pcall(function() api:patch('PHONE_MOM',{name='LATE'}) end),'frozen content rejects late mutation') + T.eq(C.phoneName(d,1),'NATIVE MOM','late mutation cannot change native data') + d.map_scripts.phone[2].name='EXTERNAL AFTER LOAD' + finish(r) + local clean=load(d) + T.eq(C.phoneName(d,2),'EXTERNAL AFTER LOAD','reload preserves an externally edited native contact') + T.eq(d.map_scripts.phone[255],nil,'reload retires unchanged added high-index contact') + finish(clean) +end +do + local d=fixture();d.map_scripts=nil + local r=load(d,"mod.content.phone_contacts:register('NEW',{name='NEW'})") + T.eq(#r.errors,0,'a named silent contact needs no native script pool') + T.eq(C.phoneName(d,4),'NEW','new native book respects explicit metadata floor') + finish(r) + local clean=load(d) + T.eq(d.map_scripts,nil,'reload removes an exclusively owned empty pool namespace') + finish(clean) +end +for _,source in ipairs({ + "p:register('BAD',{name='BAD',index=3})", + "p:register('BAD',{name='BAD',index=2})", + "p:patch('PHONE_MOM',{index=7})", + "p:register('BAD',{name='BAD',callerTime=7})", + "p:register('BAD',{name='BAD',callerTime=7,callerKey='MISSING'})", + "p:register('BAD',{class='YOUNGSTER',member='MISSING'})", + "p:register('BAD',{name='BAD',map='MISSING'})", + "p:register('BAD',{name='BAD',callerTime=0/0})", + "local x={};x.x=x;p:register('BAD',x)", + "p:register('BAD',{name='BAD',index=256})", + "p:register('BAD',{name='BAD',index=0})", + "p:register('BAD',{name='BAD',unknownField=true})", + "p:register('BAD',{class='YOUNGSTER',member='JOEY1',number=1})", + "p:patch('PHONE_MOM',{name='FAILED'});error('entry failure')", +}) do + local d=fixture();local r=load(d,'local p=mod.content.phone_contacts;'..source) + T.check(#r.errors>0,'invalid entry is rejected and attributed: '..source) + T.eq(C.phoneName(d,1),'NATIVE MOM','failed entry rolls back earlier phone operations') + T.eq(d.map_scripts.phone[4],nil,'failed entry cannot leak a native numeric contact') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();local originalRow=d.map_scripts.phone[1] + local r=load(d,[[local p=mod.content.phone_contacts;assert(not p:supports());assert(p:get('PHONE_MOM')==nil);p:register('OTHER',{name='NO WRITE'})]]) + T.eq(#r.errors,0,version..' unsupported target reports through capability handle') + T.eq(d.map_scripts.phone[1],originalRow,version..' other generations retain native-looking data unchanged') + T.eq(d.map_scripts.phone[4],nil,version..' cannot acquire a Gen2 phone book') + finish(r) +end +V.set(original) +T.finish('semantic native phone contacts, actual VM/phone book, rollback and isolation') diff --git a/tests/engine/gen2_phone_identities.lua b/tests/engine/gen2_phone_identities.lua new file mode 100644 index 000000000..4b1350476 --- /dev/null +++ b/tests/engine/gen2_phone_identities.lua @@ -0,0 +1,111 @@ +-- Copyright (c) 2026 Roxas2712. See offered PR contribution permission. +-- Synthetic native ROM rows distinguish contact numbers from map numbers. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Ids=require('src.import.Gen2PhoneIdentities') +local Extractor=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local initial=V.get() +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local ids=Ids.forVersion(version) + T.eq(#ids.phoneContactOrder,version=='crystal' and 38 or 37,version..' retail row bound includes holes') + T.eq(ids.phoneContactOrder[9],'PHONE_UNUSED',version..' reserved contact hole is explicit') + T.eq(ids.phoneContactOrder[2],'PHONE_MOM',version..' contact identity is numeric-order metadata') + T.eq(ids.phoneContactOrder[3],'PHONE_OAK',version..' retain reference spelling without renaming native slot2') + ids.phoneContactOrder[2]='LEAK';ids.trainerClassMembers.FALKNER[1]='LEAK' + T.eq(Ids.forVersion(version).phoneContactOrder[2],'PHONE_MOM',version..' contact metadata detached') + T.neq(Ids.forVersion(version).trainerClassMembers.FALKNER[1],'LEAK',version..' nested trainer metadata detached') + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + -- Four rows: unused0, non-trainer1, trainer2 and a reserved unused3. + put(0x4000+12,{0,2,9,42,7,1,0x00,0x50,2,1,0x10,0x50}) + put(0x4000+24,{12,3,5,6,1,1,0x20,0x50,4,1,0x30,0x50}) + -- Pointer slot2 names the actual imported display string; it is deliberately + -- unrelated to the legacy PHONE_OAK compatibility identifier at index2. + put(0x4204,{0x00,0x43});put(0x4300,{0x81,0x88,0x8A,0x84,0x7F,0x92,0x87,0x8E,0x8F,0x50}) + -- Crystal's late non-trainer record at native index37 names slot5. Earlier + -- code read only names1..4 and silently discarded this entire native row. + put(0x4000+37*12,{0,5,3,77,7,1,0x40,0x50,7,1,0x50,0x50}) + put(0x420a,{0x10,0x43});put(0x4310,{0x81,0x94,0x84,0x8d,0x80,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local e=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030},NonTrainerCallerNames={1,0x4200}}}) + function e:readSourceTable()return {}end + local queued={} + function e:gen2QueueScript(bank,address) + local id=string.format('S%02X_%04X',bank,address);queued[#queued+1]=id;return id + end + local rows,info=e:gen2PhoneContacts({}) + T.eq(info.size,4,version..' imported full native bound retains unused slots') + T.eq(info.rowsRead,4,version..' actual imported row count is explicit') + T.eq(rows[0],nil,version..' unused0 is not invented as a callable contact') + T.eq(rows[3],nil,version..' reserved hole is retained by bound, not a fake contact') + T.eq(rows[1].contactNumber,2,version..' actual non-trainer identity retained') + T.eq(rows[1].number,42,version..' actual map number remains separate') + T.eq(rows[1].group,9,version..' actual map group remains native') + T.eq(rows[1].name,'BIKE SHOP',version..' native display text remains authoritative') + T.eq(info.nonTrainerNames[2],'BIKE SHOP',version..' imported non-trainer lookup retained') + T.eq(rows[1].call,'S01_5000',version..' callee pointer remains outgoing') + T.eq(rows[1].callTime,7,version..' callee time remains outgoing') + T.eq(rows[1].receive,'S01_5010',version..' caller pointer remains incoming') + T.eq(rows[1].receiveTime,2,version..' caller time remains incoming') + T.eq(rows[2].class,12,version..' native trainer class is numeric and unchanged') + T.eq(rows[2].trainer,3,version..' native roster member is unchanged') + T.eq(rows[2].contactNumber,nil,version..' trainer member is not a non-trainer contact number') + T.eq(rows[2].number,6,version..' trainer map number is not replaced by member') + T.eq(#queued,4,version..' both native phone directions are queued') + local constants=e:constants() + T.eq(constants.phoneContactOrder[2],'PHONE_MOM',version..' actual constants producer installs phone identities') + T.eq(constants.generation,2,version..' new metadata preserves native generation') + local empty=Extractor.new(string.rep('\0',0x8000),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030}}}) + local none,bounds=empty:gen2PhoneContacts({}) + T.eq(none,nil,version..' empty native book has no fake callable records') + T.eq(bounds.size,4,version..' empty native book still reserves its complete row range') + local buena=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x41c8},NonTrainerCallerNames={1,0x4200}}}) + local book,meta=buena:gen2PhoneContacts({scripts={},queue={}}) + T.eq(book[37] and book[37].name,'BUENA',version..' referenced name5 retains the complete native contact') + T.eq(book[37] and book[37].contactNumber,5,version..' late non-trainer keeps its own identity') + T.eq(book[37] and book[37].number,77,version..' late non-trainer map number remains independent') + T.eq(meta.size,38,version..' full native row range remains reserved') + local data=T.fixtures.fresh();data.map_scripts={phone=book,scripts={S01_5040={{'setval',41},{'end'}},S01_5050={{'setval',42},{'end'}}}} + local C=require('src.script.Gen2Commands') + T.eq(C.phoneName(data,37),'BUENA',version..' real native phone-name consumer can resolve the restored contact') + local game={data=data,save={},overworld={}} + for _,direction in ipairs({'call','receive'})do + local rows=direction=='call' and C.phoneCallScript(data,37) or C.phoneReceiveScript(data,37) + T.check(rows~=nil,version..' restored '..direction..' script is actually compiled') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,version..' real native '..direction..' executes correct script') + T.eq(runner:isRunning(),false,version..' native '..direction..' finishes') + end +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.eq(Ids.forVersion(version),nil,version..' does not receive guessed retail identities') +end +local authored={phoneContactOrder={'CUSTOM'},trainerClassOrder={'CUSTOM_CLASS'},trainerClassMembers={CUSTOM_CLASS={'ONE'}}} +local manifest={constants=authored};local mounted={} +Ids.install(mounted,manifest,'prism') +T.eq(mounted.phoneContactOrder[1],'CUSTOM','manifest explicitly describes an alternative cartridge') +authored.trainerClassMembers.CUSTOM_CLASS[1]='LEAK' +T.eq(mounted.trainerClassMembers.CUSTOM_CLASS[1],'ONE','manifest roster metadata is detached') +local original={phoneContactOrder={'NATIVE'}};Ids.install(original,manifest,'gold') +T.eq(original.phoneContactOrder[1],'NATIVE','existing authored constants remain authoritative') +local custom={trainerClassOrder={'CUSTOM'}};Ids.install(custom,nil,'gold') +T.eq(custom.trainerClassMembers,nil,'partial authored roster cannot inherit unrelated retail members') +T.eq(custom.phoneContactOrder,nil,'partial authored identity set cannot guess retail contact names') +local Importer=require('src.import.RomImporter');local marker +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if not name then break end;if name=='markerFor'then marker=value;break end end +assert(marker) +for _,version in ipairs({'gold','silver','crystal'})do + local current=marker(version) + T.check(current:find('gen2-phone-identities-v1:',1,true)~=nil,version..' existing native import must rebuild once') + local old=current:gsub('gen2%-phone%-identities%-v1:','') + local V=require('src.core.GameVersion');local previous=V.get();V.set(version) + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',old) + T.eq(Importer.readyReport(version).ok,false,version..' real readiness gate rejects missing phone metadata') + V.set(previous) +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.check(not marker(version):find('gen2-phone-identities-v1:',1,true),version..' unrelated cache marker is unchanged') +end +V.set(initial) +T.finish('native phone/trainer identities and reserved-row import metadata') diff --git a/tests/engine/gen2_phone_list_hook.lua b/tests/engine/gen2_phone_list_hook.lua new file mode 100644 index 000000000..b1f0c959b --- /dev/null +++ b/tests/engine/gen2_phone_list_hook.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-list-hook.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Gear=require('src.ui.PokegearMenu');local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local function fixture() + local d=T.fixtures.fresh();d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','U2','U3','PHONE_ELM','U5','U6','PHONE_A','U8','PHONE_B'} + d.map_scripts={phoneInfo={size=10},scripts={STUB={{'end'}}},phone={ + [1]={name='MOM',contactNumber=1},[4]={name='ELM',contactNumber=4}, + [7]={name='A',receive='STUB',receiveTime=7,group=1,number=1}, + [9]={name='B',receive='STUB',receiveTime=7,group=1,number=1}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone_hook/manifest.json']=Json.encode({id='phone_hook',name='Phone list',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/phone_hook/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone_hook'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local r=load(d,[[ + local added=mod.content.phone_contacts:register('PHONE_NEW',{name='AUTHORED NEW'}) + mod.exports.index=added.index + mod.exports.stop=mod.hooks:wrap("phone.contact_list",function(next,save,list) + mod.exports.calls=(mod.exports.calls or 0)+1;mod.exports.save=save;mod.exports.received=list + local rows=next(save,list);rows[1]=9;rows[2]=0;rows[3]='7';rows[10]=added.index + return rows + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real public hook registration succeeds') + local save={g2Phone={[7]=true,[9]=true},g2PhoneSlots={7,9,0,0,0,0,0,0,0,0}} + local gear=setmetatable({game={data=d,save=save}},{__index=Gear}) + local rows=gear:contacts();local e=r.loader.exports.phone_hook + T.eq(e.calls,1,label..' native card dispatches the hook exactly once') + T.eq(e.save,save,label..' opaque native save handle retains reference signature') + T.eq(#rows,10,label..' presentation retains ten slots') + T.eq(rows[1].id,9,label..' display hook can reorder native contacts') + T.eq(rows[1].slot,2,label..' reordered row retains its actual physical slot') + T.eq(rows[2].id,0,label..' display hook can hide a row') + T.eq(rows[3].id,7,label..' numeric contact spellings resolve through native records') + T.eq(rows[3].slot,1,label..' reordered selected ID does not acquire a different native slot') + T.eq(rows[10].name,'AUTHORED NEW',label..' hook sees actual native content registration') + T.eq(rows[10].slot,nil,label..' an injected display row cannot borrow another physical slot') + T.eq(save.g2Phone[e.index],nil,label..' showing a contact does not register it') + T.eq(Book.contacts(save)[1],7,label..' saved registration order remains native') + local incoming=Calls.availableCallers(d,save,{group=2,number=1},'DAY') + T.eq(incoming[1],7,label..' incoming caller selection ignores display reorder') + T.eq(incoming[2],9,label..' hidden display contact remains eligible for native incoming calls') + T.eq(e.calls,1,label..' incoming call selection does not dispatch the display hook') + e.received[1]=255;rows[1].name='READ MUTATION' + T.eq(Book.contacts(save)[1],7,label..' received display arrays cannot mutate the native book') + T.eq(d.map_scripts.phone[9].name,'B',label..' returned UI rows cannot mutate native contacts') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0;gear.contact=1 + gear.game.input={wasPressed=function(_,value)return key==value end};gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local old=Sound.play;Sound.play=function()end + key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[9],nil,label..' actual DELETE targets the reordered contact ID') + T.eq(save.g2Phone[7],true,label..' display position cannot delete the unrelated physical contact') + gear.contact=1;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[7],true,label..' deleting an injected non-member never consumes another native slot') + Sound.play=old + e.stop();rows=gear:contacts() + T.eq(rows[1].id,7,label..' unsubscribe restores the actual native presentation') + T.eq(rows[2].id,0,label..' unsubscribe does not recreate the deleted contact') + finish(r) + end +end +V.set('crystal') +for _,expression in ipairs({'nil','false','"bad"','{9}','{9,7,0,0,0,0,0,0,0,0,0}'}) do + local d=fixture();local r=load(d,'mod.hooks:wrap("phone.contact_list",function()return '..expression..' end)') + T.eq(#r.errors,0,'invalid display return does not prevent entry loading') + local save={g2Phone={[7]=true}};local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'wrong return type/length keeps the real native display: '..expression) + T.eq(#rows,10,'wrong return type/length keeps native slot count') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function() + return {999,-1,1.5,0/0,math.huge,{},true,'7','bad',9} + end)]]) + local save={g2Phone={[7]=true,[9]=true}} + local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + for i=1,7 do T.eq(rows[i].id,0,'invalid callback contact becomes a blank at slot'..i) end + T.eq(rows[8].id,7,'valid numeric string still resolves') + T.eq(rows[9].id,0,'invalid text becomes a blank') + T.eq(rows[10].id,9,'valid last native contact is retained') + T.eq(save.g2Phone[7],true,'sanitization preserves native registration') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function()return {9,0,0,0,0,0,0,0,0,0}end);error('entry failure')]]) + T.check(#r.errors>0,'failed entry is reported') + local rows=setmetatable({game={data=d,save={g2Phone={[7]=true}}}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'failed-entry rollback removes its phone hook') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();d.field.phone={contacts={{id=99,name='LEGACY'}}} + local r=load(d,[[mod.exports.calls=0 + mod.hooks:wrap("phone.contact_list",function(next,...)mod.exports.calls=mod.exports.calls+1;return next(...)end)]]) + T.eq(#r.errors,0,version..' hook registration remains available without a native call site') + local rows=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(rows[1].name,'LEGACY',version..' native-looking phone records do not replace authored presentation') + T.eq(r.loader.exports.phone_hook.calls,0,version..' cannot dispatch a Gen2-only display hook') + finish(r) +end +V.set(original) +T.finish('native phone.contact_list hook, actual card/delete, saved-book isolation and rollback') diff --git a/tests/engine/gen2_phone_received_event.lua b/tests/engine/gen2_phone_received_event.lua new file mode 100644 index 000000000..72bddc0dd --- /dev/null +++ b/tests/engine/gen2_phone_received_event.lua @@ -0,0 +1,150 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-received-event.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Calls=require('src.script.Gen2Commands');local Runner=require('src.script.ScriptRunner') +local World=require('src.world.OverworldController') +local gameSlot,oldGame +for i=1,100 do + local name,value=debug.getupvalue(World.checkIncomingPhoneCall,i) + if name=='Game' then gameSlot=i;oldGame=value;break end + if not name then break end +end +assert(gameSlot,'actual native controller Game upvalue') +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local fs={['mods/phone_event/manifest.json']=Json.encode({id='phone_event',name='Phone event',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}), + ['mods/phone_event/main.lua']='local mod=...;mod.exports.api=mod;'..source} + return T.sdk.loadMods({'mods/phone_event'},{data=d,fs=T.sdk.memfs(fs)}) +end +local function fixture() + local d=T.fixtures.fresh();d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.trainers={OPP_LOCAL={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phone={[7]={class=1,trainer=1,call='OUT',callTime=7,receive='IN',receiveTime=7,group=2,number=1}, + [4]={name='LOCAL ELM',contactNumber=4,receive='ELM',receiveTime=0}}, + specialCalls={[1]={caller=4,script='SPECIAL',outside=true}}, + scripts={OUT={{'setval',74},{'end'}},IN={{'setval',71},{'end'}},ELM={{'setval',72},{'end'}}, + SPECIAL={{'setval',73},{'specialphonecall',0},{'end'}}}} + return d +end +local function gameFor(d) + local g={data=d,save={playTime=1800,g2LastCallMinute=0,g2Phone={[7]=true}}} + local w=setmetatable({map={id='HERE',def={group=1,number=1,phoneService=true,environment=1}, + warpAtCell=function()return nil end},player={cellX=0,cellY=0},scriptMoves={}, + timeOfDay=function()return 'DAY'end},{__index=World}) + w.runner=Runner.new(g,w);g.overworld=w + debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,g) + return g,w +end +-- Upstream controller methods return no admission value. Observe their +-- actual queue growth without changing that native return protocol. +local function incoming(w) + local count=#(w.pendingScripts or {}) + w:checkIncomingPhoneCall() + return #(w.pendingScripts or {})>count +end +local function special(w) + local count=#(w.pendingScripts or {}) + w:checkSpecialPhoneCall() + return #(w.pendingScripts or {})>count +end +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end +local oldRandom=math.random;math.random=function()return 0 end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture() + T.eq(Calls.phoneName(d,7),'LOCAL JOEY','unmodified contact still reads the actual native roster') + local r=load(d,[[ + mod.exports.seen={} + mod.content.phone_contacts:patch('phone:7',{name='AUTHORED JOEY'}) + mod.exports.stop=mod.events:on("phone.call_received",function(e) + mod.exports.seen[#mod.exports.seen+1]={contact=e.contact,name=e.name,className=e.className, + special=e.special,scriptKey=e.scriptKey,call={kind=e.call.kind,contact=e.call.contact, + direction=e.call.direction,class=e.call.class,member=e.call.member,scriptKey=e.call.scriptKey}} + e.contact=255;e.call.contact=255;e.call.scriptKey='MUTATED';return false + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public event registration succeeds') + local e=r.loader.exports.phone_event;local g,w=gameFor(d) + T.eq(incoming(w),true,label..' actual native controller admits a random call') + T.eq(#e.seen,1,label..' admitted random call emits exactly once') + local seen=e.seen[1] + T.eq(seen.contact,7,label..' notification exposes the selected native contact ID') + T.eq(seen.name,'AUTHORED JOEY',label..' caller name consumes the actual native content override') + T.eq(seen.className,'LOCAL CLASS',label..' caller class uses native display text') + T.eq(seen.special,nil,label..' random call has no fabricated special ID') + T.eq(seen.scriptKey,'IN',label..' notification names the actual compiled native key') + T.eq(seen.call.contact,7,label..' descriptor and notification agree') + T.eq(seen.call.direction,'incoming',label..' descriptor retains incoming direction') + T.eq(seen.call.class,'YOUNGSTER',label..' descriptor class uses explicit canonical metadata') + T.eq(seen.call.member,'JOEY1',label..' descriptor member uses explicit canonical metadata') + T.eq(g.save.g2CurCaller,7,label..' observer mutation cannot change the real caller') + T.eq(w.pendingScripts[1].extra.phoneCaller,7,label..' observer mutation cannot redirect the queued call') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,71,label..' actual selected receive program still executes') + T.eq(g.save.g2CurCaller,nil,label..' actual completion clears caller ownership') + T.eq(d.map_scripts.phone[7].receive,'IN',label..' observer mutation cannot rewrite imported contact') + g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(special(w),true,label..' actual controller admits the queued outside special') + T.eq(#e.seen,2,label..' admitted special emits exactly once') + seen=e.seen[2] + T.eq(seen.contact,4,label..' special exposes its actual caller') + T.eq(seen.name,'LOCAL ELM',label..' non-trainer name comes from actual contact data') + T.eq(seen.className,nil,label..' non-trainer has no fabricated trainer class') + T.eq(seen.special,1,label..' special ID survives disarming of its pending queue') + T.eq(seen.scriptKey,'SPECIAL',label..' wrapper script overrides contact receive key') + T.eq(w.pendingScripts[1].extra.phoneCaller,4,label..' observer mutation cannot redirect the special') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,73,label..' actual special receive program executes') + T.eq(g.save.g2SpecialCallActive,nil,label..' native specialphonecall NONE clears active ownership') + T.eq(g.save.g2CurCaller,nil,label..' special completion clears real caller ownership') + T.eq(special(w),false,label..' completed special cannot notify twice') + T.eq(#e.seen,2,label..' completed queue has no phantom event') + g,w=gameFor(d);w.transitioning=true + T.eq(incoming(w),false,label..' transition rejects a random candidate') + g.save.g2SpecialCall=1 + T.eq(special(w),false,label..' transition rejects a special candidate') + g,w=gameFor(d);g.save.g2SpecialCall=1;w.map.def.environment=3 + T.eq(special(w),false,label..' indoor map keeps an outside-only call pending') + T.eq(g.save.g2SpecialCall,1,label..' rejected special remains queued') + T.eq(#e.seen,2,label..' rejected candidates never notify') + g,w=gameFor(d) + local gear=setmetatable({game=g},{__index=require('src.ui.PokegearMenu')}) + T.eq(gear:runCallScript(7),true,label..' actual outgoing native call executes') + T.eq(#e.seen,2,label..' outgoing call cannot raise an incoming notification') + e.stop();g,w=gameFor(d) + T.eq(incoming(w),true,label..' unsubscribe preserves native incoming behavior') + T.eq(#e.seen,2,label..' unsubscribe stops event delivery') + Loader.teardown();r.release() + end +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture();local r=load(d,[[ + mod.exports.count=0 + mod.events:on("phone.call_received",function()mod.exports.count=mod.exports.count+1 end) + ]]) + T.eq(#r.errors,0,version..' native listener registration remains available without a native target') + local g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(incoming(w),false,version..' native random call gate remains isolated') + T.eq(special(w),false,version..' native special call gate remains isolated') + T.eq(r.loader.exports.phone_event.count,0,version..' no unrelated generation event') + Loader.teardown();r.release() +end +do + V.set('crystal');local d=fixture();local r=load(d,[[ + mod.events:on("phone.call_received",function()error('rolled-back listener ran')end) + error('entry failure') + ]]) + T.check(#r.errors>0,'failed entry is reported') + T.eq(require('src.mods.Runtime').wants("phone.call_received"),false,'failed entry rolls back its actual event listener') + local g,w=gameFor(d) + T.eq(incoming(w),true,'failed listener cannot block the actual native incoming call') + Loader.teardown();r.release() +end +debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,oldGame) +Sound.play=oldSound;math.random=oldRandom;V.set(original) +T.finish('native phone.call_received from actual random/special controller queues and receive programs') diff --git a/tests/engine/gen2_pokegear_presentation.lua b/tests/engine/gen2_pokegear_presentation.lua new file mode 100644 index 000000000..305d80b69 --- /dev/null +++ b/tests/engine/gen2_pokegear_presentation.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +local Gear=require('src.ui.PokegearMenu') +local Radio=require('src.world.Gen2Radio') +local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() +data.gen2RadioChannels.OAKS_POKEMON_TALK.name='AUTHORED RADIO' +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local game={data=data,save={flags={ENGINE_RADIO_CARD=true}},stack=stack} +local function press(menu,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + menu:update(1/60) +end +-- A source renderer must observe the card reached through native input, +-- not an independent presentation cursor defaulting to CLOCK. +local menu=Gear.new(game);stack:push(menu) +T.eq(menu:card(),menu:current(),'source and native consumers share selected row') +T.eq(menu:cardLabel(),'CLOCK','source label resolves actual selected native card') +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(menu:card().id,'RADIO','native card navigation reaches the source renderer') +local stations=menu:stations() +T.eq(stations[1].name,'AUTHORED RADIO','source tuner consumes mounted authored name') +T.eq(stations[1].station,'OAKS_POKEMON_TALK','source tuner receives stable station identity') +T.eq(stations[1].frequency,'04.5','frequency derives from existing native knob units') +T.eq(menu:currentStation().station,'OAKS_POKEMON_TALK','source selection reads native tuner cursor') +T.check(menu.radio and menu.radio.top=='PROF.OAK: Hello!','source panel sees the actual native program line') +local view=menu.radio;press(menu,'a') +T.eq(menu.radio,view,'advancing a program retains its live presentation object') +T.eq(menu.radio.top,'Wild #MON habits.','native line advancement reaches source panel') +press(menu,'up') +T.eq(menu:currentStation().station,'POKEDEX_SHOW','native retune changes source selection') +T.eq(menu.radio.station,'POKEDEX_SHOW','native retune changes the presented broadcast') +T.eq(menu.radio.top,'DJ MARY: Hi!','retune resets visible program text') +stations[1].name='LEAK';stations[1].lines[1]='LEAK' +T.eq(menu:stations()[1].name,'AUTHORED RADIO','source station rows are detached') +T.eq(menu:stations()[1].lines[1],'PROF.OAK: Hello!','source line snapshots cannot corrupt program text') +press(menu,'left');T.eq(menu.radio,nil,'leaving radio clears its active display') +press(menu,'right');T.check(menu.radio~=nil,'returning to radio restores a current program view') +press(menu,'b');T.eq(menu.radio,nil,'closing stops presenting a stale broadcast') +T.eq(stack:top(),nil,'native close retains its original stack ownership') +T.eq(menu:drawsWidescreen(),false,'unmodified native presentation stays on its original canvas') +local wide=menu.drawWidescreen +menu.drawWidescreen=function()end +T.eq(menu:drawsWidescreen(),true,'replacement window drawer opts into source presentation') +menu.drawWidescreen=wide +T.eq(menu:drawsWidescreen(),false,'removing replacement restores native composition') +data.gen2RadioChannels={};menu=Gear.new(game) +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(#menu:stations(),0,'removed stations stay absent from source presentation') +T.eq(menu:currentStation(),nil,'empty tuner has no invented source selection') +T.eq(menu.radio,nil,'empty tuner has no fabricated broadcast') +V.set('red');menu=Gear.new(game) +T.eq(#menu:stations(),0,'other generation cannot expose native GSC stations') +V.set(original) +T.finish('native Pokegear input and source presentation agree') diff --git a/tests/engine/gen2_radio_audio.lua b/tests/engine/gen2_radio_audio.lua new file mode 100644 index 000000000..d0626b092 --- /dev/null +++ b/tests/engine/gen2_radio_audio.lua @@ -0,0 +1,127 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +-- Replace only the device. Actual Music, native input, StateStack, VM and +-- ScriptRunner own every transition below. +local oldAudio=love.audio +local starts=0 +love.audio={newSource=function(file) + local s={file=file,playing=false} + function s:play()self.playing=true;starts=starts+1 end + function s:stop()self.playing=false end + function s:pause()self.playing=false end + function s:isPlaying()return self.playing end + function s:setLooping()end + function s:setVolume()end + function s:setFilter()end + return s +end} +local Music=require('src.core.Music') +local Radio=require('src.world.Gen2Radio') +local Audio=require('src.world.Gen2RadioAudio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack') +local function gameFor() + local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() + data.audio={songs={},mapSongs={A='MAP_A',B='MAP_B'},battle={wild='BATTLE'}} + for _,id in ipairs({'MAP_A','MAP_B','BATTLE','ALT','Music_PokeFluteChannel', + 'Music_ProfOaksPokemonTalk','Music_PokemonCenter','Music_RocketTheme'})do + data.audio.songs[id]={file=id..'.ogg'} + end + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='parent'}) + return {data=data,save={flags={ENGINE_RADIO_CARD=true,ENGINE_EXPN_CARD=true}}, + stack=stack,overworld={player={cellX=33,cellY=8},map={def={landmark=1}}}} +end +local function press(game,screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function open(game) + local menu=Gear.new(game);game.stack:push(menu) + for _=1,#menu.cards do if menu:current().id~='RADIO' then press(game,menu,'right')end end + return menu +end +local function flute(game,menu) + for _=1,#menu:stationList()do + if not (menu:currentStation() and menu:currentStation().flute)then press(game,menu,'up')end + end +end +local function gate(game,want,label) + local runner=Runner.new(game,game.overworld) + runner:run(assert(VM.compile({map_scripts={scripts={probe={{'special',95,'SnorlaxAwake'},{'end'}}}}},'probe'))) + T.eq(runner.ctx.g2Var,want and 1 or 0,label..' native byte') + T.eq(runner.ctx.lastCheck,want,label..' native branch') + T.eq(runner:isRunning(),false,label..' VM terminates') +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);Music.reload();local game=gameFor() + Music.playMap(game.data,'A',false,false) + gate(game,false,version..' EXPN without music refuses') + game.save.g2RadioChannel='POKE_FLUTE';game.save.g2MapMusic='MUSIC_POKE_FLUTE_CHANNEL' + gate(game,false,version..' stale save hints refuse') + local menu=open(game) + T.eq(Music.current(),'Music_ProfOaksPokemonTalk',version..' real native tuner starts its station') + local count=starts;press(game,menu);press(game,menu,'a') + T.eq(starts,count,version..' idle/text advancement does not restart audio') + flute(game,menu) + T.eq(Music.current(),Audio.FLUTE,version..' tuner plays actual flute Source') + T.eq(Music.mapSong(),'MAP_A',version..' open tuner retains previous map owner') + for _,cell in ipairs({{33,8},{34,10},{35,10},{36,8},{36,9}})do + game.overworld.player.cellX,game.overworld.player.cellY=cell[1],cell[2] + gate(game,true,version..' each native adjacent cell') + end + game.overworld.player.cellX,game.overworld.player.cellY=0,0 + game.overworld.player.x,game.overworld.player.y=33,8 + gate(game,false,version..' render coordinates cannot bypass local proximity') + game.overworld.player.cellX,game.overworld.player.cellY=33,8 + press(game,menu,'b') + T.eq(Music.mapSong(),Audio.FLUTE,version..' normal close transfers radio ownership') + T.eq(Music.current(),Audio.FLUTE,version..' normal close keeps flute playing') + Music.playBattle(game.data,'wild');gate(game,false,version..' battle music cannot wake') + Music.restoreMap(game.data);gate(game,true,version..' battle restoration retains tuned flute') + Music.playMap(game.data,'B',false,false) + T.eq(Music.mapSong(),'MAP_B',version..' next map replaces radio owner') + gate(game,false,version..' next map cannot retain stale flute admission') + menu=open(game);flute(game,menu);press(game,menu,'down') + T.eq(Music.current(),nil,version..' missing channel audio cannot leave flute sounding') + gate(game,false,version..' failed retune cannot keep flute admission') + press(game,menu,'b');T.eq(Music.current(),'MAP_B',version..' failed station restores map on exit') + game.save.flags.ENGINE_EXPN_CARD=nil;menu=open(game) + for _,station in ipairs(menu:stationList())do T.eq(station.flute,nil,version..' flute stays locked without EXPN')end + press(game,menu,'b') +end +V.set('crystal');Music.reload();local game=gameFor();Music.playMap(game.data,'A') +local menu=open(game);flute(game,menu);Music.play(game.data,'ALT');press(game,menu,'b') +T.eq(Music.current(),'ALT','external playback is not replaced when closing covered owner') +T.eq(Music.mapSong(),'MAP_A','external owner prevents stale flute transfer') +-- A mod's music selection hook remains authoritative and does not become a +-- forged flute song just because the dial points at that channel. +local Runtime=require('src.mods.Runtime');local oldHooks=Runtime.hooks +Runtime.hooks=require('src.mods.Hooks').new() +Runtime.hooks:wrap('music.select',function(nextSong,song,ctx) + local selected=nextSong(song,ctx);return selected==Audio.FLUTE and 'ALT' or selected +end,nil,'radio-audio-test') +menu=open(game);flute(game,menu) +T.eq(Music.current(),'ALT','station tuning respects actual hook-selected audio') +gate(game,false,'hook replacement is not flute playback') +press(game,menu,'b');T.eq(Music.mapSong(),'ALT','actual selected song transfers to map owner') +Runtime.hooks=oldHooks +Music.playMap(game.data,'A') +local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={wall={{'setval',8},{'special',40,'MapRadio'},{'setval',77},{'end'}}}}},'wall'))) +local wall=game.stack:top() +T.eq(Music.current(),'Music_RocketTheme','native imported wall-radio special plays its channel') +T.eq(runner:isRunning(),true,'wall music does not bypass native announcement wait') +for _=1,100 do press(game,wall)end +press(game,wall,'b') +T.eq(runner.ctx.g2Var,77,'wall close resumes native script after transferring music') +T.eq(Music.mapSong(),'Music_RocketTheme','wall close transfers actual channel music') +T.eq(Music.current(),'Music_RocketTheme','wall song continues after close') +local current=Music.current();V.set('red') +T.eq(Audio.tune(game,'POKE_FLUTE_RADIO'),nil,'other generations reject native station tuning') +T.eq(Music.current(),current,'generation rejection leaves current audio intact') +Music.reload();love.audio=oldAudio;V.set(original) +T.finish('native radio audio ownership and actual Snorlax VM admission') diff --git a/tests/engine/gen2_radio_content.lua b/tests/engine/gen2_radio_content.lua new file mode 100644 index 000000000..a5fa1054e --- /dev/null +++ b/tests/engine/gen2_radio_content.lua @@ -0,0 +1,170 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Loader=require('src.mods.Loader') +local Json=require('src.link.Json') +local Radio=require('src.world.Gen2Radio') +local Gear=require('src.ui.PokegearMenu') +local Runtime=require('src.mods.Runtime') +local original=V.get() +local function size(t)local n=0;for _ in pairs(t or {})do n=n+1 end;return n end +local function load(data,source,api,format,extra) + -- The real hot-reload path reconstructs engine-seeded namespaces before + -- loading mods. Retain the mounted radio target under test. + for root in pairs(require('src.mods.Builtins').namespaceRoots())do data[root]=nil end + local files={} + if source then + files['mods/radio/manifest.json']=Json.encode({id='radio',name='Radio contract',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/radio/main.lua']='local mod=...;mod.exports.api=mod; '..source + end + for k,v in pairs(extra or {})do files[k]=v end + return T.sdk.loadMods(source and {'mods/radio'} or {},{data=data,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function fixture() + local d=T.fixtures.fresh();d.maps.PALLET_TOWN={landmark=47};return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local r=load(d) + T.eq(#r.errors,0,'no-mod load succeeds: '..version) + T.eq(d.gen2RadioChannels,nil,'no-mod virtual base creates no namespace') + T.eq(size(Radio.records(d)),11,'all native station identities are readable') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),6,'vanilla native tuner count retained') + T.eq(gear:stationList()[1].name,"OAK'S POKéMON TALK",'native program name retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),'POKEDEX_SHOW','morning Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=1},'DAY'),'OAKS_POKEMON_TALK','day Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=47},'MORNING'),'PLACES_AND_PEOPLE','imported Kanto boundary wins over time') + gear.game.save.flags={ENGINE_EXPN_CARD=true} + T.eq(#gear:stationList(),7,'EXPN keeps the native flute slot') + finish(r) +end +V.set('crystal') +for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture() + local r=load(d,[[ + local radio=mod.content["radio_channels"] + local row=assert(radio:get('OAKS_POKEMON_TALK'));row.name='BORROWED' + assert(radio:get('OAKS_POKEMON_TALK').name~='BORROWED') + for id,value in radio:each()do value.name='ITERATOR MUTATION' end + assert(radio:get('OAKS_POKEMON_TALK').name~='ITERATOR MUTATION') + radio:patch('OAKS_POKEMON_TALK',{name='AUTHORED TALK'}) + radio:register('PIRATE_RADIO',{channel=9,name='PIRATE RADIO'}) + radio:remove('POKEDEX_SHOW') + radio:patch('LUCKY_CHANNEL',{channel=11}) + mod.events:on("radio.channel",function(e) + mod.exports.event={station=e.station,channel=e.channel,name=e.name,source=e.source} + e.name='CALLBACK MUTATION' + end) + ]],mode[1],mode[2]) + T.eq(#r.errors,0,'real loader accepts radio contract '..mode[1]..'/'..mode[2]) + local e=r.loader.exports.radio + T.eq(d.gen2RadioChannels.OAKS_POKEMON_TALK.name,'AUTHORED TALK','merge reaches actual native station records') + T.eq(Radio.resolve(d,9),'PIRATE_RADIO','wall radio consumes a new registered station') + T.eq(Radio.resolve(d,11),'LUCKY_CHANNEL','wall radio consumes a changed channel number') + T.eq(Radio.channelRecord(d,4),nil,'previous channel number is not retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),nil,'removed morning station cannot resurrect') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),5,'native tuner consumes removal') + T.eq(gear:stationList()[1].name,'AUTHORED TALK','native tuner consumes authored name') + T.eq(gear:stationList()[1].nameLiteral,true,'authored text bypasses engine string catalog') + T.eq(Radio.isLiteral(d,'LUCKY_CHANNEL'),false,'channel-only patch retains built-in translation ownership') + Radio.notify(d,'PIRATE_RADIO',9,'map_radio') + T.eq(e.event.station,'PIRATE_RADIO','real mod event receives station identity') + T.eq(e.event.name,'PIRATE RADIO','real mod event receives authored station name') + T.eq(e.event.channel,9,'real mod event receives script dial number') + T.eq(e.event.source,'map_radio','real mod event identifies wall producer') + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','callback mutation cannot edit content') + local borrowed=e.api.content.radio_channels:get('PIRATE_RADIO');borrowed.name='OUTSIDE' + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','post-load get is detached') + T.raises(function()e.api.content.radio_channels:patch('PIRATE_RADIO',{name='LATE'})end,'frozen','station mutations freeze after load') + finish(r) + r=load(d) + T.eq(d.gen2RadioChannels,nil,'reload without mods removes generated namespace') + T.eq(Radio.resolve(d,2),'POKEDEX_SHOW','reload restores removed native station') + T.eq(Radio.channelRecord(d,9),nil,'reload removes mod-only station') + finish(r) +end +-- Failed entries must roll back earlier valid edits and event subscriptions. +for _,bad in ipairs({ + "r:register('DUPLICATE',{channel=1,name='bad'})", + "r:patch('LUCKY_CHANNEL',{channel=256})", + "r:patch('LUCKY_CHANNEL',{channel=0/0})", + "r:patch('LUCKY_CHANNEL',{name=123})", + "local cycle={};cycle.self=cycle;r:register('CYCLE',cycle)", + "r:register('ROCKET_RADIO',{channel=10})", +})do + local d=fixture();local r=load(d,"local r=mod.content.radio_channels;r:patch('OAKS_POKEMON_TALK',{name='SHOULD ROLLBACK'});"..bad) + T.eq(#r.errors,1,'malformed/ambiguous station fails one mod') + T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),"OAK'S POKéMON TALK",'failed entry cannot leak prior valid edit') + T.eq(d.gen2RadioChannels,nil,'failed entry leaves no namespace') + finish(r) +end +-- External edits between hot reloads survive without retaining old mod rows. +local d=fixture();local r=load(d,[[ + mod.content.radio_channels:register('PIRATE_RADIO',{channel=9,name='MOD'}) + mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='MOD TALK'}) +]]) +d.gen2RadioChannels.OAKS_POKEMON_TALK.name='EXTERNAL' +d.gen2RadioChannels.LUCKY_CHANNEL=nil +finish(r);r=load(d) +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'EXTERNAL','external edit survives reload') +T.eq(Radio.channelRecord(d,9),nil,'external edit does not pin stale mod-only rows') +T.eq(d.gen2RadioChannels.LUCKY_CHANNEL,nil,'external removal survives reload') +T.eq(Radio.isLiteral(d,'OAKS_POKEMON_TALK'),true,'external authored name stays literal') +finish(r) +d=fixture();d.gen2RadioChannels={AUTHORED={channel=22,name='BASE'}} +local base=d.gen2RadioChannels.AUTHORED +r=load(d,[[ + local r=mod.content.radio_channels + local row={channel=23,name='REPLACEMENT'} + r:override('AUTHORED',row);row.name='LEAK' + r:patch('AUTHORED',{name=mod.DELETE}) + r:register('TEMP',{channel=24,name='TEMP'}) +]]) +T.eq(#r.errors,0,'override and optional field deletion are valid') +T.eq(d.gen2RadioChannels.AUTHORED.name,nil,'DELETE removes optional authored name') +T.eq(d.gen2RadioChannels.AUTHORED.channel,23,'override replaces original dial position') +T.eq(Radio.isLiteral(d,'AUTHORED'),false,'deleted authored name no longer claims literal ownership') +finish(r);r=load(d) +T.eq(d.gen2RadioChannels.AUTHORED,base,'unmodified authored base row identity is restored') +T.eq(d.gen2RadioChannels.AUTHORED.name,'BASE','reload restores original authored name') +T.eq(d.gen2RadioChannels.TEMP,nil,'reload removes temporary station from authored dataset') +finish(r) +-- A failed contribution must not roll back a preceding successful owner. +d=fixture() +local extras={ + ['mods/broken/manifest.json']=Json.encode({id='broken',name='Broken radio',version='1',api=2,entry='main.lua',dependencies={'radio'}}), + ['mods/broken/main.lua']=[[ + local mod=...;mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='BROKEN'}) + mod.events:on("radio.channel",function()error('leaked failed listener')end) + mod.content.radio_channels:register('BAD',{channel=1}) + ]], +} +r=load(d,"mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='GOOD'})",2,'native',extras) +T.eq(#r.errors,1,'only dependent broken contributor fails') +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'GOOD','successful contribution survives later rollback') +local errors=#r.errors;Radio.notify(d,'OAKS_POKEMON_TALK',1,'map_radio') +T.eq(#r.errors,errors,'failed contributor listener is removed with its operations') +finish(r) +d=fixture();d.gen2RadioChannels={};r=load(d) +T.eq(size(Radio.records(d)),0,'explicit empty authored namespace stays authoritative') +T.eq(#setmetatable({game={data=d,save={}}},{__index=Gear}):stationList(),0,'empty authored tuner cannot resurrect defaults') +finish(r) +for _,version in ipairs({'red','emerald','platinum','black'})do + V.set(version);d=T.fixtures.fresh() + r=load(d,[[ + assert(mod.content.radio_channels:get('OAKS_POKEMON_TALK')==nil) + assert(mod.content.radio_channels:each()()==nil) + assert(mod.content.radio_channels:register('NOT_NATIVE',{channel=9})==nil) + ]],2,'native') + T.eq(#r.errors,0,'other target has honest unsupported contract: '..version) + T.eq(d.gen2RadioChannels,nil,'other generation creates no radio target') + T.eq(next(r.loader.content.radio_channels.ops),nil,'other generation appends no operation') + finish(r) +end +V.set(original) +T.finish('native radio content, consumers, target isolation and rollback') diff --git a/tests/engine/gen2_radio_runtime.lua b/tests/engine/gen2_radio_runtime.lua new file mode 100644 index 000000000..c791f3dbe --- /dev/null +++ b/tests/engine/gen2_radio_runtime.lua @@ -0,0 +1,88 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +require('src.core.ModCompat').install() +local Radio=require('src.world.Gen2Radio') +local Wall=require('src.ui.gen2.MapRadio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Runtime=require('src.mods.Runtime') +local oldEvents=Runtime.events;local events=require('src.mods.Events').new();Runtime.events=events +local emitted={};events:on("radio.channel",function(e)emitted[#emitted+1]=e end) +local data=T.fixtures.fresh();require('src.render.Font').load(data) +data.maps.PALLET_TOWN={landmark=47} +data.gen2RadioChannels=Radio.defaults();data.gen2RadioChannels.TEST_RADIO={channel=9,name='TEST RADIO'} +local game={data=data,save={},stack=setmetatable({},{__index=require('src.core.StateStack')}),overworld={map={def={landmark=1}}}} +game.stack:init() +function game.overworld:timeOfDay()return 'MORNING'end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local parent={name='test parent'};game.stack:push(parent) +for _,channel in ipairs({0,1,9})do + local completed=0 + local ir={map_scripts={scripts={probe={{'setval',channel},{'special',0,'MapRadio'},{'setval',77},{'end'}}}}} + local rows=assert(VM.compile(ir,'probe')) + local runner=Runner.new(game,game.overworld) + local before=#emitted + runner:run(rows,{lastCheck=false,onDone=function()completed=completed+1 end}) + local wall=game.stack:top() + T.eq(getmetatable(wall),Wall,'actual special opens the native wall UI') + T.check(runner:isRunning(),'native VM waits for wall radio input') + T.eq(runner.ctx.g2Var,channel,'opening preserves the script dial byte') + T.eq(runner.ctx.lastCheck,channel~=0,'opening retains the result of the preceding setval') + T.eq(#emitted,before+1,'actual tune emits once') + T.eq(emitted[#emitted].channel,channel,'event preserves actual script channel') + T.eq(emitted[#emitted].source,'map_radio','event identifies native wall special') + T.eq(wall.station,channel==0 and 'POKEDEX_SHOW' or channel==9 and 'TEST_RADIO' or 'OAKS_POKEMON_TALK','native imported-map/time resolution') + T.eq(game.save.g2Roamers,nil,'opening wall radio does not initialize unrelated roaming state') + wall:draw() + for _=1,100 do press(wall,'b')end + T.eq(game.stack:top(),wall,'hundred-frame station announcement ignores exit input') + T.eq(completed,0,'announcement cannot resume script early') + wall:draw() + local cover={name='cover'};game.stack:push(cover) + T.eq(wall:close(),false,'covered radio cannot pop another screen') + T.eq(completed,0,'covered radio cannot resume its parent') + T.eq(game.stack:top(),cover,'covered close preserves top owner') + game.stack:pop();press(wall,channel==9 and 'a' or 'b') + T.eq(game.stack:top(),parent,'closing returns to the existing parent') + T.eq(completed,1,'native script resumes exactly once') + T.eq(runner.ctx.g2Var,77,'real VM executes the instruction following the special') + T.eq(runner:isRunning(),false,'real script terminates') + wall:close();T.eq(completed,1,'repeated close cannot replay script continuation') + T.eq(#emitted,before+1,'draw and close do not emit extra tuning events') +end +game.overworld.map.def.landmark=47 +local wall=Wall.new(game,{channel=0}) +T.eq(wall.station,'PLACES_AND_PEOPLE','real loaded map definition selects Kanto') +local alternate={flags={ENGINE_EXPN_CARD=true}} +wall=Wall.new(game,{channel=9,save=alternate}) +T.eq(wall.game.save,alternate,'caller-owned save is retained') +T.eq(game.save==alternate,false,'alternate save does not replace player save') +data.gen2RadioChannels={} +local completed=0;local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={empty={{'setval',1},{'special',0,'MapRadio'},{'end'}}}}},'empty')), + {lastCheck=false,onDone=function()completed=completed+1 end}) +T.eq(game.stack:top(),parent,'empty station registry cannot strand an empty UI') +T.eq(completed,1,'unavailable station allows script to continue') +T.eq(runner.ctx.lastCheck,true,'unavailable station preserves preceding setval result') +data.gen2RadioChannels=Radio.defaults() +game.save.flags={ENGINE_RADIO_CARD=true} +local gear=Gear.new(game) +for i,card in ipairs(gear.cards)do if card.id=='RADIO' then gear.index=i end end +T.eq(gear:current().id,'RADIO','actual owned card selects native radio') +local before=#emitted +press(gear);press(gear) +T.eq(#emitted,before+1,'Pokegear emits initial tune once') +T.eq(emitted[#emitted].source,'pokegear','Pokegear identifies the other real producer') +press(gear,'up') +T.eq(#emitted,before+2,'changing native tuner emits once') +T.eq(emitted[#emitted].station,'POKEDEX_SHOW','tuner event carries station identity') +press(gear,'a');T.eq(#emitted,before+2,'advancing program text does not retune') +V.set('red');T.raises(function()Wall.new(game)end,'Gen2 host','wall radio rejects another host') +Runtime.events=oldEvents;V.set(original) +T.finish('native radio VM, station lifecycle, Pokegear input and events') diff --git a/tests/engine/legacy_npc_controls.lua b/tests/engine/legacy_npc_controls.lua new file mode 100644 index 000000000..a70ed9da9 --- /dev/null +++ b/tests/engine/legacy_npc_controls.lua @@ -0,0 +1,134 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local function fixture(version) + local f = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", version, "legacy") + -- API 2 materializes its legacy world lazily; the fixture already owns a + -- constructed game by the time the exported lookup is called. + f.loader._game = function() + return f.live + end + f.legacy = f.loader + f.handle = assert(f.loader.exports.legacy.npc(f.home, "IMPORTED")) + return f +end +for _, version in ipairs({ "red", "gold", "firered" }) do + local f = fixture(version) + local h, actor = f.handle, f.imported + T.eq(h.ow, f.ow, "legacy world field remains the native world") + T.eq(h.npc, actor, "legacy actor field retains its original identity") + T.eq(h.id, actor.id, "legacy public identity is preserved") + T.eq(h:isMoving(), false, "new motion query reads native state") + T.eq(h:placeAt(1, 1, "right"), true, "immediate placement is available to API 2") + T.eq(actor.px, 16, "placement synchronizes native pixel coordinates") + T.eq(actor.py, 16, "placement synchronizes native pixel coordinates") + T.eq(h:canStep("right"), true, "query uses actual native map collision") + local queue = #f.ow.scriptMoves + T.eq(h:stepNow("right"), true, "immediate stepping starts native motion") + T.eq(#f.ow.scriptMoves, queue, "immediate step creates no script queue entry") + T.eq(actor.targetX, 2, "native target follows the requested direction") + T.eq(h:isMoving(), true, "motion query sees the started step") + T.eq(h:stepNow("down"), nil, "an active step cannot be overwritten") + for _ = 1, 16 do + actor:update(f.ow.map, f.ow.entities) + end + T.eq(actor.cellX, 2, "actual NPC updates complete the step") + T.eq(h:isMoving(), false, "actual NPC updates clear native motion") + T.eq(h:stepNow("right"), true, "another native step starts") + T.eq(h:placeAt(3, 4, "up"), true, "placement cancels an in-flight step") + T.eq(actor.targetX, nil, "placement clears the old native target") + T.eq(actor.progress, 0, "placement clears the old step phase") + T.eq(actor.moving, false, "placement clears the old movement flag") + T.eq(actor.facing, "up", "placement applies optional facing") + local peer = + f:make(f.home, { index = 2, name = "PEER", sprite = "PROBE", x = 4, y = 4, movement = "STAY", range = "DOWN" }) + f.ow.npcs[#f.ow.npcs + 1] = peer + f.ow.entities[#f.ow.entities + 1] = peer + T.eq(h:canStep("right"), false, "collision query sees a native peer") + local other = assert(f.legacy.exports.legacy.npc(f.home, "PEER")) + T.eq(other:setPassable(true), true, "API-2 passability control reaches the native actor") + T.eq(h:canStep("right"), true, "native collision honors the changed peer passability") + T.eq(other:setPassable(false), true, "passability can be restored") + T.eq(h:canStep("right"), false, "restored passability restores native blocking") + local def = actor.def + T.eq(h:setAppearance("ALT"), true, "appearance resolves through the native sprite path") + T.eq(actor.appearance, "ALT", "native appearance identity updates") + T.eq(actor.def.sprite, "ALT", "native actor definition uses the chosen sprite") + T.eq(def.sprite, "PROBE", "imported shared template is retained") + local changed = actor.def + T.eq(h:setAppearance("MISSING"), nil, "missing appearance is declined") + T.eq(actor.def, changed, "a declined appearance cannot half-change the actor") + T.eq(h:stepNow("north"), nil, "bad directions are declined") + T.eq(h:placeAt(0 / 0, 2), nil, "non-finite coordinates are declined") + T.eq(actor.cellX, 3, "invalid input leaves the native cell unchanged") + local x, y = h:position() + T.eq(x, 3, "existing position method remains available") + T.eq(y, 4, "existing position method retains its returns") + T.eq(h:face("left"), true, "existing facing method remains available") + T.eq(actor.facing, "left", "existing facing method retains its native effect") + Runtime.currentMod = "legacy" + T.eq(h:isMoving(), false, "new control is callable under mod attribution") + T.eq(Runtime.currentMod, "legacy", "new control restores caller attribution") + Runtime.currentMod = nil + f.game.save = {} + T.eq(h:placeAt(0, 0), nil, "retained new controls decline a replacement save") + T.eq(actor.cellX, 3, "stale control cannot change the previous native actor") + f.restoreRenderer() +end +for _, change in ipairs({ "rollback", "disabled", "map", "map_id", "fast_world", "removed", "version", "bus" }) do + local f = fixture("gold") + local h = f.handle + if change == "rollback" then + f.legacy:_rollback("legacy") + elseif change == "disabled" then + f.legacy.mods.legacy.enabled = false + elseif change == "map" then + f.ow.map = { id = "CHANGED" } + elseif change == "map_id" then + f.ow.map.id = "CHANGED" + elseif change == "fast_world" then + f.game.overworld = {} + elseif change == "removed" then + f.ow.npcs = {} + elseif change == "version" then + Version.set("silver") + else + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + end + T.eq(h:setPassable(true), nil, change .. " retires new controls") + T.eq(f.imported.passable, nil, change .. " does not change the old native actor") + f.restoreRenderer() +end + +do + local f = fixture("gold") + local Renderer = require("src.render.SpriteRenderer") + local old = Renderer.new + local def, sprite = f.imported.def, f.imported.sprite + Renderer.new = function() + error("owned sprite failure") + end + Runtime.currentMod = "legacy" + local ok, why = f.handle:setAppearance("ALT") + T.eq(ok, nil, "native sprite failures are contained") + T.check(why:find("owned sprite failure", 1, true) ~= nil, "native sprite failure remains diagnosable") + T.eq(Runtime.currentMod, "legacy", "failed native control restores caller attribution") + T.eq(f.imported.def, def, "failed native sprite resolution retains the old definition") + T.eq(f.imported.sprite, sprite, "failed native sprite resolution retains the old renderer") + Runtime.currentMod = nil + Renderer.new = old + f.restoreRenderer() +end + +do + local f = fixture("gold") + f.ow.map = nil + local h = assert(f.legacy.exports.legacy.npc(f.home, "IMPORTED")) + local value, why = h:isMoving() + T.eq(value, nil, "legacy lookup without a map cannot grant new controls") + T.eq(why, "NPC handle is stale", "missing-map control has a stable refusal") + f.restoreRenderer() +end +T.finish() diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..5d4562cd8 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,251 @@ +-- API availability is observable through the real loader, before Game/world +-- construction. No imported content, graphics, or network is required. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local GameVersion = require('src.core.GameVersion') +local Loader = require('src.mods.Loader') +local Runtime = require('src.mods.Runtime') +local WorldAPI = require('src.world.WorldAPI') +local Schemas = require('src.mods.Schemas') +local Catalog = require('tests.modkit.catalog') +local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors +local originalVersion = GameVersion.get() +local savedLove = love +love = nil + +local files = { + ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/probe/main.lua'] = [[ +return function(mod) + -- Runs inside the entry window, not after the loader has finished. + mod.exports.early = mod:supports('world', 'warpTo') + mod.exports.api = mod +end +]], +} +local fs = { + read = function(path) return files[path] end, + getDirectoryItems = function(path) + if path == 'mods' then return { 'probe' } end + return {} + end, + getInfo = function(path) + if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end + if files[path] then return { type = 'file' } end + end, + load = function(path) return loadstring(files[path], path) end, +} +local function probe(version) + GameVersion.set(version) + local data = { pokemon = {} } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error('supports must not construct Game') end + assert(loader:load(data)) + assert(loader.apis.probe, tostring(loader.errors.probe)) + return loader.apis.probe, loader, data +end +local function yes(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == true and reason == nil +end +local function no(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == false and type(reason) == 'string' and #reason > 0 +end + +-- Builtin registrants can allocate fresh closures on each load. Compare +-- record structure and values while treating function identity as opaque, +-- as the registry parity gate does. +local function record(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out +end + +local allHooks, allEvents = {}, {} +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, data = probe(version) + T.check(api.exports.early, version .. ': query succeeds inside entry chunk') + T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'), + version .. ': shared simulation/world call sites available') + local registries = true + for name in pairs(Schemas.REGISTRIES) do + registries = registries and yes(api, 'content', name) + end + T.check(registries, version .. ': all API-2 registration surfaces available') + T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', + version .. ': additive native flight interface is reported when installed') + T.check(no(api, 'hooks', 'core.logic_speed') + and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), + version .. ': namespaced user events do not need a producer installed') + for _, name in ipairs(Catalog.hooks()) do + if yes(api, 'hooks', name) then allHooks[name] = true end + end + for _, name in ipairs(Catalog.events()) do + if yes(api, 'events', name) then allEvents[name] = true end + end + -- Mutation of the handed-out metadata cannot change a capability answer. + local statusHud = api:supports('hooks', 'battle.status_hud_visible') + api.gameVersion, api.generation, api.host.version = 'red', 1, 'red' + GameVersion.set('red') + T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud, + version .. ': query retains captured host even after metadata/global changes') + T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil, + version .. ': probing installs no listeners or wrappers') + local pristine = { pokemon = {} } + GameVersion.set(version) + local vanilla = Loader.new({ fs = fs, noMods = true, dev = false }) + assert(vanilla:load(pristine)) + T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot') +end + +-- The union covers every existing native callback; stale catalog entries +-- also fail. This gate reads source rather than a second hand-kept name list. +T.same(allHooks, (function() + local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out +end)(), 'all native hook names have a queryable implementation') +T.same(allEvents, (function() + local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out +end)(), 'all native event names have a queryable implementation') + +-- Also reject advertised names whose native call site was removed. Read +-- the simple declaration lists without exporting mutable production state. +local h = assert(io.open('src/mods/Capabilities.lua', 'r')) +local body = h:read('*a'); h:close() +local declared = { hooks = {}, events = {} } +for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do + local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$') + assert(names, 'unrecognized capability declaration') + for name in names:gmatch('%S+') do declared[surface][name] = true end +end +T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites') +T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites') + +local api, loader = probe('crystal') +local noWorldBefore = package.loaded['src.world.WorldAPI'] +T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'), + 'world methods available independently of save/map state') +T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI') +for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do + for _, invalid in ipairs({ '', 1, false, {}, function() end }) do + T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason') + end + T.check(no(api, surface, nil), surface .. ': missing name declines with reason') + T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason') +end +for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do + T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason') +end +T.check(no(api, nil, 'anything'), 'missing surface declines with reason') +T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'), + 'empty user-event namespace/name is unavailable') +T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed') + and no(api, 'world', 'face') and no(api, 'world', '__index'), + 'constructors, internals and NPC-handle methods are not world capabilities') +T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'), + 'names are case-sensitive') + +-- Verify world catalog against the real public method table, not just names +-- duplicated in a test. A supported method may still decline without a map. +love = savedLove or require('tests.love_stub') +local WorldAPI = require('src.world.WorldAPI') +for name, method in pairs(WorldAPI) do + if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then + T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name) + end +end +local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch')) +for name in worldNames:gmatch('%S+') do + T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name) +end +local world = WorldAPI.new({}, 'probe') +local result, reason = world:warpTo('missing', 0, 0) +T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'), + 'availability is distinct from current-state eligibility') + +-- Version-specific routing is deliberately different from Gen1Recomp. +for _, version in ipairs(GameVersion.ORDER) do + local mod = probe(version) + local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, + version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') diff --git a/tests/engine/native_mod_seams.lua b/tests/engine/native_mod_seams.lua new file mode 100644 index 000000000..6d3794059 --- /dev/null +++ b/tests/engine/native_mod_seams.lua @@ -0,0 +1,234 @@ +-- Native producers, observed through a sandboxed mod loaded by the real SDK. +-- Do not emit these events from the test: a missing call site must fail here. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local GameVersion = require("src.core.GameVersion") +local Overworld = require("src.world.OverworldController") +local BirchSpeech = require("src.ui.BirchSpeech") +local Gen4Battle = require("src.battle.Gen4Battle") +local Pokemon = require("src.pokemon.Pokemon") + +local savedVersion = GameVersion.get() +local savedGame = {} +for _, key in ipairs({ "data", "save", "stack", "input", "overworld", "world", "renderer" }) do + savedGame[key] = { Game[key] } +end +GameVersion.set("red") +local data = T.fixtures.fresh() +local run = T.sdk.loadMods( + { "mods/seam_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/seam_probe/manifest.json"] = [[{ + "id":"seam_probe", "name":"Native seam probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/seam_probe/main.lua"] = [[ + local mod = ... + local seen = {} + mod.exports.seen = seen + local names = { + "intro.birch_speech.started", "intro.birch_speech.finished", + "pokemon.egg_hatched", "world.blocks_replaced", "world.tick", + } + for _, name in ipairs(names) do + seen[name] = {} + mod.events:on(name, function(payload) + local events = seen[name] + events[#events + 1] = payload + end) + end + mod.exports.hideHUD = true + mod.exports.hudCalls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.hudCalls = mod.exports.hudCalls + 1 + mod.exports.battle = battle + if mod.exports.hideHUD then return false end + return nextFn(battle) + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the public-API probe loads without errors") +local exports = assert(run.loader.exports.seam_probe) +local seen = exports.seen +local stack = { states = {} } +function stack:push(state) + self.states[#self.states + 1] = state +end +function stack:pop() + return table.remove(self.states) +end +function stack:top() + return self.states[#self.states] +end +Game.data, Game.stack = data, stack +Game.save = { player = { map = "FIX_TOWN", name = "TEST", id = 123 }, options = {} } +Game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, +} +Game.renderer = { + worldViewSize = function() + return 160, 144 + end, +} + +-- enter() establishes the native module's Game reference and real runner. +-- Map loading is the ROM/graphics boundary; provide a tiny idle map there. +local map = { + id = "FIX_TOWN", + def = { width = 2, height = 2 }, + blockPatch = {}, + renderer = { + rebuilds = 0, + rebuild = function(self) + self.rebuilds = self.rebuilds + 1 + end, + }, + warpAtCell = function() + return nil + end, +} +function map:blockAt(bx, by) + return self.blockPatch[by * 2 + bx + 1] or 0 +end +local world = setmetatable({ + setMap = function(self) + self.map = map + self.player = { + cellX = 0, + cellY = 0, + px = 0, + py = 0, + update = function() + return false + end, + } + self.npcs, self.ghosts, self.entities, self.cast = {}, {}, {}, {} + end, +}, { __index = Overworld }) +world:enter("FIX_TOWN", 0, 0, "down") + +-- Birch start and finish reach subscribers at their real lifecycle boundary. +GameVersion.set("emerald") +data.constants.gen3Intro = { lines = { "WELCOME", "WORLD", "PLAYER", "GENDER" } } +data.text.WELCOME, data.text.GENDER = "Welcome!", "Boy or girl?" +local done = false +local speech = BirchSpeech.new(Game, function() + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#seen["intro.birch_speech.started"], 1, "enter publishes one Birch start") +T.eq(seen["intro.birch_speech.started"][1].speech, speech, "start identifies the live speech") +T.check(stack:top() ~= speech, "start advances into the first real text box") +stack:pop() +speech.answers = { name = "TEST", gender = "girl" } +speech:finish() +T.eq(#seen["intro.birch_speech.finished"], 1, "finish publishes one Birch completion") +local finished = seen["intro.birch_speech.finished"][1] +T.eq(finished.speech, speech, "finish identifies the same speech") +T.eq(finished.answers, speech.answers, "finish carries the recorded answers") +T.check(done and #stack.states == 0, "finish then closes the speech and calls its continuation") + +-- Batch block changes publish only actual writes, clipped to map dimensions. +GameVersion.set("red") +T.eq(world:replaceBlocks({ 1, 0, 2, 0, 99 }), true, "native batch changes map blocks") +T.eq(#seen["world.blocks_replaced"], 1, "batch emits one event") +T.eq(seen["world.blocks_replaced"][1].mapId, "FIX_TOWN", "batch reports the map") +T.eq(seen["world.blocks_replaced"][1].count, 2, "batch counts changed cells only") +T.eq(map.blockPatch[1], 1, "first block is written") +T.eq(map.blockPatch[3], 2, "second changed block is written") +T.eq(map.blockPatch[5], nil, "extra input cannot extend the map") +T.eq(map.renderer.rebuilds, 1, "batch rebuilds once") +T.eq(world:replaceBlocks({ 1, 0, 2, 0 }), false, "identical batch does nothing") +T.eq(world:replaceBlocks(nil), false, "missing batch does nothing") +T.eq(#seen["world.blocks_replaced"], 1, "no-op batches publish no event") + +-- A headless logic update emits even though draw() was never called. +world:update(1 / 60) +T.eq(#seen["world.tick"], 1, "native update publishes one simulation tick") +local tick = seen["world.tick"][1] +T.eq(tick.dt, 1 / 60, "tick carries the simulation dt") +T.eq(tick.mapId, "FIX_TOWN", "tick carries the active map") +T.eq(tick.overworld, world, "tick identifies the native world") +T.eq(tick.scripted, false, "an idle runner is reported as idle") +world.runner:run({ { "wait", 5 } }) +T.eq(world.runner:isRunning(), true, "the native wait command holds a script open") +world:update(1 / 30) +T.eq(#seen["world.tick"], 2, "scripted updates still publish exactly one tick") +T.eq(seen["world.tick"][2].scripted, true, "tick reports the real running script") +T.eq(seen["world.tick"][2].dt, 1 / 30, "tick does not hardcode the frame interval") + +-- Hatching is announced after the native dialogue callback, with a healed +-- ordinary Pokemon rather than an egg. Use Emerald to avoid Johto's second +-- nickname prompt; the native hatch method itself remains under test. +GameVersion.set("emerald") +local egg = Pokemon.new(data, "FIXMON_A", 5, function(a) + return a +end) +egg.isEgg, egg.eggSteps, egg.eggCycles = true, 0, 0 +egg.hp, egg.status = 1, "PSN" +T.eq(world:hatchEgg(egg), true, "native hatch accepts the egg") +T.eq(#seen["pokemon.egg_hatched"], 0, "hatch is not announced before dialogue finishes") +local box = stack:pop() +T.check(box ~= nil, "hatch opens its native text box") +box.onDone() +T.eq(#seen["pokemon.egg_hatched"], 1, "dialogue completion publishes the hatch") +T.eq(seen["pokemon.egg_hatched"][1].mon, egg, "hatch identifies the live Pokemon") +T.eq(egg.isEgg, nil, "the announced Pokemon is no longer an egg") +T.eq(egg.eggSteps, nil, "hatch clears egg step bookkeeping") +T.eq(egg.eggCycles, nil, "hatch clears egg cycle bookkeeping") +T.eq(egg.hp, egg.stats.hp, "hatch heals HP before publication") +T.eq(egg.status, nil, "hatch cures status before publication") +T.eq(egg.happiness, 120, "hatch applies base happiness before publication") +T.eq(world:hatchEgg(nil), false, "missing egg is rejected") +T.eq(#seen["pokemon.egg_hatched"], 1, "rejected hatch does not publish") + +-- A public wrapper can suppress native Sinnoh/Unova healthboxes before any +-- battler/graphics access. Returning true prevents fallback Game Boy panels. +GameVersion.set("platinum") +local battle = {} +T.eq(Gen4Battle.drawHealthboxes(battle), true, "hidden native HUD is handled") +T.eq(exports.hudCalls, 1, "native healthbox draw consults the public hook") +T.eq(exports.battle, battle, "the hook receives the live battle") +exports.hideHUD = false +T.eq( + Gen4Battle.drawHealthboxes(battle), + false, + "delegating preserves the native fallback when healthbox art is absent" +) +T.eq(exports.hudCalls, 2, "delegation still goes through the public wrapper") +exports.hideHUD = true +GameVersion.set("black") +T.eq(Gen4Battle.drawHealthboxes(battle), true, "the shared native path also hides Unova boxes") +T.eq(exports.hudCalls, 3, "Unova consults the same public wrapper") + +T.eq(#run.errors, 0, "native callbacks produce no attributed mod errors") +run.loader.events:removeOwner("seam_probe") +run.loader.hooks:removeOwner("seam_probe") +for _, name in ipairs({ + "intro.birch_speech.started", + "intro.birch_speech.finished", + "pokemon.egg_hatched", + "world.blocks_replaced", + "world.tick", +}) do + T.eq(Runtime.wants(name), false, "unloading removes subscription: " .. name) +end +T.eq(Runtime.wantsHook("battle.status_hud_visible"), false, "unloading removes the HUD hook") +run.release() +GameVersion.set(savedVersion) +for key, value in pairs(savedGame) do + Game[key] = value[1] +end +T.finish("native public mod seams") diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua new file mode 100644 index 000000000..ccef037d9 --- /dev/null +++ b/tests/engine/portable_world_scripts.lua @@ -0,0 +1,856 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local WorldAPI = require("src.world.WorldAPI") +local GameVersion = require("src.core.GameVersion") +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Json = require("src.link.Json") +local originalVersion = GameVersion.get() +local function boot(version) + GameVersion.set(version) + local data, files = T.fixtures.fresh(), {} + for _, id in ipairs({ "probe", "other", "legacy" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 2, + entry = "main.lua", + script_format = id ~= "legacy" and "unified" or nil, + content_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + callback_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + description = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and "all-formats" or "", + }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + if mod.id ~= "legacy" then + assert(mod.scriptFormat == "unified") + if mod.manifest.description == "all-formats" then + assert(mod.contentFormat == "unified") + assert(mod.callbackFormat == "unified") + end + local ok, why = mod.world:queueScript({ { "wait", 1 } }) + assert(ok == nil and type(why) == "string") + else assert(mod.scriptFormat == "native") end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other", "legacy" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" or p == "mods/legacy" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + T.eq(package.loaded["src.core.Game"], nil, "early portable request does not bootstrap Game") + Font.load(data) + local game = { + data = data, + save = SaveData.newGame(), + stack = setmetatable({ states = {} }, { __index = StateStack }), + } + local ow = { + isOverworld = true, + map = { id = "FIX_TOWN", def = data.maps.FIX_TOWN }, + player = { cellX = 4, cellY = 5, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + parallelRunners = {}, + } + game.overworld, ow.game = ow, game + ow.runner = Runner.new(game, ow) + function ow:update() + self.runner:update() + end + game.stack.states = { ow } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + loader.game = game + return loader.apis.probe.world, game, ow, loader, files +end +local function tick(game, key) + game.input.wasPressed = function(_, k) + return k == key + end + Runtime.call("input.step", function() end, game, 1 / 60) + game.stack:update(1 / 60) + game.input.wasPressed = function() + return false + end +end +local function untilState(game, predicate) + for _ = 1, 2000 do + if predicate() then + return + end + tick(game) + end + error("native UI did not reach requested state") +end +local function finishText(game) + local box = game.stack:top() + untilState(game, function() + return box.done or box.waiting and (box.preWait or 0) <= 0 + end) + tick(game, "a") +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local before, calls, result = ow.runner, 0, nil + T.check( + world:queueScript({ { "wait", 2 }, { "text", "First" }, { "end" }, { "text", "Unreachable" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + version .. " program admitted" + ) + T.neq(ow.runner, before, "isolated native runner installed") + T.eq(ow.runner:isRunning(), true, "runner locks foreground input") + tick(game) + T.eq(game.stack:top(), ow, "wait does not end early") + tick(game) + T.neq(game.stack:top(), ow, "native text appears after wait") + T.eq(calls, 0, "completion awaits text") + finishText(game) + T.same(result, { true }, "success completion") + T.eq(calls, 1, "completion once") + T.eq(ow.runner, before, "idle native runner restored") + T.eq(game.stack:top(), ow, "script text closed") + + local Choice = require("src.ui.ChoiceBox") + for _, yes in ipairs({ true, false }) do + local lines = {} + T.check( + world:queueScript({ + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "text", "YES" }, + { "jump", "end" }, + { "label", "no" }, + { "text", "NO" }, + }, { + onDone = function(ok) + lines[#lines + 1] = ok + end, + }), + "question script admitted" + ) + untilState(game, function() + return getmetatable(game.stack:top()) == Choice + end) + local choice = game.stack:top() + tick(game, yes and "a" or "b") + T.eq(choice.pending, yes, "native answer pending") + T.eq(#lines, 0, "answer hold preserved") + untilState(game, function() + return game.stack:top() ~= choice + end) + T.same( + game.stack:top().pages, + require("src.render.TextBox").paginate( + yes and "YES" or "NO", + game.stack:top().maxCols, + game.stack:top().letterSpacing, + game.stack:top().maxPixels, + true + ), + "correct branch text" + ) + finishText(game) + T.same(lines, { true }, "question branch completes") + end + local trace = {} + T.check( + world:queueScript({ + { "choice", { "ONE", "TWO", "THREE" }, { default = 2, cancel = 3 } }, + { "jump_if_false", 5 }, + { "text", "WRONG" }, + { "end" }, + { "text", "RIGHT" }, + }, { + onDone = function(ok) + trace[#trace + 1] = ok + end, + }), + "multiple choice admitted" + ) + T.eq(game.stack:top().index, 2, "native default selection") + tick(game, "b") + finishText(game) + T.same(trace, { true }, "native B choice follows false branch") + T.eq(loader.apis.legacy.world.queueScript, WorldAPI.queueScript, "legacy method unchanged") +end + +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(world, game, rows, options, label) + local unchanged = freeze(game) + local ok, why = world:queueScript(rows, options) + T.eq(ok, nil, label) + T.eq(type(why), "string", label .. " has reason") + unchanged(label .. " is observational") +end +local badPrograms = { + false, + 1, + "text", + function() end, + { [1] = { "wait", 1 }, [3] = { "end" } }, + { [1] = { "wait", 1 }, extra = true }, + setmetatable({ { "end" } }, {}), + { false }, + { {} }, + { { "wait" } }, + { { "wait", 1, 2 } }, + { { "wait", -1 } }, + { { "wait", 1.5 } }, + { { "wait", 0 / 0 } }, + { { "wait", math.huge } }, + { { "wait", 36001 } }, + { { "end", false } }, + { { "native_mutate", {} } }, + { { "show_text", {} } }, + { { "show_text", string.rep("x", 4097) } }, + { { "text", "Valid" }, { "give_item", "POTION" } }, + { { "text", "Valid" }, { "jump", "missing" } }, + { { "label", "same" }, { "label", "same" } }, + { { "label", "end" } }, + { { "label", "bad\nname" } }, + { { "jump", 0 } }, + { { "jump", 99 } }, + { { "jump", false } }, + { { "choice", {} } }, + { { "choice", { "A", "B" }, { cancel = 3 } } }, + { { "choice", { "A" }, { default = 0 } } }, + { { "choice", { "A" }, { custom = true } } }, + { { "choice", { "A", function() end } } }, + { { "choice", { "" } } }, + { { "choice", { "A" }, setmetatable({}, {}) } }, + { setmetatable({ "wait", 1 }, {}) }, +} +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local callbacks = 0 + for _, rows in ipairs(badPrograms) do + refusal(world, game, rows, { + onDone = function() + callbacks = callbacks + 1 + end, + }, "invalid whole program") + end + for _, options in ipairs({ + false, + 1, + "options", + { onDone = true }, + { onDone = function() end, runner = {} }, + setmetatable({}, {}), + }) do + refusal(world, game, { { "wait", 1 } }, options, "invalid options") + end + T.eq(callbacks, 0, "rejected lists never complete or execute a prefix") + for _, key in ipairs({ + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + }) do + ow[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns world") + ow[key] = nil + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns player") + ow.player[key] = nil + end + for _, key in ipairs({ "pendingScripts", "scriptMoves", "parallelRunners", "parallelQueue" }) do + ow[key] = { {} } + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns script scheduling") + ow[key] = {} + end + game.linkSession = {} + refusal(world, game, { { "wait", 1 } }, nil, "link session busy") + game.linkSession = nil + game.linkNet = { closed = false } + refusal(world, game, { { "wait", 1 } }, nil, "link transport busy") + game.linkNet = nil + game.stack:push({}) + refusal(world, game, { { "wait", 1 } }, nil, "covered world busy") + game.stack:pop() + ow.runner:run({ { "wait", 9 } }) + refusal(world, game, { { "wait", 1 } }, nil, "native script busy") + for _ = 1, 9 do + tick(game) + end + T.check(world:queueScript({ { "wait", 1 } }), "new script after native completion") + tick(game) + T.eq(loader.apis.other.world:cancelScript(), false, "another mod cannot cancel a program") +end +-- Limits, data copying and compiler immutability, including a cyclic payload. +do + local world, game, ow = boot("crystal") + local over = {} + for i = 1, 1025 do + over[i] = { "end" } + end + refusal(world, game, over, nil, "row bound") + over = {} + for i = 1, 33 do + over[i] = { "text", string.rep("x", 4096) } + end + refusal(world, game, over, nil, "total text bound") + local cycle = {} + cycle[1] = cycle + refusal(world, game, cycle, nil, "cyclic command denied") + local rows = { { "wait", 2 }, { "choice", { "ONE", "TWO" }, { default = 2 } } } + local unchanged = freeze(rows) + local compiled = Program.compile(rows) + unchanged("compiler leaves caller rows intact") + T.neq(compiled[2][2], rows[2][2], "compiler copies nested labels") + T.check(world:queueScript(rows), "copied program accepted") + rows[2][1], rows[2][2][2], rows[2][3].default = "invalid", "MUTATED", 1 + tick(game) + tick(game) + T.eq(game.stack:top().index, 2, "choice options captured before caller edit") + T.eq(game.stack:top().items[2].label, "TWO", "choice labels copied before execution") + world:cancelScript() +end +-- Session changes cancel even while an old Game is no longer being stepped. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "heartgold", "black" }) do + for _, change in ipairs({ + "save", + "data", + "stack", + "states", + "map", + "mapId", + "player", + "position", + "runner", + "game", + "version", + "removed", + "transition", + }) do + local world, game, ow, loader = boot(version) + local originalRunner, calls, result = ow.runner, 0 + T.check( + world:queueScript({ { "wait", 30 }, { "text", "Must not run" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + "session test admitted" + ) + local stale = ow.runner + local replacement + if change == "save" then + game.save = SaveData.newGame() + elseif change == "data" then + game.data = T.fixtures.fresh() + elseif change == "stack" then + game.stack = setmetatable({ states = { ow } }, { __index = StateStack }) + elseif change == "states" then + game.stack.states = { ow } + elseif change == "map" then + ow.map = { id = "FIX_ROUTE", def = game.data.maps.FIX_ROUTE } + elseif change == "mapId" then + ow.map.id = "FIX_ROUTE" + elseif change == "player" then + ow.player = { cellX = 4, cellY = 5 } + elseif change == "position" then + ow.player.cellX = 7 + elseif change == "runner" then + replacement = Runner.new(game, ow) + ow.runner = replacement + elseif change == "game" then + replacement = { stack = setmetatable({ states = {} }, { __index = StateStack }), input = game.input } + loader.game = replacement + elseif change == "version" then + GameVersion.set(version == "red" and "crystal" or "red") + elseif change == "removed" then + game.stack.states = {} + elseif change == "transition" then + ow.transitioning = true + end + Runtime.call("input.step", function() end, change == "game" and replacement or game, 1 / 60) + T.eq(calls, 1, change .. " cancellation delivered") + T.eq(result[1], false, "session cancellation is not success") + T.eq(type(result[2]), "string", "cancellation gives reason") + T.eq(ow.runner, change == "runner" and replacement or originalRunner, "restore only owned runner") + stale:resume() + stale:update() + T.eq(calls, 1, "old runner callbacks cannot replay cancellation") + T.eq(stale:isRunning(), false, "abandoned coroutine released") + GameVersion.set(version) + end +end +-- Cancel removes only owned windows, including the choice above its text. +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local originalRunner, calls = ow.runner, 0 + T.check( + world:queueScript({ { "ask", "Question?" }, { "text", "Later" } }, { + onDone = function(ok) + T.eq(ok, false, "explicit cancel completion") + calls = calls + 1 + end, + }), + "cancellable question" + ) + local box = game.stack:top() + untilState(game, function() + return game.stack:top() ~= box + end) + local choice = game.stack:top() + local foreign = { update = function() end } + game.stack:push(foreign) + T.eq(loader.apis.other.world:cancelScript(), false, "foreign mod has no cancellation authority") + T.check(world:cancelScript(), "owner cancels") + T.same(game.stack.states, { ow, foreign }, "foreign overlay survives cancellation") + T.eq(ow.runner, originalRunner, "cancel restores idle runner") + T.eq(calls, 1, "cancel callback once") + choice.onChoose(true) + box.onDone() + choice:update(1 / 60) + box:update(1 / 60) + T.same(game.stack.states, { ow, foreign }, "stale native UI cannot pop foreign screen") + T.eq(calls, 1, "stale native callbacks do not complete twice") +end +-- Replaying an old callback during a later row must not skip that row's wait. +do + local world, game, ow = boot("red") + T.check( + world:queueScript({ { "text", "First" }, { "wait", 10 }, { "text", "Last" } }), + "callback replay test admitted" + ) + local old = game.stack:top() + finishText(game) + T.eq(ow.runner.waitingFrames, 10, "later wait begins") + old.onDone() + old:update(1 / 60) + T.eq(ow.runner.waitingFrames, 10, "old text callback cannot skip current wait") + for _ = 1, 10 do + tick(game) + end + T.neq(game.stack:top(), ow, "new text after full wait") + world:cancelScript() +end + +-- Native error cleanup, completion exceptions and event reentry are bounded. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "black" }) do + local world, game, ow, loader = boot(version) + local TextBox = require("src.render.TextBox") + local original, make = ow.runner, TextBox.new + local completions, result = 0 + TextBox.new = function() + error("injected native constructor failure") + end + Runtime.currentMod = "probe" + T.check( + world:queueScript({ { "text", "Fail" } }, { + onDone = function(ok, why) + completions = completions + 1 + result = { ok, why } + end, + }), + "entered native failure is admitted" + ) + T.eq(Runtime.currentMod, "probe", "engine ownership restored on failure") + Runtime.currentMod = nil + TextBox.new = make + T.eq(result[1], false, "native error completes as failure") + T.check(result[2]:find("constructor failure", 1, true) ~= nil, "native error reason retained") + T.eq(completions, 1, "native error completion once") + T.eq(ow.runner, original, "native error releases runner") + T.check( + world:queueScript({}, { + onDone = function() + error("completion failure") + end, + }), + "callback error isolated" + ) + T.eq(ow.runner, original, "callback error cannot strand runner") + local events = {} + local stop1 = loader.events:on("script.started", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = "started" + refusal( + loader.apis.other.world, + game, + { { "wait", 1 } }, + nil, + "event callback cannot reenter owned run" + ) + end + end) + local stop2 = loader.events:on("script.ended", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = ev.completed and "success" or "failure" + end + end) + T.check(world:queueScript({ { "wait", 1 } }), "eventful wait admitted") + tick(game) + T.same(events, { "started", "success" }, "one lifecycle event pair") + T.check(world:queueScript({ { "wait", 10 } }), "eventful cancel admitted") + world:cancelScript() + T.same(events, { "started", "success", "started", "failure" }, "cancel emits failure once") + stop1() + stop2() +end +-- An infinite authored jump yields slices to the engine and eventually +-- reports failure rather than blocking a frame or permanently owning input. +do + local world, game, ow = boot("red") + local original, result, ticks = ow.runner, nil, 0 + T.check( + world:queueScript({ { "label", "loop" }, { "jump", "loop" } }, { + onDone = function(ok, why) + result = { ok, why } + end, + }), + "loop admitted" + ) + T.eq(result, nil, "loop yields back from initial call") + untilState(game, function() + ticks = ticks + 1 + return result ~= nil + end) + T.check(ticks > 1, "loop spans engine updates") + T.eq(result[1], false, "instruction limit fails explicitly") + T.check(result[2]:find("budget", 1, true) ~= nil, "budget reason") + T.eq(ow.runner, original, "budget exhaustion releases world") +end +-- Teardown/reload and failed entry chunks retire captured facades. Neither +-- completion nor a script-ended listener may start fresh work during teardown. +for _, mode in ipairs({ "teardown", "reload", "rollback" }) do + local world, game, ow, loader, files = boot("emerald") + local original, calls, retry = ow.runner, 0 + T.check( + world:queueScript({ { "text", "Pending" } }, { + onDone = function(ok) + T.eq(ok, false, "lifecycle cancellation") + calls = calls + 1 + retry = world:queueScript({ { "wait", 1 } }) + end, + }), + "lifecycle program accepted" + ) + if mode == "teardown" then + Loader.teardown() + elseif mode == "reload" then + boot("emerald") + else + loader:_rollback("probe") + end + T.eq(calls, 1, "lifecycle completion once") + T.eq(retry, nil, "no restart while retiring") + T.eq(ow.runner, original, "lifecycle restores runner") + T.same(game.stack.states, { ow }, "lifecycle removes owned text") + refusal(world, game, { { "wait", 1 } }, nil, "retained API retired") +end +do + local _, game, ow, loader, files = boot("crystal") + local original = ow.runner + files["mods/probe/main.lua"] = [[return function(mod) + assert(mod.world:queueScript({ { "wait", 30 } })) + error("entry failure after queue") + end]] + files["mods/other/main.lua"] = "return function() end" + local failed = Loader.new({ fs = loader.fs, dev = false }) + failed.game = game + failed:load(T.fixtures.fresh()) + T.eq(ow.runner, original, "failed entry cancels its accepted run") + T.eq(original:isRunning(), false, "failed entry cannot strand foreground") +end +-- Omitted/native formats preserve the old API 1/2 contract. Explicit malformed +-- or API-1 unified declarations must fail instead of silently changing mode. +do + local function manifest(format, level) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = level or 2, + script_format = format, + } + end + for _, bad in ipairs({ false, true, 1, {}, "portable", "" }) do + T.raises(function() + Manifest.validate(manifest(bad), "mods/probe") + end, "script_format", "invalid format") + end + T.raises(function() + Manifest.validate(manifest("unified", 1), "mods/probe") + end, "API 2", "unified requires API 2") + T.eq( + Manifest.validate(manifest(nil, 1), "mods/probe").script_format, + "native", + "API 1 default remains native" + ) + T.eq( + Manifest.validate(manifest("native", 1), "mods/probe").script_format, + "native", + "explicit API 1 native accepted" + ) +end +-- Per-frame traces against the real native runner/commands, rather than +-- against another implementation of the portable adapter. +local function stateRecord(game, ow) + local top = game.stack:top() + local record = + { depth = #game.stack.states, running = ow.runner:isRunning(), wait = ow.runner.waitingFrames } + if top ~= ow then + record.index, record.pending, record.hold = top.index, top.pending, top.holdFrames + record.page, record.line, record.char = top.pageIndex, top.lineIndex, top.charIndex + record.done, record.waiting, record.preWait = top.done, top.waiting, top.preWait + if top.shown then + record.shown = Json.decode(Json.encode(top.shown)) + end + if top.items then + record.labels = {} + for _, item in ipairs(top.items) do + record.labels[#record.labels + 1] = item.label + end + end + end + return record +end +for _, version in ipairs(GameVersion.ORDER) do + for _, yes in ipairs({ true, false }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local world, game, ow = boot(version) + local rows = { + { "wait", 2 }, + { "show_text", "First\fSecond" }, + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "choice", { "ONE", "TWO" }, { default = 2, cancel = 2 } }, + { "jump", "end" }, + { "label", "no" }, + { "show_text", "NO" }, + } + local completed, trace = false, {} + local saveUnchanged = freeze(game.save) + if native then + ow.runner:run(rows, { + onDone = function() + completed = true + end, + }) + else + T.check( + world:queueScript(rows, { + onDone = function(ok) + T.eq(ok, true, "parity program succeeds") + completed = true + end, + }), + "parity program admitted" + ) + end + for frame = 1, 2000 do + trace[#trace + 1] = stateRecord(game, ow) + if completed then + break + end + local top = game.stack:top() + local key + if top.pending == nil and getmetatable(top) == require("src.ui.ChoiceBox") then + key = yes and "a" or "b" + elseif top.items then + key = "b" + elseif top.done or top.waiting and (top.preWait or 0) <= 0 then + key = "a" + end + tick(game, key) + end + T.eq(completed, true, "native parity trace completed") + saveUnchanged("dialogue program leaves save unchanged") + traces[#traces + 1] = trace + end + T.same(traces[1], traces[2], version .. " frame-by-frame native dialogue/wait/branch parity") + end +end + +do + local world, game, ow, loader = boot("black") + for _, invalid in ipairs({ + "bad\0text", + "\128", + "\192\128", + "\224\128\128", + "\237\160\128", + "\244\144\128\128", + "\240\159", + }) do + refusal( + world, + game, + { { "text", "Valid first" }, { "ask", invalid } }, + nil, + "invalid UTF-8 refused before prefix" + ) + end + T.check(world:queueScript({ { "text", "Grüße Pokémon ♀" } }), "valid UTF-8 accepted") + world:cancelScript() + local calls = 0 + local stop = loader.hooks:wrap("input.step", function(nextFn, ...) + calls = calls + 1 + return nextFn(...) + end, -1, "other") + for _ = 1, 3 do + T.check(world:queueScript({ { "wait", 2 } }), "reused observer admission") + world:cancelScript() + end + local before = calls + tick(game) + T.eq(calls, before + 1, "observer does not skip or duplicate downstream hooks") + stop() + local random = math.random + local rolls = 0 + math.random = function() + rolls = rolls + 1 + error("portable scripts must not roll RNG") + end + T.check(world:queueScript({ { "text", "FIRST" } }), "no-RNG dialogue") + finishText(game) + math.random = random + T.eq(rolls, 0, "no RNG consumed") + local events = {} + local done = loader.events:on("script.ended", function(ev) + events[#events + 1] = ev.completed + end) + T.check(world:queueScript({ { "text", "Native failure" } }), "native update failure admission") + local box = game.stack:top() + -- The guarded method captures the native update when the box is created. + -- Fail native work reached on a later frame instead of changing the guard. + box.beginLine = function() + error("native text update failure") + end + box.done, box.waiting, box.preWait, box.contAdvance = false, true, 0, true + tick(game, "a") + T.same(events, { false }, "native UI update error completes once") + T.eq(game.stack:top(), ow, "native update error releases owned UI") + done() +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local api = loader.apis.probe + if api.supports then + T.eq(api:supports("world", "queueScript"), true, "portable queue capability") + T.eq(api:supports("world", "cancelScript"), true, "portable cancel capability") + for _, verb in ipairs({ + "text", + "show_text", + "ask", + "choice", + "wait", + "label", + "jump", + "jump_if_true", + "jump_if_false", + "end", + }) do + T.eq(api:supports("scripts", verb), true, "bounded verb capability") + end + T.eq(api:supports("scripts", "warp"), false, "unsupported verb declined") + T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") + T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") + end + T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate") + T.eq(world.game, game, "existing native fields still delegate") +end + +-- First admission from an input.step callback must not sort a new observer +-- into the chain while it is being traversed (which would replay the caller). +do + local world, game = boot("crystal") + local wrappers, vanilla = 0, 0 + local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...) + wrappers = wrappers + 1 + if wrappers == 1 then + T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook") + end + return nextFn(...) + end, 0, "probe") + Runtime.call("input.step", function() + vanilla = vanilla + 1 + end, game, 1 / 60) + T.eq(wrappers, 1, "first queue does not replay input callback") + T.eq(vanilla, 1, "native input boundary called once") + world:cancelScript() + stop() +end +GameVersion.set(originalVersion) +T.finish("portable world scripts") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..19cfd28ac 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -192,18 +192,77 @@ check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= n -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit -- it refused. -local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n') -check(quitBranch ~= nil, "love.run still handles the quit event") -quitBranch = quitBranch or "" -local vetoAt = quitBranch:find("not love%.quit()") -local osAt = quitBranch:find("os%.exit") -local androidAt = quitBranch:find('getOS() == "Android"', 1, true) -check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt, - "the process exit runs only after love.quit declined to veto") -check(androidAt ~= nil and androidAt < osAt, - "the process exit is gated on Android") -local exits = 0 -for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end -eq(exits, 1, "os.exit appears once, at the quit event") +-- Execute the actual run-loop function in an isolated environment. Scanning +-- nested `end`s or counting os.exit in comments cannot prove veto behavior. +local runAt = assert(mainSrc:find("\nfunction love.run()", 1, true)) +local runSource = mainSrc:sub(runAt + 1) +local function quitFrame(platform, answer, code, noCallback) + local calls, exits = {}, {} + local function record(name) calls[#calls + 1] = name end + local env = setmetatable({ + BootTrace = { mark = function() end, frame = function() record("frame") end }, + Frames = { tick = function() record("tick") end }, + FrameProfile = { section = function() return function() end end }, + pacingEnabled = function() return false end, + os = { exit = function(value) record("exit"); exits[#exits + 1] = value end }, + love = { + handlers = { quit = function() record("dispatch") end }, + update = function() record("update") end, + event = { + pump = function() end, + poll = function() + local sent = false + return function() + if not sent then sent = true; return "quit", code end + end + end, + }, + }, + }, { __index = _G }) + if platform then env.love.system = { getOS = function() return platform end } end + if not noCallback then + env.love.quit = function() record("quit"); return answer end + end + local compile = loadstring or load + local chunk + if setfenv then + chunk = assert(compile(runSource, "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(runSource, "@main.lua:love.run", "t", env)) + end + chunk() + local result = env.love.run()() + return result, table.concat(calls, ","), exits +end + +for _, platform in ipairs({ "Android", "Windows", "OS X", "Linux", "NX", false }) do + local label = tostring(platform) + for _, code in ipairs({ 0, 7, false }) do + -- false is a table-friendly sentinel for an omitted exit status. + local expected = code or 0 + for _, noCallback in ipairs({ false, true }) do + for _, answer in ipairs({ false, "nil" }) do + local callbackAnswer = answer + if answer == "nil" then callbackAnswer = nil end + local result, order, exits = quitFrame(platform, callbackAnswer, code or nil, noCallback) + local prefix = noCallback and "" or "quit" + eq(result, expected, label .. ": accepted quit returns its exit status") + if platform == "Android" then + eq(#exits, 1, "Android exits exactly once after accepting quit") + eq(exits[1], expected, "Android forwards the requested exit status") + eq(order, prefix == "" and "exit" or "quit,exit", "shutdown precedes process exit") + else + eq(#exits, 0, label .. ": accepted quit does not force a process exit") + eq(order, prefix, label .. ": no further frame work after accepted quit") + end + end + end + end + local result, order, exits = quitFrame(platform, true, 7) + eq(result, nil, label .. ": veto keeps the frame running") + eq(#exits, 0, label .. ": veto never exits the process") + eq(order, "quit,dispatch,update,frame,tick", label .. ": veto resumes event and frame processing") +end T.finish("quit thread shutdown") diff --git a/tests/engine/rebind_capture_bug510.lua b/tests/engine/rebind_capture_bug510.lua index e5d13d311..f36306d15 100644 --- a/tests/engine/rebind_capture_bug510.lua +++ b/tests/engine/rebind_capture_bug510.lua @@ -48,8 +48,13 @@ local function openMenu(game) return bm end --- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B -local ROW_A, ROW_B = 5, 6 +-- Find actions by their public menu row metadata, not by a generation's order. +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- The mechanism, pinned so a future "just apply it immediately" revert -- fails here: a rebind overwrites whatever the key used to do, so B = Z @@ -73,14 +78,14 @@ check(game.save.options.bindings == nil, "escaping a capture writes no binding") eq(game.wroteOptions, 0, "and does not touch options on disk") -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "a") bm:onKeyPressed("z") check(game.save.options.bindings == nil, "a capture holds its press; nothing stores before the release (#589)") bm:onKeyReleased("z") eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z") -eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away") +eq(bm.items[row(bm, "b")].right, "Z/B", "the row shows the new key straight away") eq(game.wroteOptions, 1, "the choice persists immediately") eq(Input.keyBindings["z"], "a", "but the live map still reads Z as A while the screen is open (#510)") @@ -90,12 +95,12 @@ eq(#game.stack.states, 1, "capturing Z does not close the screen") -- inherits B's previous key and no key serves two rows (#589) eq(game.save.options.bindings.a.key, "x", "capturing A's key for B hands A the old B key") -eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it") +eq(bm.items[row(bm, "a")].right, "X/A", "and the A row redraws with it") -- the next Z the player presses is still confirm, so the A row can be armed -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing") +eq(bm.capture, bm.items[row(bm, "a")], "the A row arms instead of the screen closing") bm:onKeyPressed("x") bm:onKeyReleased("x") eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it") @@ -117,16 +122,18 @@ eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close") Input:init() local padGame = newGame() local padBm = openMenu(padGame) -padBm.index = ROW_B +padBm.index = row(padBm, "b") press(padBm, "a") padBm:onGamepadPressed("y") padBm:onGamepadReleased("y") eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores") -eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close") +eq(Input.padBindings["y"], "x", "pad Y keeps its default DS X action until close") +eq(padGame.save.options.bindings.x.pad, "b", "DS X inherits the captured row's pad B") +eq(Input.padBindings["b"], "b", "the donor swap is also deferred until close") -- a second input going down while the first is held backs the capture out -- with no keyboard in reach, the pad's Escape (#589) -padBm.index = ROW_A +padBm.index = row(padBm, "a") press(padBm, "a") padBm:onGamepadPressed("x") padBm:onGamepadPressed("b") @@ -139,6 +146,20 @@ eq(padGame.save.options.bindings.a, nil, press(padBm, "b") eq(Input.padBindings["y"], "b", "closing commits the pad half too") +eq(Input.padBindings["b"], "x", "closing commits the DS X donor half") + +-- A genuinely unbound input must also remain absent until the screen closes. +Input:init() +local spareGame = newGame() +local spareBm = openMenu(spareGame) +spareBm.index = row(spareBm, "a") +press(spareBm, "a") +spareBm:onGamepadPressed("leftstick") +spareBm:onGamepadReleased("leftstick") +eq(spareGame.save.options.bindings.a.pad, "leftstick", "unbound pad capture stores") +eq(Input.padBindings.leftstick, nil, "unbound pad remains absent while the menu is open") +press(spareBm, "b") +eq(Input.padBindings.leftstick, "a", "closing activates the newly bound pad") Input:init() T.finish("rebind_capture_bug510") diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua index 0e49d03d0..7ccca5fe0 100644 --- a/tests/engine/rebind_swap_clear_bug589.lua +++ b/tests/engine/rebind_swap_clear_bug589.lua @@ -54,8 +54,12 @@ local function settleChoice(state) for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end end --- rows are BindingsMenu's BUTTONS order -local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8 +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- ---- (a) pad capture swaps with the row that owns the button -------------- @@ -64,9 +68,9 @@ local game = newGame() local bm = BindingsMenu.new(game) game.stack:push(bm) -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "A arms the A row") +eq(bm.capture, bm.items[row(bm, "a")], "A arms the A row") bm:onGamepadPressed("b") check(game.save.options.bindings == nil, "a pad capture holds its press; nothing stores before the release") @@ -75,8 +79,8 @@ local bindings = game.save.options.bindings eq(bindings.a.pad, "b", "releasing pad B stores it on the A row") eq(bindings.b.pad, "a", "and the B row, which owned pad B, inherits the A row's old pad (#589)") -eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad") -eq(bm.items[ROW_B].right, "X/A", "so does the B row") +eq(bm.items[row(bm, "a")].right, "Z/B", "the A row redraws with the new pad") +eq(bm.items[row(bm, "b")].right, "X/A", "so does the B row") -- after applying, every row's effective pad is unique and none is lost Input:applyBindings(bindings) @@ -89,13 +93,21 @@ for button, action in pairs(Input.padBindings) do seen[action] = button actions[#actions + 1] = action end -eq(#actions, 8, "all eight actions still have exactly one pad button") +eq(#actions, 12, "all twelve actions still have exactly one pad button") +local unchanged = { + up = "dpup", down = "dpdown", left = "dpleft", right = "dpright", + x = "y", y = "x", start = "start", select = "back", + l = "leftshoulder", r = "rightshoulder", +} +for action, button in pairs(unchanged) do + eq(seen[action], button, "A/B swap preserves the binding for " .. action) +end Input:init() -- ---- (b) a second input while the first is held cancels ------------------- -- key then key: the straggling release of the first press writes nothing -bm.index = ROW_SELECT +bm.index = row(bm, "select") press(bm, "a") bm:onKeyPressed("q") bm:onKeyPressed("w") @@ -121,17 +133,17 @@ check(bindings.select == nil, "press alone commits nothing (#589)") eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk") bm:onKeyReleased("q") eq(bindings.select.key, "q", "the release is the commit") -eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key") +eq(bm.items[row(bm, "select")].right, "Q/BACK", "the row shows the new key") -- ---- (d) SELECT on a row forgets its rebind ------------------------------- press(bm, "select") check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)") -eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default") +eq(bm.items[row(bm, "select")].right, "TAB/BACK", "the row falls back to the default") -- the swapped B row clears the same way; a second SELECT on the now -- default row is a no-op, not a write local writes = game.wroteOptions -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "select") eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once") check(game.save.options.bindings.b == nil, "and drops its overlay entry") @@ -141,7 +153,7 @@ eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothin -- ---- (e) START confirms, then drops the whole overlay --------------------- -- rebuild a dirty overlay to reset -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") bm:onKeyPressed("p") bm:onKeyReleased("p") @@ -158,7 +170,7 @@ press(box, "a") settleChoice(box) eq(game.stack:top(), bm, "answering pops the box") check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)") -eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them") +eq(bm.items[row(bm, "a")].right, "P/B", "and the rows keep showing them") -- again, flip to YES: the overlay goes away and every row reads default press(bm, "start") @@ -167,8 +179,8 @@ press(box, "up") press(box, "a") settleChoice(box) check(game.save.options.bindings == nil, "YES clears options.bindings (#589)") -eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again") -eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched") +eq(bm.items[row(bm, "a")].right, "Z/A", "the A row reads its default again") +eq(bm.items[row(bm, "b")].right, "X/B", "so does the B row the swap had touched") -- closing after the reset leaves the live map at the defaults press(bm, "b") diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..bae8bc2cc --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,39 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..fe8fdcf7d 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -106,8 +106,9 @@ check(stepChord(), "reaching 16 again from the restart") -- before stack:update, so it fires from a battle, a menu or a cutscene and -- not just from the overworld (#563). local function fakeGame() - local game = { input = Input, save = {}, returned = 0 } + local game = setmetatable({ input = Input, save = {}, returned = 0 }, { __index = Game }) game.stack = { + states = { {} }, -- a live state, so the real empty-stack guard does not recover updates = 0, update = function(self) self.updates = self.updates + 1 end, } @@ -117,7 +118,23 @@ end local game = fakeGame() holdKeys("z", "x", "escape", "tab") +-- Observe the real profiling wrapper as well as its real _step body. A reset +-- returns early from _step, but must still close the outer profiling scope. +local FrameProfile = require("src.core.FrameProfile") +local savedSection = FrameProfile.section +local opened, closed = 0, 0 +FrameProfile.section = function(name) + local close = savedSection(name) + if name == " step: whole" then opened = opened + 1 end + return function() + if name == " step: whole" then closed = closed + 1 end + close() + end +end for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end +FrameProfile.section = savedSection +eq(opened, HOLD_STEPS, "every logic step enters the real profiling wrapper") +eq(closed, HOLD_STEPS, "the reset early return closes its profiling scope") eq(game.returned, 1, "Game:step falls back to the title on the 16th step") eq(game.stack.updates, HOLD_STEPS - 1, "and the state on top never gets that step, whatever it was") @@ -131,16 +148,17 @@ local bare = fakeGame() bare.input = { step = function() end, isDown = function() return false end } for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end eq(bare.returned, 0, "an input with no chord bookkeeping never resets") +eq(bare.stack.updates, HOLD_STEPS * 2, "an input stand-in still updates the real state stack") -- ---- the on-screen overlay --------------------------------------------- -- Force the overlay live the way a phone would have it. img is only ever --- tested for truthiness on the input path (drawing is what reads it), so a --- placeholder keeps this suite off love.graphics.newImage. +-- checked per button on the input path. Provide each available control +-- without asking love.graphics to load actual image assets. Input:init() TouchControls:init() TouchControls.active, TouchControls.enabled = true, true -TouchControls.img = { stub = true } +TouchControls.img = { a = {}, b = {}, start = {}, select = {}, dpad = {} } local L = TouchControls:layout() -- One finger, one control: touchpressed returns on its first hit, so no diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index 36f1f2a01..d92ac0aad 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -8,6 +8,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -19,14 +20,14 @@ Runtime.hooks = hooks -- ordering rather than merely checking that a hook was registered. do local order = {} - local fake = { + local fake = setmetatable({ input = { step = function() order[#order + 1] = "input" end }, - stack = { update = function(_, dt) + stack = { states = { {} }, update = function(_, dt) order[#order + 1] = "world" T.eq(dt, 1 / 60, "the world receives the fixed-step dt") end }, save = {}, - } + }, { __index = Game }) hooks:wrap("input.step", function(nextFn, game, dt) T.check(game == fake, "input.step receives the live Game object") T.eq(dt, 1 / 60, "input.step receives the fixed-step dt") @@ -71,10 +72,10 @@ end -- the live snapshot in preparation for disk IO. do local captured = false - local fake = { + local fake = setmetatable({ save = {}, overworld = { captureSave = function() captured = true end }, - } + }, { __index = Game }) hooks:wrap("save.write", function(nextFn, game) T.check(game == fake, "save.write receives the live Game object") return false @@ -112,14 +113,14 @@ do -- Game:draw iterates stack.states and calls each state's draw (it no -- longer goes through stack:draw), so the fixture must put a drawable -- state on the stack to observe composition order. - local fake = { + local fake = setmetatable({ overworld = {}, stack = { states = { { draw = function() order[#order + 1] = "states" end }, }, }, - } + }, { __index = Game }) function fake.stack:visibleBase() return 1 end function fake.stack:top() return self.states[#self.states] end diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 698c5c9f7..4df8fec72 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -13,22 +13,36 @@ local Json = require("src.link.Json") -- releaseUrl is the fixed public landing page the UI links on needs_full eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") -- parseRelease: a well-formed release with the .love payload and its sums local body = Json.encode({ tag_name = "v1.4.2", assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, }, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "leading v stripped from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "payload name derived from version") eq(rel.payload.url, "http://x/love", "payload asset url picked") eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") @@ -50,13 +64,13 @@ eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected -- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './' -- prefix and CRLF line endings; unrelated lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "not a checksum line\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "bare-name sum parsed") eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered") -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly") -- pickAsset guards a non-table assets field diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171e..d150e43f6 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -193,11 +193,11 @@ end -- --------------------------------------------------------------------------- local assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, } -local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love") +local picked = Check.pickAsset(assets, "Gen2Recomped-1.4.2.love") check(picked ~= nil, "pickAsset finds an asset by exact name") eq(picked.url, "http://x/love", "pickAsset returns the download url") eq(picked.size, 12345, "pickAsset returns the numeric size") @@ -223,10 +223,24 @@ local body = Json.encode({ tag_name = "v1.4.2", assets = assets, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "parseRelease derives the payload name from the version") eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url") eq(rel.payload.size, 12345, "parseRelease picks the payload asset size") eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url") @@ -268,18 +282,18 @@ eq(literal, nil, "parseRelease returns nil on a non-object JSON literal") -- standard " " lines, the '*' binary marker, a './' prefix, CRLF -- endings and mixed hash case; junk lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "deadBEEF ./nested.love\n" .. "not a checksum line at all\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases") eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./") eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums") -- the target form returns just that file's hash (hit / miss) -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file") -- degenerate inputs: empty text yields an empty map, a targeted miss is nil, @@ -294,7 +308,7 @@ eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil wi -- --------------------------------------------------------------------------- eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") T.finish("update") diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..dadf125cf 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,38 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua new file mode 100644 index 000000000..dab0d78bd --- /dev/null +++ b/tests/engine/world_overview_api.lua @@ -0,0 +1,660 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local WorldAPI = require("src.world.WorldAPI") +local Encounter = require("src.world.Encounter") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GameVersion = require("src.core.GameVersion") +local Assets = require("src.render.Assets") +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks +local function copy(t, seen) + if type(t) ~= "table" then + return t + end + seen = seen or {} + if seen[t] then + return seen[t] + end + local out = {} + seen[t] = out + for k, v in pairs(t) do + out[copy(k, seen)] = copy(v, seen) + end + return out +end +-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real. +local function freeze(root) + local graph = {} + local function walk(t) + if type(t) ~= "table" or graph[t] then + return + end + local keys = {} + graph[t] = { keys = keys, mt = getmetatable(t) } + for k, v in pairs(t) do + keys[k] = v + walk(k) + walk(v) + end + end + walk(root) + return function(label) + for t, before in pairs(graph) do + T.eq(getmetatable(t), before.mt, label .. " metatable") + for k, v in pairs(before.keys) do + T.eq(rawget(t, k), v, label .. " value") + end + for k, v in pairs(t) do + T.eq(before.keys[k], v, label .. " no new key") + end + end + end +end +local function pack(...) + local out = {} + for _, n in ipairs({ ... }) do + out[#out + 1] = string.char(n % 256, math.floor(n / 256)) + end + return table.concat(out) +end +local function uniform(n) + local out = {} + for i = 1, 16 do + out[i] = n + end + return out +end +local function scene(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local def = { + id = "ACTIVE", + width = 2, + height = 2, + borderBlock = 0, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1 }, { x = -1, y = 1 } }, + signs = { + { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" }, + { + x = 1, + y = 0, + item = "POTION", + eventFlag = "HIDDEN", + }, + }, + } + local ts = { + blockCells = 2, + blockTiles = 4, + blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) }, + walkable = { 0, 2 }, + waterTiles = { 1 }, + shoreTiles = {}, + warpTiles = { 3 }, + image = "overview-test.png", + tilesPerRow = 4, + } + if gen == 2 then + ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 } + elseif gen >= 3 then + def.blocks = pack(1 + 1024, 2 + 4096, 3, 4) + def.collisionCells = { 0, 0, 1, 0 } + def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil + ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 } + ts.behaviourBytes = true + ts.warpsAreEvents = true + if gen >= 4 then + def.generation = 4 + def.gen5 = gen == 5 or nil + def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0) + end + end + local map = Map.new(def, ts) + local ow = { + isOverworld = true, + map = map, + npcs = { + { def = { item = "POTION" }, cellX = 0, cellY = 0 }, + { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true }, + { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 }, + { def = { item = 0 }, cellX = 0, cellY = 1 }, + { def = { item = "POTION" }, cellX = 99, cellY = 99 }, + }, + tod = "NITE", + player = { cellX = 0, cellY = 0 }, + } + local game = { + data = { + maps = { ACTIVE = def }, + field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } }, + }, + save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } }, + overworld = ow, + stack = { states = { ow, { isMenu = true } } }, + } + ow.game = game + return WorldAPI.new(game, "probe"), game, ow +end +local savedLoveRandom, savedMathRandom = love.math.random, math.random +local draws = 0 +love.math.random = function() + draws = draws + 1 + error("query consumed love RNG") +end +math.random = function() + draws = draws + 1 + error("query consumed math RNG") +end +Runtime.hooks = Hooks.new() +local callbacks = 0 +for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do + Runtime.hooks:wrap(name, function() + callbacks = callbacks + 1 + error("query executed encounter hook") + end) +end +for _, version in ipairs({ + "red", + "gold", + "silver", + "crystal", + "emerald", + "firered", + "platinum", + "heartgold", + "black", + "white", +}) do + local api, game, ow = scene(version) + for _, warm in ipairs({ false, true }) do + if warm then + ow.map:blockAt(0, 0) + end + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable(version .. " overview") + T.eq(out.mapId, "ACTIVE", version .. " map ID") + T.eq(out.width, ow.map.widthCells, version .. " width") + T.eq(#out.rows, out.height, version .. " rows") + -- Native classifiers on an independent map give the exact terrain contract. + local native = Map.new(copy(ow.map.def), copy(ow.map.tileset)) + for y = 0, out.height - 1 do + T.eq(#out.rows[y + 1], out.width, version .. " row width") + for x = 0, out.width - 1 do + local expected = native:isWarpTileCell(x, y) and "+" + or native:isWaterCell(x, y) and "~" + or native:isWalkableCell(x, y) and "." + or " " + T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain") + end + end + T.same(out.markers, { + { kind = "warp", x = 1, y = 1 }, + { kind = "item", x = 0, y = 0 }, + { kind = "hidden", x = 0, y = 0 }, + { + kind = "hidden", + x = 0, + y = 1, + }, + }, version .. " only active markers") + out.markers[1].x = 999 + out.rows[1] = "corrupted" + T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers") + end + local old = assert(api:mapOverview()) + ow.npcs = {} + game.save.flags.FOUND = true + game.save.hiddenTaken.ACTIVE_0_0 = true + T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected") + T.eq(#old.markers, 4, version .. " old snapshot remains detached") + ow.map:setBlock(0, 0, 0, true) + local stable = freeze(game) + local patched = assert(api:mapOverview()) + stable(version .. " patched map") + T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell") + if GameVersion.generation() >= 4 then + ow.map.def.behaviorCells = nil + local result, why = api:mapOverview() + T.eq(result, nil, version .. " streamed raster refused") + T.check(why:find("streamed", 1, true), version .. " streamed reason") + end +end +-- Literal terrain reference: whitespace is an impassable cell, not a missing row. +do + local api = scene("red") + T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid") +end +-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure. +do + local api, game, ow = scene("gold") + local saved = love.image + love.image = {} + local reads = 0 + love.image.newImageData = function(path) + reads = reads + 1 + T.eq(path, "overview-test.png", "resolved image path") + return { + getDimensions = function() + return 32, 8 + end, + getPixel = function(_, x, y) + local shade = math.floor(x / 8) + if shade == 2 then + shade = (x % 8 < 4 and 0 or 3) + end + local v = 1 - shade / 3 + return v, v, v, 1 + end, + } + end + Assets.invalidate() + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable("visual pure") + T.eq(out.tileRows[1], "00001111", "literal GB tile shades") + T.eq(out.tileRows[5], "22223333", "literal averaged split tile") + T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants") + T.eq(out.tileWidth, 8, "2x tile width") + T.eq(out.tileDetailWidth, 16, "4x detail width") + out.tileRows[1] = "broken" + out.tileDetailRows[1] = "broken" + T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows") + T.eq(reads, 1, "visual cache reused") + Assets.invalidate() + api:mapOverview() + T.eq(reads, 2, "asset reload invalidates visual cache") + love.image.newImageData = function() + error("missing image") + end + Assets.invalidate() + T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted") + love.image = saved + Assets.invalidate() +end +local function tableDef(rate, buckets, species) + local slots = {} + for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do + slots[i] = { species = id, min = 2, max = 7 } + end + return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots } +end +local function encounters(version) + GameVersion.set(version) + local a = tableDef(60) + a.byTime = { + morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }), + nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }), + } + local maps = { + A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil }, + B = { + id = "B", + }, + } + local data = { + maps = maps, + encounters = { + [maps.A.encounters or "A"] = { + grass = a, + water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }), + }, + }, + field = {}, + pokemon = { + RATTATA = { name = "Rattata" }, + PIDGEY = { name = "Pidgey" }, + HOOTHOOT = { name = "Hoothoot" }, + MAGIKARP = { name = "Magikarp" }, + POLIWAG = { name = "Poliwag" }, + }, + } + local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } } + return WorldAPI.new(game, "probe"), game, a +end +local function nativeParity(api, game, opts) + local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12 + local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save) + local slots = Encounter.atTime(enc.grass, opts.daytime) + local counts = {} + Encounter.load(game.data) + for pick = 0, (slots.bucketSpan or 256) - 1 do + local n = 0 + local result = Encounter.chooseTable(slots, function(lo) + n = n + 1 + if n == 1 then + return pick + end + return lo + end) + if result then + counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1 + end + end + local stable = freeze(game) + local out = assert(api:effectiveEncounters("A", "grass", opts)) + stable("encounter graph") + T.same(out.dist, counts, "native exhaustive bucket distribution") + local passed = 0 + for gate = 0, (slots.rateMax or 256) - 1 do + local n = 0 + local result = Encounter.rollTable(slots, function(lo) + n = n + 1 + if n == 1 then + return gate + end + return lo + end) + if result then + passed = passed + 1 + end + end + T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate") + return out +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do + local api, game = encounters(version) + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" }) + T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated") + out.dist.RATTATA = 0 + T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached") + T.same( + api:effectiveEncounters("A", "indoor", { daytime = "NITE" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "indoor grass alias" + ) + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water table" + ) + T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters") + T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused") + for _, bad in ipairs({ "fishing", "darkGrass", false }) do + T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused") + end + T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table") + T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused") + T.same( + api:effectiveEncounters("A", "grass", { daytime = "night" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "time aliases" + ) + game.data.encounters = nil + T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused") +end +-- Gen2 outbreaks use the same native map selection for all three cartridges. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local api, game = encounters(version) + game.data.field.gen2Swarms = { + maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } }, + flags = { [0] = 17 }, + } + game.save.g2Swarm = { kind = 0, map = "A" } + if version == "crystal" then + game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true + end + nativeParity(api, game, { daytime = "DAY" }) + T.same( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist, + { HOOTHOOT = 256 }, + version .. " outbreak table" + ) + game.save.g2Swarm = nil + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, + 205, + "outbreak ends immediately" + ) +end +-- DS rates/bucket spans, time substitutions, daily replacements and seasons. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance } + end + local raw = { + grassRate = 30, + grass = slots, + surf = { + rate = 20, + slots = { { species = "MAGIKARP", level = 5, chance = 100 } }, + }, + } + game.data.encounters[7] = raw + if version == "black" or version == "white" then + raw.family = "bw" + raw.seasons = + { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } } + game.save.gen5Season = 3 + end + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + if raw.family == "bw" then + T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season") + game.save.gen5Season = 0 + T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately") + end +end +-- Cold DS resolver views: timed slots and daily replacements are reflected +-- without initializing save state or exposing mutable source slot records. +for _, version in ipairs({ "platinum", "heartgold" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = "RATTATA", level = 5, chance = chance } + end + local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } } + if version == "heartgold" then + raw.timed = { day = {}, night = {} } + for i = 1, 12 do + raw.timed.day[i] = 25 + raw.timed.night[i] = 163 + end + end + game.data.pokemon[25] = { name = "Pikachu" } + game.data.pokemon[163] = { name = "Hoothoot" } + game.data.pokemon[129] = { name = "Magikarp" } + game.data.encounters[7] = raw + local stable = freeze(game) + local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" })) + T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day") + T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night") + stable("cold DS timed query") + local Swarms = require("src.world.Gen4Swarms") + game.data.maps.A.header = Swarms.HEADERS[1] + game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 } + stable = freeze(game) + local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots") + stable("DS daily swarm read") + outbreak.dist.MAGIKARP = 0 + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP, + 40, + "DS distribution detached" + ) + game.save.gen4Swarm.enabled = false + if version == "platinum" then + game.data.maps.A.header = 287 + game.save.pokedex = { national = true } + game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 } + game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } } + stable = freeze(game) + local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" })) + T.eq(garden.dist.PIKACHU, 5, "garden first daily slot") + T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot") + stable("DS garden read") + end +end +-- A water table is not time-swapped by the native step path. +do + local api, game = encounters("gold") + game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) } + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water ignores land time selector" + ) +end +-- Numeric IDs follow the shared public name contract, including loader aliases. +do + local api, game = encounters("platinum") + game.data.pokemon[25] = { id = 25, name = "Pikachu" } + game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) } + T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID") +end +-- Default Gen2 period follows the clock, and queries never run world.tod hooks. +do + local api, game = encounters("gold") + local date = os.date + os.date = function(format) + if format == "%H" then + return "23" + end + return date(format) + end + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "live Gen2 clock" + ) + Runtime.hooks:wrap("world.tod", function() + callbacks = callbacks + 1 + end) + game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" } + game.stack.states = { game.overworld } + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "MORNING" }), + "cached effective mod period" + ) + os.date = date +end +-- Malformed tables and exceptional positional tables fail explicitly. +do + local api, game, a = encounters("red") + for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do + a.rateMax = bad + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator") + end + a.rateMax = nil + a.buckets = { 51, 50, 256 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused") + a.buckets = { 51, 102, 257 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused") + a.buckets = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused") + a.buckets = { 51, 102, 256 } + local originalSlots = a.slots + a.slots = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused") + a.slots = originalSlots + a.buckets = { 51, 102, 256 } + a.rate = 0 + T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate") + a.rate = 999 + T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate") + GameVersion.set("heartgold") + local S = require("src.world.HgssSafariZone") + game.data.maps.A.layout = S.LAYOUT + game.data.maps.A.cameraFamily = "hgss" + local stable = freeze(game) + T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused") + stable("Safari does not seed save") + GameVersion.set("emerald") + for _, id in ipairs({ + require("src.script.Gen3BattlePike").WILD_ROOM, + require("src.script.Gen3BattlePyramid").FLOOR, + }) do + game.data.maps[id] = { id = id } + T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused") + end +end +-- API1/2 native mod loading, lazy capability queries and live table patches. +for _, apiVersion in ipairs({ 1, 2 }) do + GameVersion.set("gold") + local data = T.fixtures.fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = apiVersion, + entry = "main.lua", + content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil, + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + elseif files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function(self) + resolves = resolves + 1 + return self.game + end + T.check(loader:load(data), "real loader succeeds") + local mod = loader.apis.probe + if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then + T.eq(mod.contentFormat, "unified", "unified content facade") + T.eq(mod.callbackFormat, "unified", "unified callback facade") + T.eq(mod.scriptFormat, "unified", "portable script facade") + end + T.eq(resolves, 0, "entry and ID context stay lazy") + if mod.supports then + T.eq(mod:supports("world", "mapOverview"), true, "overview capability") + T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability") + T.eq(resolves, 0, "capability query stays lazy") + end + loader.game = { data = data, save = {}, stack = { states = {} } } + local world = mod.world + T.eq(world:mapOverview(), nil, "title screen overview is quiet") + local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass")) + T.check(next(out.dist) ~= nil, "query available at title screen") + local before = out.chance + local id = next(out.dist) + data.pokemon[id].name = "Renamed presentation" + T.check( + world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil, + "public identity survives name patch" + ) + data.encounters.FIX_ROUTE.grass.rate = 100 + T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected") +end +T.eq(draws, 0, "all queries consume zero RNG draws") +T.eq(callbacks, 0, "all queries execute zero hook callbacks") +love.math.random, math.random = savedLoveRandom, savedMathRandom +Runtime.hooks = savedHooks +GameVersion.set(savedVersion) +T.finish("world overview and encounter API") diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua new file mode 100644 index 000000000..b7beda07f --- /dev/null +++ b/tests/engine/world_read_api.lua @@ -0,0 +1,415 @@ +-- ROM-free tests through the real loader and real runtime Map objects. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Json = require("src.link.Json") +local Reads = require("src.mods.WorldReads") +local savedLove, savedVersion = love, GameVersion.get() +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors +love = nil +local function pack16(...) + local out = {} + for _, v in ipairs({ ... }) do + out[#out + 1] = string.char(v % 256, math.floor(v / 256)) + end + return table.concat(out) +end +local function map(id, cells, packed, ds) + return Map.new({ + id = id, + width = 3, + height = 2, + blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 }, + borderBlock = 999, + generation = ds and 4 or nil, + }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} }) +end +local function boot(version, entry) + GameVersion.set(version) + local gen = GameVersion.generation() + local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION" + local species = { id = s, name = "Pikachu" } + local move = { id = m, name = "Tackle", pp = 35 } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { id = item, name = "Potion" }, + }, + } + if gen >= 4 then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local mon = { + species = s, + item = item, + nickname = "Buddy", + name = "native name", + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 }, + moves = { { id = m, pp = 29, ppUps = 1, hidden = false } }, + ivs = { hp = 27 }, + evs = { speed = 3 }, + personality = 12345, + extension = { flag = false }, + isEgg = false, + } + if gen > 1 then + mon.stats.spatk = 13 + mon.stats.spdef = 14 + end + local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } } + local game = { + data = data, + save = { party = { mon }, money = 500 }, + stack = { states = { ow, { isMenu = true } } }, + overworld = ow, + } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + local api = assert(loader.apis.probe) + return api, loader, game, ow, { s, m, item }, function() + return resolves + end +end + +-- Fail if any reader tries to load a map, generate a Pokemon or use RNG. +local oldMapLoad = MapLoader.load +local Pokemon = require("src.pokemon.Pokemon") +local oldNew = Pokemon.new +local oldRandom = math.random +local function forbidden() + error("read crossed into gameplay") +end +MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden + +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, ow, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game") + if api.supports then + for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolves(), 0, label .. "capability probes do not instantiate Game") + end + local before = Merge.deepCopy(game) + local world = api.world + T.eq(api.world, world, label .. "same cached facade") + local party = world:party() + local mon = party[1] + T.eq(mon.species, "PIKACHU", label .. "symbolic species") + T.eq(mon.item, "POTION", label .. "symbolic item") + T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move") + T.eq(mon.moves[1].pp, 29, label .. "current PP") + T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding") + T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed") + T.eq(mon.maxHp, 23, label .. "max HP from stats.hp") + T.eq(mon.stats.specialAttack, 13, label .. "special attack") + T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense") + T.eq(mon.status, "poison", label .. "status normalized") + T.eq(mon.isEgg, false, label .. "false flags preserved") + T.eq(mon.native.extension.flag, false, label .. "native false extension preserved") + T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics") + T.eq(getmetatable(mon), nil, label .. "plain detached mon") + mon.species = "OTHER" + mon.stats.hp = 1 + mon.moves[1].pp = 0 + mon.native.ivs.hp = 0 + party[2] = { species = "INJECTED" } + T.same(game, before, label .. "party mutations do not reach game") + local nextParty = world:party() + T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot") + T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot") + T.eq(#nextParty, 1, label .. "party membership detached") + game.save.party[1].hp = 18 + T.eq(world:party()[1].hp, 18, label .. "reads current native HP") + game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } } + T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save") + game.save = before.save + + local cells = ow.map.blockCells + before = Merge.deepCopy(game) + local id, metadata = world:activeBlockAt("ACTIVE", 0, 0) + T.eq(id, 0, label .. "block zero is present") + T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map") + T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size") + T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates") + T.eq(metadata.width, 3, label .. "block width") + T.eq(metadata.height, 2, label .. "block height") + local gen = GameVersion.generation() + T.eq( + metadata.kind, + gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block", + label .. "honest block kind" + ) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID") + T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded") + metadata.width = 999 + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached") + for _, xy in ipairs({ + { -1, 0 }, + { 0, -1 }, + { 3, 0 }, + { 0, 2 }, + { 0.5, 0 }, + { 0, 0.5 }, + { math.huge, 0 }, + { + 0, + 0 / 0, + }, + { + "1", + 0, + }, + }) do + local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2]) + T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected") + end + local missing, reason = world:activeBlockAt("NORTH", 0, 0) + T.check(missing == nil and reason == "map is not active", label .. "stale map rejected") + T.same(game, before, label .. "block reads do not alter world or decoder cache") + ow.map:setBlock(1, 0, 0) + local patched = Merge.deepCopy(game) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins") + T.same(game, patched, label .. "patch read remains pure") + ow.map.blockPatch[2] = 777 + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch") + local oldMap = ow.map + ow.map = ow.neighbors[1].map + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp") + T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map") + ow.map = oldMap + + before = Merge.deepCopy(game) + local legacy = world:loadedMaps() + local maps = world:liveMaps() + T.eq(#maps, 2, label .. "only loaded maps returned") + T.eq(maps[1].mapId, "ACTIVE", label .. "active first") + T.eq(maps[1].active, true, label .. "active tagged") + T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells") + T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included") + T.eq(maps[2].x, -2, label .. "neighbor x offset in cells") + T.eq(maps[2].y, -4, label .. "neighbor y offset in cells") + T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units") + maps[1].mapId = "MUTATED" + maps[2].block.width = 9 + maps[2].x = 123 + T.same(game, before, label .. "live map snapshots detached") + T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged") + T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots") + + game.stack.states = {} + game.overworld = nil + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result") + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason") + T.eq(world:liveMaps(), nil, label .. "no active world maps result") + T.check(world:party() ~= nil, label .. "party available without world") + game.save = nil + T.eq(world:party(), nil, label .. "no save party result") + T.eq(select(2, world:party()), "no save", label .. "no save reason") +end + +MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom +-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary +-- data reads in a host with no graphics/filesystem surface. +love = { math = { random = forbidden } } +local rngApi = WorldAPI.new( + { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } }, + "rng" +) +T.check(rngApi:party() ~= nil, "party never calls love.math.random") +T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random") +T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random") +love = nil +-- Direct engine construction also produces symbolic records without a loader. +local data = { + pokemon = { [999] = { name = "Mr. Mime" } }, + moves = { [888] = { name = "Ember", pp = 25 } }, + items = { + [777] = { name = "Potion" }, + }, +} +local raw = { + species = 999, + item = 777, + hp = 3, + stats = { hp = 9 }, + moves = { { id = 888, pp = 5 } }, + extension = { + flag = false, + }, +} +local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct") +local before = Merge.deepCopy(data) +local p = direct:party() +T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name") +T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves") +T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained") +T.same(data, before, "standalone resolver does not mutate data") +raw.self = raw +raw.callback = function() end +raw[{}] = { hidden = true } +local cyclic = direct:party()[1] +T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently") +T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions") +local tableKey = false +for k in pairs(cyclic.native) do + if type(k) == "table" then + tableKey = true + end +end +T.eq(tableKey, false, "diagnostics do not retain native table keys") +raw.self = nil +raw.callback = nil + +-- Metadata comes from the actual tileset even when its size is unusual. +local mixed = map("MIXED", 3, false, false) +local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed") +T.eq( + select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize, + 48, + "custom block size is not inferred from generation" +) +local missingMap = map("SHORT", 1, true, false) +missingMap.def.blocks = "\1" +local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short") +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused") +missingMap.def.blocks = {} +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused") +missingMap.def.blocks = { -1 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused") +missingMap.def.blocks = pack16(1) +missingMap.def._blockArray = { 7 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt") +local cache = missingMap.def._blockArray +T.eq(cache[1], 7, "warm cache unmodified") +missingMap.blockTiles = 3 +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused") +local placements = { isOverworld = true, map = map("HOME", 1, false, false) } +local repeated = map("NORTH", 1, false, false) +placements.neighbors = { + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 3, cy = 0 }, + { map = placements.map, cx = 0, cy = 0 }, + { map = map("OTHER", 2, false, false), cx = -4, cy = 0 }, +} +local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps() +T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain") +T.eq(list[2].mapId, list[3].mapId, "two placements share map identity") +T.eq(list[3].x, 3, "second placement offset preserved") +T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map") +T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells") +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:party(), nil, "no Game party read safe") +T.eq(empty:liveMaps(), nil, "no Game map read safe") +T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe") + +-- IDs stay bound to pre-merge identity despite presentation patches and +-- registrations made after the API/context was constructed. +local api, loader, game = boot( + "platinum", + [[return function(mod) + mod.exports.api=mod + mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"}) + mod.content.pokemon:register("CUSTOMMON",{ + id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"}, + catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST", + learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5, + baseStats={hp=40,attack=40,defense=40,speed=40,special=40}, + }) +end]] +) +game.save.party[1].species = "SPECIES_25" +game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} } +T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID") +T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID") +local emptyParty = game.save.party +game.save.party = {} +T.same(api.world:party(), {}, "empty saved party returns an empty snapshot") +game.save.party = emptyParty +game.save.party[1].item = 0 +game.save.party[1].status = nil +game.save.party[1].isEgg = true +T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent") +T.eq(api.world:party()[1].status, nil, "healthy status is absent") +T.eq(api.world:party()[1].isEgg, true, "egg flag exposed") +-- Optional integration gate against #92's actual event record encoder. +if pcall(require, "src.mods.UnifiedCallbacks") then + local Unified = require("src.mods.UnifiedCallbacks") + Unified.install(loader, api, "probe", 4) + local eventMon + api.events:on("pokemon.received", function(event) + eventMon = event.mon + end) + loader.events:emit("pokemon.received", { mon = game.save.party[1] }) + T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly") +end + +-- Verify both normal RNG streams independently, without relying only on the +-- tripwire above. Lua's sequence must remain unchanged by the real readers. +math.randomseed(24680) +local expected = { math.random(), math.random(), math.random() } +math.randomseed(24680) +api.world:party() +api.world:activeBlockAt("ACTIVE", 1, 0) +api.world:liveMaps() +T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged") +for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do + T.check(Reads.supports(name), "new capability " .. name) +end +T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow") + +GameVersion.set(savedVersion) +Runtime.install(savedEvents, savedHooks, savedErrors) +love = savedLove +T.finish() diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua new file mode 100644 index 000000000..ee1762048 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua @@ -0,0 +1,18 @@ +return function(mod) + mod.exports.npc = function(...) + return mod.world:npc(...) + end + mod.exports.sample = function(mapId, ref, appearance) + local h = assert(mod.world:npc(mapId, ref)) + assert(h:placeAt(1, 1, "right")) + local allowed = h:canStep("right") + assert(h:setPassable(true)) + assert(h:stepNow("right")) + local moving = h:isMoving() + assert(h:placeAt(3, 4, "up")) + assert(h:setPassable(false)) + assert(h:setAppearance(appearance)) + local x, y = h:position() + return { x = x, y = y, allowed = allowed, moving = moving, stopped = not h:isMoving() } + end +end diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json new file mode 100644 index 000000000..ac5706ad2 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json @@ -0,0 +1 @@ +{"id":"legacy","name":"Legacy NPC controls fixture","version":"1.0.0","api":2,"entry":"main.lua","gen2compat":true,"games":["gen1","gen2","gen3"]} diff --git a/tests/fixtures/legacy_npc_controls/worker.lua b/tests/fixtures/legacy_npc_controls/worker.lua new file mode 100644 index 000000000..bd4bbcf1b --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/worker.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = assert(arg[1]), assert(arg[2]), assert(arg[3]) +love = require("tests.love_stub") +local f = dofile(root .. "/tests/api3_contract/npcs.lua").fixture( + root .. "/tests/fixtures/legacy_npc_controls", + backend, + version, + "legacy" +) +f.loader._game = function() + return f.live +end +local result = f.exports.sample(f.home, "IMPORTED", "ALT") +assert(result.x == 3 and result.y == 4 and result.allowed and result.moving and result.stopped) +f.restoreRenderer() +print("LEGACY_NPC_RESULT " .. require("src.link.Json").encode(result)) diff --git a/tests/fixtures/upstream_mods/README.md b/tests/fixtures/upstream_mods/README.md new file mode 100644 index 000000000..66c1677bc --- /dev/null +++ b/tests/fixtures/upstream_mods/README.md @@ -0,0 +1,43 @@ +# External compatibility fixtures + +This directory contains an owned synthetic dataset, a loader probe, public +callback names and SHA-256 digests. It contains no reference implementation or +package source. The probe reads unchanged packages from a caller-supplied, +read-only checkout at the commit in `index.json`. + +Run from the repository root: + +``` +python3 tools/check_external_mod_fixtures.py --reference /absolute/reference \ + --lua /absolute/luajit --report .local/qa/mod-fixtures/report.json +``` + +The optional local Python runner requires the QA storage guard and its companion +modules. Supply their directory with `--qa-tools` when they are maintained outside +the checkout. It never falls back to unguarded local execution. + +The command authenticates every file before loading it. It also checks that the +eight shipped gallery packages match the external package bytes. Each package +gets an isolated worker running the production Loader, manifest validation, +registry merge and callback registration. The synthetic pack filesystem and CPU +image objects exercise entrypoints without providing cartridge art or claiming +rendered-image correctness. Writes stay in the worker's memory. + +A missing package, changed file, unknown hook, unloaded entrypoint or worker +failure makes the gate fail. The initial probe deliberately fails on +`mystery_dungeon_follower` at `src.import.pmd.Sprite`; retain that failed report as +the negative control. This is an executable compatibility gap, not a skipped or +successful test. After aliases are available, the requested intermediate +acceptance may name unsupported modules explicitly; complete acceptance requires +all entrypoints to load. ROM extraction is outside this gate. + +The gallery test additionally uses the synthetic dataset whenever no imported +cache is present, so `tests/mod_examples_tests.lua` runs its original effect and +hook assertions in a ROM-free checkout. It validates the legacy API-1 manifest +separately from the retired production example path. No compatibility fixtures +are placed under `mods/`. + +Use `--phase aliases` only for the explicitly defined intermediate acceptance: the +one sprite-interface gap must be reported with its module and affected consumer. +It is recorded as an expected gap, never as a loaded mod. Default complete +acceptance remains red until the entrypoint actually loads. diff --git a/tests/fixtures/upstream_mods/data.lua b/tests/fixtures/upstream_mods/data.lua new file mode 100644 index 000000000..db0b5d609 --- /dev/null +++ b/tests/fixtures/upstream_mods/data.lua @@ -0,0 +1,31 @@ +-- Synthetic data for exercising loader contracts without an imported cartridge. +-- Identifiers match consumer queries; records and art are test-owned stand-ins. +local M = {} +local function copy(value) + if type(value) ~= 'table' then return value end + local out = {}; for k,v in pairs(value) do out[k]=copy(v) end; return out +end +function M.fresh() + local data=require('tests.modkit.fixtures').fresh() + local seed=data.pokemon.FIXMON_A + for index,id in ipairs({'MEW','VENUSAUR','CHARIZARD','BLASTOISE','PIDGEY','RATTATA','SPEAROW','CHARMANDER'}) do + local mon=copy(seed);mon.id=id;mon.name=id;mon.index=index;mon.dex=index + data.pokemon[id]=mon + end + data.pokemon.MEW.dex=151 + for _,id in ipairs({'TACKLE','GROWL','QUICK_ATTACK','CUT','SURF'}) do + data.moves[id]=copy(data.moves.FIX_TACKLE);data.moves[id].id=id + end + data.items.POTION={id='POTION',name='Potion',price=300,tossable=true} + data.items.TM_TOXIC={id='TM_TOXIC',name='TM test',price=4000,machine={kind='TM',move='TACKLE'}} + data.tilesets.OVERWORLD=copy(data.tilesets.FIX_OUT) + for _,id in ipairs({'PALLET_TOWN','OAKS_LAB','ROUTE_1','VIRIDIAN_CITY','PEWTER_CITY'}) do + local map=copy(data.maps.FIX_TOWN);map.id=id;map.tileset='OVERWORLD';map.objects={};map.warps={};map.connections={} + data.maps[id]=map + end + data.encounters.ROUTE_1={grass={rate=10,slots={{level=3,species='PIDGEY'}}}} + data.sprites.SPRITE_RED=copy(data.sprites.SPRITE_FIX_PLAYER) + data.sprites.SPRITE_RED_BIKE=copy(data.sprites.SPRITE_RED) + return data +end +return M diff --git a/tests/fixtures/upstream_mods/index.json b/tests/fixtures/upstream_mods/index.json new file mode 100644 index 000000000..e36106415 --- /dev/null +++ b/tests/fixtures/upstream_mods/index.json @@ -0,0 +1,239 @@ +{ + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "fixtures": [ + { + "path": "example_mew_starter", + "files": { + "README.md": "7f2a710b80723aa71c1040d3c00bd2e5d153c0e8d75edcfe088f71da0b8a35de", + "assets/mew_back_inverted.png": "e860dc4ba66efdc3f8d8d1b8fe5c17cb008fdcb57fbd435f15c0e0a2d7b93ec8", + "assets/mew_back_inverted_64.png": "924f6f50eabab8cc2b405ae318b6b9beb033568c461c4bad82692467001201c3", + "assets/mew_front_inverted.png": "a785e64760bdcf790bf1fc6005524ed3352b9ced922275b68ce4a8944cb00e71", + "assets/mew_front_inverted_64.png": "4dedded623ade2ed07c3d9de3fb34d6b9456fc3a56aaeaf812afd55a877240a9", + "main.lua": "d3cda8d3f402e8b36d0546267d0008e071e40f9112847125957898dc85c198b4", + "manifest.json": "835590b79e2bb31fc3666f5e27469b67c7313f83037f284524a7f3f52b3dea0f", + "mod.card": "c342c8e59fb75e52091e7a19ed346c7048672c46414d61e34d828660806f7372" + } + }, + { + "path": "example_lttp_overhaul", + "files": { + "main.lua": "c206fc886986a1ed8daf47a4c1f1ca01cb86ece7224dc5d1601ea84a8f14e1aa", + "manifest.json": "5d43ad9518455facebb3ec9c9230bd856cc9a6cb072a13c531306113bbb9cc69", + "town.lua": "a7688fce525725a65bd4cfccb545972ad87b29e4bc24608eee738a3496a165fb" + } + }, + { + "path": "mystery_dungeon_follower", + "files": { + "README.md": "5e384571580ec21aecdc334d35671bbd45ae1ef77d7d47393d73c21a86343b4f", + "main.lua": "2009b76ba5bcfb7c459adc6de93ecc5a265216c0a45f49174cf4203bb55e65db", + "manifest.json": "15f24259f6f7ce963a34ccdc57c5f89b53ec685b4b9d3a95e7d45c2b5cc12f85" + } + }, + { + "path": "examples/portable_api", + "files": { + "main.lua": "d7920469c0fc6e7908cd0aca84e93e2cc7187df75702d775fb8a86034764a8e8", + "manifest.json": "c054ede45667d98e47a5f1a311271089c291b08e17ac3a847db2653675da9550" + } + }, + { + "path": "examples/gen5_battle_sprites", + "files": { + ".modkitignore": "08c5cc7829fa520a00e4a997e93e17ab886ea5a208b7d29087f6432af372ae81", + "CHANGELOG.md": "1f27fd3d6e9c713a9fe39f48b7c52955b5f1f0e17852ba314be769ae2734c84c", + "README.md": "c8cd82a339c7868401ca72a31cd73dc62ecb41f4cdc6f57870374e29545da4a9", + "main.lua": "1ef5a2d247ad080a424848da25e2049e5734ae3906f2c4aa600dc5e4ae840480", + "manifest.json": "ba05e72bc933548503a9ee4f6518239a467a6adb4843589365851db8da310e1c", + "mod.card": "bfafadaae24328f0f3aa4698377e0901160857d4c6e675f4348c4b893cc9ab6f", + "tests/provider_test.lua": "93a253b06437673b8e3b4d44e4acfd6956c310cdba4030eac10facd10a0cb608" + } + }, + { + "path": "examples/example_balance_tweaks", + "files": { + "CHANGELOG.md": "c2bf004d49a2430aa4a1b2a2cb5775f4e0f5095c028b6873db11d07837a454a1", + "README.md": "2f08f7cd51a4f5ef07a2a7fa94dc6e487d776eca374260fba47cb82b9e6ce2a7", + "main.lua": "7965513d3b57938f048469f6335b62fdfd799662902813517a51d255eb8dfda1", + "manifest.json": "9950d8fdaf8322aa16cefad086ef37f6983a8d81ab555f90fe63664ade20c7bb", + "mod.card": "d4fed4416a71281e7ed8b7ddf4a27122c83a8e63d9cf11827afecbd8ab56df70", + "tests/example_balance_tweaks_test.lua": "3859fe203dc1774c4bff7e928edb604567a8707eb4336ddad629335737dcd1fe" + } + }, + { + "path": "examples/example_dexnav", + "files": { + "CHANGELOG.md": "6ceadfb09065d86e07426d71dfb03e124b463272ba42ad364eed490774e9b843", + "README.md": "910bcca17506755696a21fb72c807a2b113a53e259332f1b9968eb48cbea3344", + "main.lua": "1ba6989142555077a65c6083f5cb691c2b350e4df3721dc710972a32b96290cb", + "manifest.json": "eb0f394097f5f767778521a02378557c283fbb678b932319ea1bb53fddde72df", + "mod.card": "d749d034ec5397b124bc96f7ec6e4a051dda49f03faf5fcad868aa73a97019fb", + "tests/example_dexnav_test.lua": "3f6cf884fcd27ce793153a30409d238fc60f2e6ffdefc5cfd4de6631cc6ca643" + } + }, + { + "path": "examples/example_jukebox", + "files": { + "CHANGELOG.md": "c4e3f09e38ba72888140ee0a27b94453dfa138ab62f40026dbf9c42ceb0a6af3", + "README.md": "417a37a730f80d05b9948b22cb1d842e1e3ada4475fd32be98f7022ef2fa0307", + "main.lua": "d2f153f3aee094d154c6f223cb1c6efaf458de5b3714c642953868537c3711e6", + "manifest.json": "3691e9b41f51733c8449854187285fbc0aa94e836e01739858485b3181243d6c", + "mod.card": "3260bc14e18049ea0a1a4c299e1d80690a02bda8c0b1a292a591a39e82a8772f", + "song.lua": "9ace700345831a45f6a69f158ec21e518923a81cf533ddfca1a39c608e2085dc", + "tests/example_jukebox_test.lua": "e391cc36ab539ea19678f4a6a6d9296bcaf0c2d5de9c33e7bdaccc74a9fb1468" + } + }, + { + "path": "examples/example_lost_parcel", + "files": { + "CHANGELOG.md": "874c37283a2966bf7cc8c84d188c3236923a3c9be1e34405804d5d4bed4e77e3", + "README.md": "03d5a96346f2eb0d4abf7d089993b13d072aedf808bf8efe2dd9c7e699f1c1ba", + "main.lua": "c12780da1f628393beffe164e8fd5b936c3097153a245a9b77b8a146b2d40b72", + "manifest.json": "fbce2a37050a22b8eba63c9b4d2b83ca858a2a3647181f3dc89776bcdee0e77b", + "mod.card": "de071623e46b7399dfc2b4806914800e98c9c87d1cb0e6fb8ec16d16b4268873", + "tests/example_lost_parcel_test.lua": "830c1b568ddb8fc8936c4ddccae0dbe570e84f132394c08bcec922520d627204" + } + }, + { + "path": "examples/example_mini_conversion", + "files": { + "CHANGELOG.md": "5b2859552307c79a6c2c48a77adfa8428894fcc18c2a6d2028e39edad7aeb384", + "README.md": "b72557832443bf0e6a046c4a0f5e09b710051db46f88137ca8e5632dbfb916aa", + "assets/emberkit_back.png": "c43583aa7236ba089a584a17a823ee118d026168a2f924c8a1cfa35bb3156f2e", + "assets/emberkit_front.png": "615300dd5ae8adedffe0d291599d8f2e563cb4a618503590fd1ac7d8ed3b02a7", + "assets/emberkit_icon.png": "de25a5bc5d21c69a11c60302acb1ba80442024e2f524343d9921de6dc3d848ea", + "assets/mossling_back.png": "2c7318680d922f7d4e6faed28c7ada90d0fcc85b99959d4881c0e740d82b1c25", + "assets/mossling_front.png": "dc345925977760209798525e2803f8b8e026001e570b7eeb84ff1e30f55763ec", + "assets/mossling_icon.png": "4dce52410e5b363860c0cbdb89b551716a8edb27a3d884f57c63b6fdfe75ca4c", + "assets/tidepup_back.png": "62f9285c9140cfd4860edc9ac62c4def3a665b1ef71e0b798c7b300245995ca7", + "assets/tidepup_front.png": "d40b73febd54792fab4786ead1647d925e0d9485ccfab8264de0ba0119dc733c", + "assets/tidepup_icon.png": "c9f5fc63e6d6e1e44456e2fa3269baa91f7a6e5c4a7d6707fd58362fc44ff800", + "main.lua": "a6422be98ef0bd836256492f3db56f43320097b6112e843362b203a030f68e04", + "manifest.json": "db5243b57e3a644457499022e70b77f3812f01b9336fd81b8f742dce11d1dcfb", + "mod.card": "f78ab29616d631ea8035c5a755c48794b89443df427be418c1743955e6e966ef", + "tests/example_mini_conversion_test.lua": "141cca215c97b2dcc8daedcf5bc11b5e1ae0d5d5ce3e821b467441724c649b54", + "tools/make_assets.py": "e9ee0d9ce949ee52b2b141e2ac2c82a48f45247a2003b56672f8cd223ffc16ec" + } + }, + { + "path": "examples/example_shiny_palette", + "files": { + "CHANGELOG.md": "bda0e3903af2e0cc65fa6866091da6c6f8625a4a8e42606f9e0f9f11a16d3461", + "README.md": "46bfda385d398fe3f81e655a92686913b1e54f43c820d2929960c3ab2844d650", + "assets/accent_sparkle.png": "bfbc34694108fa6501cc1b1bba1ddcacc5010d3d752290ae473fa33a888a23a2", + "main.lua": "9071e23785aa0c9fcd2fc87df303958573689bf4fa1be939e29b610437549a0e", + "manifest.json": "263f8626e8a875f502251d71edb3908fb8f6d8d833d6257e2c2c7fb66056ad9e", + "mod.card": "5b40433baaf4a5ba82b855f067f8e565ee75a3a9e67879973bc5a94dd97dec60", + "tests/example_shiny_palette_test.lua": "ffba892ce7390f8ec99fdb84fac3e31b797251dee86582933b2d03868b90944c", + "transforms.lua": "66bb07464003969402abe5c0b6fdfe43b20a93f578e06f48b96de75b2f2594eb" + } + }, + { + "path": "examples/example_silly_oak", + "files": { + "CHANGELOG.md": "0077f49b1a8415edaf278e7da0f7ac924af886dbbba92795a22903d3150e9481", + "README.md": "5d3d65f536bd9b5bb195cfc3dda2b2fa7bf8ecac3ead1abd066a802d066b8df1", + "assets/toast_kid.png": "02963b7c5c55e1e80d7ec4eaf274776170dd924ae421f29af14020bc3f32bc7d", + "main.lua": "2f7fbc5c6555f6d20ffa27c20233f0532368cca165a0afa7d393afd75f23de48", + "manifest.json": "cac8e5d240261cca5e6c02ff9e9446a74162200c2534e44589549a29d4a92026", + "mod.card": "0affa0fb359320b5b043731458d59eb92de4330bd4d3913ce9c35496b7ef3539", + "tests/example_silly_oak_test.lua": "3be13592fc90452edcc8c8589ab7f14b5690a5a721b5617871d17d699eea6eeb" + } + }, + { + "path": "examples/example_weather", + "files": { + "CHANGELOG.md": "82e88c93893ae5375a0151c9522cc8b082694bb53af5839f89cc55f9249acd37", + "README.md": "3c3490d87f38f37104ed46d22bdefa7aeed6815ecbb6a69834f9738a81fe7b13", + "main.lua": "469e663f3eaaaa9557253e2a7022e65d332731f438ef532a49a5715f354dd2eb", + "manifest.json": "7ddb5dd1f9c09b19b9cfbf61e37b1df48fa7321c93728495fbe778d00ab6f1d8", + "mod.card": "02a4949b455ccf12a3f00b33e39f8c0327f4544179de59c5c45108d3aae79e46", + "tests/example_weather_test.lua": "77fd2dd1fdc737b65511c6e89bca5b1077c0de27a2254168427f253aef3f0a1f" + } + } + ], + "hooks": [ + "battle.accuracy", + "battle.bottom_ui_visible", + "battle.catch_exp", + "battle.caught_marker_visible", + "battle.charge_required", + "battle.crit", + "battle.damage", + "battle.enemy_action", + "battle.enemy_switch_or_item", + "battle.exp_award", + "battle.field_residual", + "battle.low_health_alarm", + "battle.menu_auxiliary", + "battle.move_grid_navigation", + "battle.overlay", + "battle.run", + "battle.status_hud_visible", + "battle.style", + "battle.turn_order", + "breeding.compatibility", + "catch.nickname", + "catch.party_full", + "catch.rate", + "core.logic_speed", + "core.quit_to_launcher", + "core.update", + "encounter.fishing", + "encounter.roll", + "encounter.species", + "encounter.table", + "evolution.check", + "exp.gain", + "fieldmove.eligibility", + "gender.roll", + "held_item.trigger", + "input.gamepad", + "input.key", + "input.pointer", + "input.step", + "input.wheel", + "intro.oak_speech.build", + "item.use", + "link.fingerprint", + "map.palette", + "movement.collision", + "movement.speed", + "music.select", + "music.volume", + "phone.contact_list", + "player.sprite", + "pokemon.icon", + "pokemon.level_visible", + "pokemon.sprite", + "render.compose", + "render.hud", + "render.letterbox", + "render.output", + "render.output_enabled", + "render.viewport", + "render.window", + "render.zones", + "save.new_game", + "save.write", + "screen.render_visible", + "script.command", + "shiny.roll", + "trainer.before_battle", + "trainer.party", + "transition.style", + "ui.list_menu", + "ui.naming.grid", + "ui.options.rows", + "ui.party.grid_navigation", + "ui.party.submenu", + "ui.pc.items", + "ui.start_menu.items", + "ui.title_menu.items", + "warp.destination", + "world.follower.spawn", + "world.talk", + "world.tod", + "zoom.range" + ] +} diff --git a/tests/fixtures/upstream_mods/worker.lua b/tests/fixtures/upstream_mods/worker.lua new file mode 100644 index 000000000..e99379d1d --- /dev/null +++ b/tests/fixtures/upstream_mods/worker.lua @@ -0,0 +1,95 @@ +-- Read-only external packages; production Loader, manifests, registries and buses. +package.path='./?.lua;./?/init.lua;'..package.path +local reference,fixture,version=assert(arg[1]),assert(arg[2]),arg[3] or 'red' +love=require('tests.love_stub') +local Json=require('src.link.Json') +local Files=require('tests.fs_io').new(reference) +local Writer=require('src.import.LuaWriter') +local I={ + manifestPath=function(importer,pack)return 'asset_packs/'..importer..'/'..pack..'/pack.lua' end, + assetPath=function(importer,pack,file)return 'asset_packs/'..importer..'/'..pack..'/'..file end, +} +local raw=assert(Files.read('mods/'..fixture..'/manifest.json')) +local manifest=assert(Json.decode(raw));local id=manifest.id +local mount='mods/'..id +local overlay={['options.lua']=Writer.encode({mods={[id]=true},modCallbackProfiles={[id]=id=='example_mew_starter' and 'decoded' or nil}})} +local function pack(importer,kind,name,entry) + local p={format=1,importer=importer,pack=kind,kind=kind=='tiles' and 'tileset' or 'sprite',version='1.1.0',source={name='Synthetic fixture',md5=string.rep('0',32)},entries={[name]=entry}} + overlay[I.manifestPath(importer,kind)]=Writer.encode(p) + overlay[I.assetPath(importer,kind,entry.file)]='PNG!' +end +pack('lttp','sprites','player',{file='synthetic.png',size=4,width=16,height=24}) +pack('lttp','tiles','kakariko',{file='synthetic.png',size=4,width=1024,height=1024}) +pack('pmd_red','sprites','pikachu',{file='synthetic.png',size=4,width=16,height=24,sprite={dexNumber=25,monsterId=25}}) +local function remap(path) + if path==mount then return 'mods/'..fixture end + if path:sub(1,#mount+1)==mount..'/' then return 'mods/'..fixture..path:sub(#mount+1) end + return path +end +local fs={root=reference} +function fs.read(path)if path:match('^assets/generated/')then return nil end;return overlay[path] or Files.read(remap(path))end +function fs.write(path,bytes)overlay[path]=bytes;return true end +function fs.remove(path)overlay[path]=nil;return true end +function fs.rename(a,b)if not overlay[a]then return false end;overlay[b]=overlay[a];overlay[a]=nil;return true end +function fs.createDirectory()return true end +function fs.load(path)local bytes=fs.read(path);if not bytes then return nil,'missing '..path end;return loadstring(bytes,'@'..path)end +function fs.getInfo(path) + if overlay[path]then return {type='file',size=#overlay[path]}end + if path=='mods'then return {type='directory'}end + if path:match('^assets/generated/')then return nil end + return Files.getInfo(remap(path)) +end +function fs.getDirectoryItems(path)if path=='mods'then return {id}end;return Files.getDirectoryItems(remap(path))end +-- CPU image objects exercise the real assembling consumer, without claiming pixels. +love.image={newImageData=function(w,h) + if type(w)=='string'then w,h=1024,1024 end + return {getWidth=function()return w end,getHeight=function()return h end, + getDimensions=function()return w,h end,paste=function()end,setPixel=function()end, + getPixel=function()return 0.5,0.5,0.5,1 end,getString=function()return 'synthetic' end, + release=function()end,encode=function()return {getString=function()return 'PNG!' end}end} +end} +require('src.core.GameVersion').set(version) +local data=require('tests.fixtures.upstream_mods.data').fresh() +local Loader=require('src.mods.Loader') +local loader=Loader.new({fs=fs,dev=false}) +local ok,result=pcall(loader.load,loader,data) +local names,known={},{} +local handle=assert(io.open('tests/fixtures/upstream_mods/index.json','rb')) +local index=assert(Json.decode(handle:read('*a')));handle:close() +for _,name in ipairs(index.hooks)do known[name]=true end +for _,bus in ipairs({loader.hooks,loader._decodedHooks})do + for name in pairs(bus.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +end +table.sort(names) +local loaded=ok and result==true and loader.mods[id] and loader.mods[id].state=='loaded' +local errorText=ok and table.concat(loader.errors,'; ') or tostring(result) +-- Execute a registered gift handler, without substituting any loader/API method. +if loaded and id=='example_mew_starter' and version=='red' then + local gift={species='CHARMANDER',level=5,ctx={overworld={map={id='OAKS_LAB'}},save={flags={},party={}}}} + loader.events:emit('pokemon.before_give',gift) + assert(gift.species=='MEW' and gift.level==20,'legacy starter event did not run') +end +-- Authentic unchanged starter code now runs through the actual Gen-3 compiler, +-- runner, existing gift event and Pokémon constructor on test-owned data. +local functional +if loaded and id=='example_mew_starter' and version=='firered' then + data.maps.MAP_G04_N03={id='MAP_G04_N03',group=4,number=3} + data.constants.speciesOrder={[4]='CHARMANDER',[151]='MEW'} + data.map_scripts={source='RomExtractorGen3',scripts={ENTRY={ + {'setvar',0x4002,4},{'givemon',0x4002,5,0},{'end'}, + }},maps={}} + local save={flags={},party={},gen3Vars={},player={name='TEST',id=12345}, + pokedex={seen={},owned={}}} + local game={data=data,save=save,mods=loader} + local world={map={id='MAP_G04_N03',def=data.maps.MAP_G04_N03}} + local runner=require('src.script.ScriptRunner').new(game,world) + runner:exec(require('src.script.Gen3ScriptVM').compile(data,'ENTRY'),runner:makeContext()) + assert(save.gen3Vars[0x4002]==151,'unchanged starter hook did not rewrite the native variable') + local mon=assert(save.party[1],'native gift did not create a Pokémon') + assert(#save.party==1 and mon.species=='MEW' and mon.level==20 and mon.nickname=='HOGHEAD', + 'unchanged starter event did not produce the requested native gift') + assert(world.map.id=='MAP_G04_N03','profile renamed the native map') + functional={species=mon.species,level=mon.level,nickname=mon.nickname,variable=save.gen3Vars[0x4002]} +end +print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText,functional=functional})) +if not loaded then os.exit(1)end diff --git a/tests/goldens/fixture_fingerprint.md b/tests/goldens/fixture_fingerprint.md new file mode 100644 index 000000000..55c09281f --- /dev/null +++ b/tests/goldens/fixture_fingerprint.md @@ -0,0 +1,47 @@ +# Fixture link fingerprint audit + +The previous `7de3051a7b3c108e` pin was already stale at Gen2Recomped's +`0b8c9c4` import: that checkout computes `8c94d595cd60ca69` against its own +ROM-free fixtures. It is not a baseline that current Gen2 data can satisfy. + +At upstream `13af468b97dc37015d14ad5c71d7f067c3ee810d`, the same production +serializer and digest compute `41e69caf4afa35ab`. Comparing the canonical +`Fingerprint.surface(data, {})` strings against `0b8c9c4` changes **only** +`move_effects`: commit `3252aec` adds these four full-handler registry records: + +- `POWER_BASED_ON_LOW_SPEED` (Gyro Ball) +- `INCREASE_POWER_WITH_MORE_HP` (Wring Out / Crush Grip) +- `DOUBLE_POWER_WHEN_BELOW_HALF` (Brine) +- `DOUBLE_POWER_HEAL_SLEEP` (Wake-Up Slap) + +No records were removed or altered in the serialized surface comparison. +The current surface has 198 effect records. Species, moves, type chart, +statuses, constants and mod list are byte-identical to the imported checkout. +An independent Python implementation using integer XOR, multiplication by +16777619 and modulo 2**32 in both lanes also produces the current digest. +No fingerprint algorithm, allowlist, cache or production registry is changed +by this test repair. + +`fixture_fingerprint_sections.txt` pins each populated section separately so +future intentional registry additions cannot hide changes in another section. +`gate_fingerprint.lua` retains all prior mutation and determinism assertions, +and additionally proves that removing any of the four Sinnoh registrations +changes compatibility. An empty `link_fields` registry has no serialized +section; a future nonempty one requires a deliberate new section pin. + +To audit a future change, load a fresh `tests.modkit` fixture with +`T.sdk.loadNone({data=data})`, compare `Fingerprint.surface(data,{})` section +by section, and then update only reviewed pins. Run the gate again and keep +the mutation assertions enabled. The ROM-dependent vanilla golden is separate +and was not re-blessed by this update. + +## Current upstream Gen4 effect registrations + +Upstream `8d5b4387` adds 53 native Gen4 effect registrations through +`Gen4MoveEffects` and the existing non-destructive registry merge. Comparing +canonical fixture surfaces against `ed5189c2` changes only `move_effects`: +53 additions, no removals, and no changed existing kind/revision records. +The other six populated sections remain byte-identical. The whole fingerprint +is now `884a4b379d3e2f83`, and the effect section is `1c01a759e13618d5`. +The production serializer, digest and all gate mutation assertions remain +unchanged. These pins acknowledge the new upstream registry surface. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..d43cb64a0 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +884a4b379d3e2f83 diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt new file mode 100644 index 000000000..4437d24e9 --- /dev/null +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -0,0 +1,7 @@ +pokemon 0ad4d8a59c0daa59 +moves 54b3cb2c1776ed48 +type_chart cc5bd80d003bd459 +statuses 68247ced2ed19a21 +move_effects 1c01a759e13618d5 +constants e4cf6f91ff122fa5 +mods 6a9ee9de68f78902 diff --git a/tests/mod_examples_tests.lua b/tests/mod_examples_tests.lua index 64b84b2d7..0dbff0a1a 100644 --- a/tests/mod_examples_tests.lua +++ b/tests/mod_examples_tests.lua @@ -141,25 +141,16 @@ for _, id in ipairs(IDS) do end end --- the legacy entry keeps its v1 manifest and gains only a card +-- API-1 manifest validation remains covered even when the old demonstration +-- is not installed in the production mod directory. The pinned external +-- fixture gate loads the untouched original separately. do - local cardChunk = loadfile("mods/example_mew_starter/mod.card") - if check(cardChunk ~= nil, "example_mew_starter: ships a mod.card") then - local ok, card = pcall(cardChunk) - check(ok and type(card) == "table", "example_mew_starter: the card parses") - check(ok and card.compat and card.compat.modApi == 1, - "example_mew_starter: the card declares api 1, not 2") - end - local body = io.open("mods/example_mew_starter/manifest.json", "rb") - if check(body ~= nil, "example_mew_starter: manifest is readable") then - local manifest = require("src.link.Json").decode(body:read("*a")) - body:close() - check(manifest.api == nil, "example_mew_starter: stays an api 1 manifest") - eq(manifest.category, "GAMEPLAY", - "example_mew_starter: keeps the legacy category value") - local ok = pcall(Manifest.validate, manifest, "mods/example_mew_starter") - check(ok, "example_mew_starter: the v1 manifest still validates") - end + local ok, manifest = pcall(Manifest.validate, { + id = "legacy_gallery_probe", name = "Legacy gallery probe", version = "1.0.0", + entry = "main.lua", category = "GAMEPLAY", + }, "tests/fixtures/upstream_mods/legacy_gallery_probe") + check(ok, "legacy api-1 manifest still validates") + eq(ok and manifest.api, 1, "omitted api selects legacy semantics") end -- ------- disabled by default @@ -176,18 +167,13 @@ do for _, id in ipairs(IDS) do check(not top[id], id .. " is not discoverable at the mods/ root") end - check(top.example_mew_starter, - "the legacy example is still discovered at the mods/ root") + check(not top.example_mew_starter, + "external compatibility fixtures are not installed as player mods") end -- ------- the gallery loads -local data = freshImported() -if not data then - print("modkit: example gallery load skipped -- no imported dataset in " - .. "data/generated/ to fold the examples against") - return S.finish() -end +local data = freshImported() or require("tests.fixtures.upstream_mods.data").fresh() -- The transform in example_shiny_palette reads the imported cache and -- writes under save/mod-derived/. The io-backed harness filesystem has no diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..8b759d1e9 --- /dev/null +++ b/tools/api3-contract-check/check.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Run the identical API-3 mod files on Gen2Recomped and pinned Gen1Recomp.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +GEN1_REFS = ("79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91") +VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald") + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gen1", required=True, type=Path, help="clean checkout at the pinned Gen1 commit") + parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + gen1, gen2 = args.gen1.resolve(), args.gen2.resolve() + reference_ref = git(gen1, "rev-parse", "HEAD") + if reference_ref not in GEN1_REFS: + parser.error("reference must be pinned to one of " + ", ".join(GEN1_REFS)) + if git(gen1, "status", "--porcelain", "--untracked-files=normal"): + parser.error("Gen1 reference has tracked modifications; use an isolated checkout") + fixtures = gen2 / "tests" / "api3_contract" + manifest = {} + for path in sorted((fixtures / "mods").rglob("*")): + if path.is_file(): + manifest[str(path.relative_to(fixtures))] = hashlib.sha256(path.read_bytes()).hexdigest() + results = [] + scaffold_temp = tempfile.TemporaryDirectory(prefix="api3-scaffold-") + scaffold_root = Path(scaffold_temp.name) + for name, options in (("portable", ["--api", "3"]), ("legacy", [])): + subprocess.run([sys.executable, str(gen2 / "tools/modkit.py"), "scaffold", name, + "--dest", str(scaffold_root / "mods"), "--quiet", *options], + cwd=gen2, check=True, capture_output=True, text=True, timeout=60) + generated = json.loads((scaffold_root / "mods/portable/manifest.json").read_text()) + assert generated["api"] == 3 and "game_version" not in generated + assert generated["games"] == ["gen1", "gen2", "gen3"] + legacy = json.loads((scaffold_root / "mods/legacy/manifest.json").read_text()) + assert legacy["api"] == 2 and legacy["game_version"].startswith(">=") + generated_hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in (scaffold_root / "mods/portable").iterdir() + if path.is_file()} + for version in VERSIONS: + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs"): + values = {} + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + source = scaffold_root if mode == "scaffold" else fixtures + command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode] + run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60) + reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("API3_CONTRACT ")] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}") + values[backend] = json.loads(reports[0]) + if values["gen1"] != values["gen2"]: + raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") + results.append({"version": version, "mode": mode, "result": values["gen1"]}) + print(f"PASS {version}/{mode}: identical mod and public result") + report = {"gen1_ref": reference_ref, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), + "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + scaffold_temp.cleanup() + print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/check_decoded_profiles.py b/tools/check_decoded_profiles.py new file mode 100644 index 000000000..7af5f6f0e --- /dev/null +++ b/tools/check_decoded_profiles.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Measure native script execution with an owned LÖVE window and F11 sections.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--npc-controls', action='store_true') + parser.add_argument('--baseline', required=True, help='local pre-M5 Git commit') + parser.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + files = ('src/script/ScriptRunner.lua', 'src/script/DecodedScript.lua', + 'src/script/Gen2ScriptVM.lua', 'src/script/Gen3ScriptVM.lua', + 'src/script/Commands.lua', 'src/mods/Loader.lua', 'src/mods/DecodedCallbacks.lua', + 'tools/decoded-profile-check/main.lua', 'tools/decoded-profile-check/conf.lua') + if args.npc_controls: + files = ('src/world/WorldAPI.lua', 'src/world/NpcControls.lua', 'src/mods/Api3NPC.lua', + 'src/mods/Loader.lua', 'tools/decoded-profile-check/main.lua', + 'tools/decoded-profile-check/conf.lua', 'tools/decoded-profile-check/npcs.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/main.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + before = qa_run(['git', 'rev-parse', '--verify', args.baseline + '^{commit}'], cwd=root, + capture_output=True, text=True, check=True).stdout.strip() + baseline_path = 'src/world/WorldAPI.lua' if args.npc_controls else 'src/script/ScriptRunner.lua' + blob = qa_run(['git', 'show', before + ':' + baseline_path], cwd=root, + capture_output=True, check=True).stdout + baseline = args.report.parent / 'baseline-runner.lua' + baseline.write_bytes(blob) + identity = 'oasc-gen2recomp-qa-decoded-profile-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/decoded-profile-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + DECODED_PROFILE_REPO=str(root), DECODED_PROFILE_QA_GUARDED='1', + DECODED_PROFILE_BASELINE=str(baseline.resolve()), + NPC_CONTROLS_QA='1' if args.npc_controls else '0'), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + prefix = 'M6_NATIVE_RESULT ' if args.npc_controls else 'M5_NATIVE_RESULT ' + records = [line[len(prefix):] for line in result.stdout.splitlines() if line.startswith(prefix)] + if result.returncode or len(records) != 1: + raise RuntimeError('native profile probe failed; see ' + str(args.report.with_suffix('.log'))) + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(records[0]) + report['baselinePath'] = baseline_path + report.update(baseline=before, baselineRunnerSha256=hashlib.sha256(blob).hexdigest(), + sha256=hashes, minimumFreeGiB=20, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/check_external_mod_fixtures.py b/tools/check_external_mod_fixtures.py new file mode 100644 index 000000000..b5ff89d33 --- /dev/null +++ b/tools/check_external_mod_fixtures.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Authenticate external packages and exercise their unchanged entrypoints.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path, help='directory providing qa_storage.py and its companion modules') + parser.add_argument('--lua', default=os.environ.get('LUA', 'luajit')) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--phase', choices=('complete', 'aliases'), default='complete') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + reference = args.reference.resolve() + if args.qa_tools: + sys.path.insert(0, str(args.qa_tools.resolve())) + try: + from qa_storage import qa_source, qa_cache, qa_run + except ImportError: + parser.error('local QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(reference) + qa_cache(args.report.parent) + index = json.loads((root / 'tests/fixtures/upstream_mods/index.json').read_text()) + head = subprocess.check_output(['git', '-C', str(reference), 'rev-parse', 'HEAD'], text=True).strip() + if head != index['reference']: + parser.error('reference checkout does not match the authenticated commit') + results = [] + for fixture in index['fixtures']: + package = reference / 'mods' / fixture['path'] + files = {str(p.relative_to(package)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in package.rglob('*') if p.is_file()} + if files != fixture['files']: + raise RuntimeError('external package bytes differ: ' + fixture['path']) + if fixture['path'].startswith('examples/example_'): + shipped = root / 'mods' / fixture['path'] + for name, digest in fixture['files'].items(): + if hashlib.sha256((shipped / name).read_bytes()).hexdigest() != digest: + raise RuntimeError('shipped gallery bytes differ: ' + fixture['path'] + '/' + name) + versions = ('red', 'firered') if fixture['path'] == 'example_mew_starter' else ('red',) + for version in versions: + run = qa_run([args.lua, str(root / 'tests/fixtures/upstream_mods/worker.lua'), + str(reference), fixture['path'], version], cwd=root, + capture_output=True, text=True, timeout=60) + lines = [line[len('FIXTURE_RESULT '):] for line in run.stdout.splitlines() + if line.startswith('FIXTURE_RESULT ')] + if len(lines) != 1: + raise RuntimeError(f"{fixture['path']}/{version}: missing worker result\n{run.stdout}{run.stderr}") + result = json.loads(lines[0]) + result['exit_code'] = run.returncode + expected = ('Compatibility module src.import.pmd.Sprite is unavailable in this engine; ' + 'affected mods: mystery_dungeon_follower') + result['accepted_gap'] = (args.phase == 'aliases' and fixture['path'] == 'mystery_dungeon_follower' + and run.returncode == 1 and not result['loaded'] + and result['error'].endswith(expected)) + results.append(result) + print(('EXPECTED GAP ' if result['accepted_gap'] else + 'PASS ' if result['loaded'] and not run.returncode else 'FAIL ') + + fixture['path'] + '/' + version + ': ' + result['error'], flush=True) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps({'reference': head, 'phase': args.phase, 'fixtures': results}, indent=2) + '\n') + return 0 if all((r['loaded'] and r['exit_code'] == 0) or r['accepted_gap'] for r in results) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/check_gen2_registry_contract.py b/tools/check_gen2_registry_contract.py new file mode 100644 index 000000000..a20dee711 --- /dev/null +++ b/tools/check_gen2_registry_contract.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare the five public Gen2 registry descriptors with a pinned backend.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--love', type=Path) + parser.add_argument('--baseline', help='local commit for the optional native validation profile') + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + reference=args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + if args.love: + if not args.baseline: parser.error('--love requires --baseline') + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True,exist_ok=True) + ref=qa_run(['git','rev-parse','HEAD'],cwd=reference,capture_output=True,text=True,check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip() or ref!='535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference must be the clean pinned contract') + worker=root/'tools/gen2-registry-contract/fields.lua' + digest=hashlib.sha256(worker.read_bytes()).hexdigest() + results=[] + for engine in (reference,root): + run=qa_run([args.lua,str(worker)],cwd=engine,capture_output=True,text=True,timeout=60) + records=[line.removeprefix('REGISTRY_CONTRACT ') for line in run.stdout.splitlines() + if line.startswith('REGISTRY_CONTRACT ')] + if run.returncode or len(records)!=1: + raise RuntimeError(run.stdout+run.stderr) + results.append(json.loads(records[0])) + if results[0]!=results[1]: + raise RuntimeError('public registry descriptors differ: '+json.dumps(results,sort_keys=True)) + if hashlib.sha256(worker.read_bytes()).hexdigest()!=digest: + raise RuntimeError('contract consumer changed during the comparison') + report={'reference':ref,'consumerSha256':digest,'registries':results[1]} + if args.love: + baseline=qa_run(['git','show',args.baseline+':src/mods/Schemas.lua'],cwd=root, + capture_output=True,check=True).stdout + path=args.report.parent/'baseline-schemas.lua' + path.write_bytes(baseline) + identity='oasc-gen2-registry-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/gen2-registry-contract')],cwd=root, + env=dict(os.environ,GEN2_REGISTRY_QA='1',GEN2_REGISTRY_ROOT=str(root), + GEN2_REGISTRY_BASELINE=str(path.resolve()),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=60) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + profiles=[line.removeprefix('REGISTRY_PROFILE ')for line in run.stdout.splitlines() + if line.startswith('REGISTRY_PROFILE ')] + if run.returncode or len(profiles)!=1:raise RuntimeError('native registry probe failed') + report['profile']=json.loads(profiles[0]) + report['baseline']=args.baseline + args.report.write_text(json.dumps(report,indent=2)+'\n') + print('PASS: five identical Gen2 registry descriptors') + + +if __name__=='__main__': + main() diff --git a/tools/check_legacy_npc_contract.py b/tools/check_legacy_npc_contract.py new file mode 100644 index 000000000..5e4f6b8a3 --- /dev/null +++ b/tools/check_legacy_npc_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Compare the unchanged API-2 NPC consumer on both pinned native backends.""" +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_run + reference = args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + ref = qa_run(['git','rev-parse','HEAD'], cwd=reference, capture_output=True, text=True, check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip(): raise RuntimeError('pinned reference has tracked modifications') + if ref != '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference does not match the pinned NPC contract') + files=root/'tests/fixtures/legacy_npc_controls/mods/legacy' + hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} + worker = root/'tests/fixtures/legacy_npc_controls/worker.lua' + records=[] + for version in ('red','blue','yellow','gold','silver','crystal','firered','emerald'): + results={} + for backend, engine in [('gen1',reference),('gen2',root)]: + result=qa_run([args.lua,str(worker),str(root),backend,version],cwd=engine, + capture_output=True,text=True,timeout=60) + lines=[line[len('LEGACY_NPC_RESULT '):]for line in result.stdout.splitlines() + if line.startswith('LEGACY_NPC_RESULT ')] + if result.returncode or len(lines)!=1: + raise RuntimeError(f'{backend}/{version}:\n{result.stdout}{result.stderr}') + results[backend]=json.loads(lines[0]) + if results['gen1']!=results['gen2']: + raise RuntimeError(f'{version}: NPC contract differs: {results}') + records.append({'version':version,'result':results['gen2']}) + print('PASS '+version,flush=True) + if {p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} != hashes: + raise RuntimeError('NPC consumer bytes changed during the comparison') + args.report.write_text(json.dumps({'reference':ref,'consumerSha256':hashes,'comparisons':records},indent=2)+'\n') + + +if __name__=='__main__': + main() diff --git a/tools/decoded-profile-check/conf.lua b/tools/decoded-profile-check/conf.lua new file mode 100644 index 000000000..eff68e98f --- /dev/null +++ b/tools/decoded-profile-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("DECODED_PROFILE_QA_GUARDED") == "1", "use tools/check_decoded_profiles.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Decoded script profile QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/decoded-profile-check/main.lua b/tools/decoded-profile-check/main.lua new file mode 100644 index 000000000..e73a148aa --- /dev/null +++ b/tools/decoded-profile-check/main.lua @@ -0,0 +1,104 @@ +local root = assert(os.getenv("DECODED_PROFILE_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +if os.getenv('NPC_CONTROLS_QA') == '1' then + require('tools.decoded-profile-check.npcs') + return +end +local Version = require("src.core.GameVersion") +Version.set("firered") +local Native = require("src.script.ScriptRunner") +local baseline = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local original = assert(loadstring(baseline:read("*a"), "@baseline/ScriptRunner.lua"))() +baseline:close() +local Profile = require("src.core.FrameProfile") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Compiler = require("src.script.Gen3ScriptVM") +local source = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row)return next(ctx,op,row)end) +end]] +local phase, frame, runner, rows, game, started, total = 1, 0, nil, nil, nil, 0, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + local data = require("tests.fixtures.upstream_mods.data").fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = source, + ["options.lua"] = "return {mods={probe=true}}", + } + local loader = + Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = phase == 3 and "decoded" + or "native" } }) + assert(loader:load(data)) + local ir = {} + for i = 1, 64 do + ir[i] = { "setvar", 0x4000 + i, i } + end + ir[#ir + 1] = { "end" } + data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = ir }, maps = {} } + game = { data = data, save = { gen3Vars = {}, flags = {}, party = {} }, mods = loader } + runner = (phase == 1 and original or Native).new(game, {}) + rows = Compiler.compile(data, "ENTRY") + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + started = love.timer.getTime() + end + local close = Profile.section("script.exec (64 writes + end)") + local t = love.timer.getTime() + runner:exec(rows, runner:makeContext()) + if frame >= WARMUP then + total = total + love.timer.getTime() - t + end + close() + assert(game.save.gen3Vars[0x4040] == 64) + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "decoded" })[phase], + frames = WINDOW, + scriptMeanMs = total / WINDOW * 1000, + seconds = love.timer.getTime() - started, + } + if phase == 3 then + print( + "M5_NATIVE_RESULT " + .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Native LÖVE ScriptRunner and actual per-mod Loader profile over 65 test-owned commands per frame; no world scene or cartridge gameplay", + }) + ) + return 0 + end + phase = phase + 1 + start() + end + end +end diff --git a/tools/decoded-profile-check/npcs.lua b/tools/decoded-profile-check/npcs.lua new file mode 100644 index 000000000..f667c0137 --- /dev/null +++ b/tools/decoded-profile-check/npcs.lua @@ -0,0 +1,88 @@ +-- Owned native CPU workload. Sprite construction is a deterministic seam; +-- map collision and actor updates use the production engine. +local Contract = require("tests.api3_contract.npcs") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local Native = require("src.world.WorldAPI") +local file = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local Original = assert(loadstring(file:read("*a"), "@baseline/WorldAPI.lua"))() +file:close() +local phase, frame, fixture, total = 1, 0, nil, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + package.loaded["src.world.WorldAPI"] = phase == 1 and Original or Native + fixture = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", "gold", "legacy") + fixture.loader._game = function() + return fixture.live + end + -- Bind the actual API-2 loader facade before restoring the global module. + assert(fixture.exports.npc(fixture.home, "IMPORTED")) + package.loaded["src.world.WorldAPI"] = Native + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + end + local label = phase == 3 and "npc.controls + native updates (64)" or "npc.lookup + legacy calls (64)" + local close = Profile.section(label) + local began = love.timer.getTime() + for _ = 1, 64 do + local h = assert(fixture.exports.npc(fixture.home, "IMPORTED")) + if phase == 3 then + assert(h:placeAt(1, 1, "right")) + assert(h:canStep("right")) + assert(h:setPassable(true)) + assert(h:stepNow("right")) + assert(h:isMoving()) + for _ = 1, 16 do + fixture.imported:update(fixture.ow.map, fixture.ow.entities) + end + assert(not h:isMoving()) + assert(h:setAppearance("ALT")) + else + h:position() + h:face("left") + end + end + if frame >= WARMUP then + total = total + love.timer.getTime() - began + end + close() + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "controls" })[phase], + frames = WINDOW, + operationMeanMs = total / WINDOW * 1000, + } + fixture.restoreRenderer() + if phase == 3 then + print("M6_NATIVE_RESULT " .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Actual LÖVE host, production API-2 loader, native Map collision and NPC updates over owned data; deterministic sprite construction, no rendered artwork or cartridge scene", + })) + return 0 + end + phase = phase + 1 + start() + end + end +end diff --git a/tools/gen2-registry-contract/conf.lua b/tools/gen2-registry-contract/conf.lua new file mode 100644 index 000000000..a414765a8 --- /dev/null +++ b/tools/gen2-registry-contract/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv('GEN2_REGISTRY_QA')=='1','use the guarded registry checker') + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.title='Gen2 registry validation profile' + t.window.width,t.window.height,t.window.vsync=480,240,1 + t.modules.audio=false +end diff --git a/tools/gen2-registry-contract/fields.lua b/tools/gen2-registry-contract/fields.lua new file mode 100644 index 000000000..025db5579 --- /dev/null +++ b/tools/gen2-registry-contract/fields.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Inspect public Gen2 descriptors without executing another engine's bodies. +package.path='./?.lua;./?/init.lua;'..package.path +local Schemas=require('src.mods.Schemas') +local Json=require('src.link.Json') +local function describe(field) + local out={kind=field.kind,min=field.min,max=field.max,registry=field.registry} + if field.inner then out.inner=describe(field.inner)end + return out +end +local out={} +for _,name in ipairs({'apricorns','decorations','landmarks','phone_contacts','radio_channels'})do + local spec=assert(Schemas.REGISTRIES[name],name) + local entry={semantics=spec.semantics,fields={}} + for key,field in pairs(spec.fields)do entry.fields[key]=describe(field)end + out[name]=entry +end +print('REGISTRY_CONTRACT '..Json.encode(out)) diff --git a/tools/gen2-registry-contract/main.lua b/tools/gen2-registry-contract/main.lua new file mode 100644 index 000000000..4a52777ac --- /dev/null +++ b/tools/gen2-registry-contract/main.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('GEN2_REGISTRY_ROOT')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +local Profile=require('src.core.FrameProfile') +local Json=require('src.link.Json') +local file=assert(io.open(assert(os.getenv('GEN2_REGISTRY_BASELINE')),'rb')) +local before=assert(loadstring(file:read('*a'),'@baseline/Schemas.lua'))() +file:close() +local after=require('src.mods.Schemas') +local rows={ + apricorns={apricorn='FRUIT',ball='BALL',event=600,index=1}, + decorations={type=2,name='BED',action='SET_UP_BED',flag=500,sprite=0}, + landmarks={name='HOME',x=80,y=72,index=1}, + phone_contacts={index=2,name='CALLER',number=2,calleeTime=0,callerTime=0}, + radio_channels={channel=9,name='STATION'}, +} +local phase,frame,total=1,0,0 +local results={} +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end +function love.run() + return function() + love.event.pump() + for name in love.event.poll()do if name=='quit'then return 1 end end + if frame==120 then Profile.setEnabled(true)end + local schema=phase==1 and before or after + local close=Profile.section('mods.registry.validate (320 records)') + local began=love.timer.getTime() + for _=1,64 do + for name,row in pairs(rows)do + assert(schema.check(schema.REGISTRIES[name],name,'QA',row,'register')) + end + end + if frame>=120 then total=total+love.timer.getTime()-began end + close() + love.graphics.clear(.03,.04,.06) + love.graphics.print('Gen2 registry validation: '..(phase==1 and 'before' or 'after'),12,12) + love.graphics.present() + if frame>=120 then Profile.frame()end + frame=frame+1 + if frame==240 then + results[phase]={mode=phase==1 and 'before' or 'after',frames=120,meanMs=total/120*1000} + if phase==2 then + print('REGISTRY_PROFILE '..Json.encode({phases=results,renderer=love.graphics.getRendererInfo(), + os=love.system.getOS(),arch=jit.arch, + scope='Owned records, native Love host and FrameProfile section; schema validation only, no gameplay scene'})) + return 0 + end + Profile.setEnabled(false) + phase,frame,total=2,0,0 + end + end +end diff --git a/tools/modkit.py b/tools/modkit.py index fb0b91499..91c23ffa4 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest): "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "{{profile}}", - "game_version": ">={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true",