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..7a048e237 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,9 @@ 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/engine/ diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..52f592613 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,145 @@ +# 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. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`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: 24 comparisons and +48 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. 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/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/modding.md b/docs/modding.md index f6f71594f..a48460651 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,65 @@ 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. + +## 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 +129,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 +154,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 +226,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 +277,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 +312,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 +356,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 +387,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/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/qa/native-foundation/ball-battler-presentation.md b/docs/qa/native-foundation/ball-battler-presentation.md new file mode 100644 index 000000000..fdaf9c006 --- /dev/null +++ b/docs/qa/native-foundation/ball-battler-presentation.md @@ -0,0 +1,41 @@ +# Native battler observations during send-out + +The source presentation view previously reported a visible, full-size Pokemon +while its actual native ball still owned an empty slot. It also omitted the +native rise offset. A fitted presentation could consequently show a Pokemon +before its throw or opening, then restart its appearance. + +Observe the existing native ball's `monHidden`, `monScale` and `monRise` for +that ball's battler only. Compose its scale/rise with existing native move +effects. Before a ball exists, observe the native trainer/send-out hiding +flags as well; the opposite slot remains independent. Additional double-battle +slots use the existing native side-arrival query. Explicit source overrides +retain their established precedence. Native draw overrides still use only +explicit overrides, so these observations cannot feed native scale back into +itself. No native controller, command queue or animation clock is changed. + +Validation so far: + +- Exact predecessor `4e807b6d`, run `7237c0a31e`: the same fixture reports + 4,075 failures out of 23,160 assertions. No new controller is overlaid. +- Older-public composition `0b63737b6a`: 73 groups pass, including all + 23,160 visibility/scale/rise assertions across five GBA editions and + player/opponent/capture balls. Existing native bodies remain in place. +- Frozen `2ad0e77282` includes fetched fork main `72c2a946` and the entry + visibility correction: 197 CPU groups pass, including all 23,160 assertions + above and the new main's font, alpha-depth, effects, checkpoint and waterfall + regressions. Native Gen2 regressions remain included. +- Real Metal Ruby `611cb4757b`, unchanged consumer `bddb5181`, imported TIANA + portrait 2: 595 battler observations, including 553 before the accepted + player send-out with zero premature visible player rows. All 82 observed + ball-hidden rows hide their actual actor. Both native send-outs are admitted; + 108 native clock samples, 76 visible fitted ball rows and 11 captured throw + images are retained. The command menu and cleanup complete without loader + errors; 378 opening-effect passes contain zero duplicate native ball cells. + Images at enemy frames 32/62 and player frames 0/20 confirm empty slots + before release and actual opening emergence. Opening particles still need + fitting to the source scene; this is not complete scene, all-portrait/view + or stable frame-rate acceptance. Hosted CI remains separate. + +QA uses owned profiles and preserved disposable copies with a 20 GiB free +floor. Private consumer sources, ROM originals and player saves are unchanged. diff --git a/docs/qa/native-foundation/ball-effect-layer.md b/docs/qa/native-foundation/ball-effect-layer.md new file mode 100644 index 000000000..55337d9fb --- /dev/null +++ b/docs/qa/native-foundation/ball-effect-layer.md @@ -0,0 +1,60 @@ +# Ball cells and opening effects share one native timeline + +A source renderer draws its fitted ball through its own imported cell, then +calls `ball_open.draw()` for opening effects. That call formerly borrowed the +complete native painter, adding another ball at native coordinates and size. +Real FireRed capture `b3d97aa123` shows both balls simultaneously after native +send-out admission was repaired. + +Keep the existing native painter as one implementation with two paint modes. +Ordinary native battle drawing retains the complete layer. A source opening +pass uses the effects component, including the same native particles, shiny +sparkles and capture stars, without adding another ball cell. Base calls inside +an actual native whole-layer boundary still borrow that complete layer once. +The source's fitted position/scale and the native clocks, queues and capture +math are untouched. No private renderer/controller body is included. + +Validation: + +- Focused run `4b62b07721`:11 groups pass. The new real-painter fixture passes + 235 assertions across five GBA editions and player/opponent/capture modes. + It checks effect command equality, native complete-layer preservation, + once-only wrapper borrowing, no artless duplicate, and read-only painting. +- Exact predecessor `e8d494e1`, run `2826493842`, reproduces45 failures with + the identical fixture and no native controller overlay (205/250 checks; + the old extra draw also adds data-dependent assertions). Corrected code + passes235/235. +- Current fork main `c548cd16` composition `d3dc3867ae`:181 CPU groups pass. + The subsequently fetched main `0af0b3cf` was integrated as `23fcddaa`; + frozen composition `53b81840c4` also passes181 groups. Four additional + new-main appearance/new-game/ending groups pass separately. +- Older-public composition `696b620eea`:69 groups pass, including235 new + assertions. It retains older native simulation bodies and previously + offered cartridge/art/stage prerequisites. The offered raw branch reuses + the existing ball painter boundary from the public presentation component + (unchanged preimage of #329), then applies only the component split. + +Fresh Metal FireRed run `368069098b` on frozen `53b81840c4` completes +native new-game and both actual send-outs without loader errors. It records69 +native clock/position/draw frames, five captured player-flight frames and412 +source opening-effect calls with zero duplicate native ball cells in those +calls. The entire69-frame native ball trace equals pre-split run `b3d97aa123`. +Images show one ball moving from hand to landing point. Actual closed artwork +is12x12 inside its16x16 cell; fitted scale grows0.296745 to0.65. Cell extent is +reported in logical canvas units, not claimed as physical opaque pixel width. + +Earlier fresh run `49a837bef7` also showed the corrected single ball and equal +clock, but its broad all-target asset counter rejected legitimate native draws. +That failed receipt is retained. The scoped probe distinguishes the actual +source opening pass from other native drawing; it does not suppress native +painting to satisfy the counter. LeafGreen run `0237101bb2` also passes both +actual starts and69 clock/position/draw rows, five captures, and469 opening +passes with zero duplicate cells and no loader errors. Its scale likewise +grows0.296745 to0.65. Earlier `1d183dc241` did not enter game code: a relative +QA input path failed in host configuration. That receipt is retained and both +starter boundaries now normalize their input paths. The focused +hosted ball workflow succeeds on `dd5a507d`; other CI and full trainer-pose, +other-view or FPS acceptance remain separate. + +Only owned profiles/copies are used with the20GiB free-space floor; private +files and player saves/profiles are untouched. diff --git a/docs/qa/native-foundation/ball-flight-observations.md b/docs/qa/native-foundation/ball-flight-observations.md new file mode 100644 index 000000000..8b64108d6 --- /dev/null +++ b/docs/qa/native-foundation/ball-flight-observations.md @@ -0,0 +1,47 @@ +# Native ball flight observations + +A player send-out ball was always exposed with rotation zero, even while the +native clock advanced its throw. Existing presentation consumers use the +unwrapped throw angle to place the ball between the trainer's hand and its +landing point, and to transition from grip size to flight size. A constant +angle therefore pins that progress at zero until the cell changes to open. + +Expose the player send-out's actual `phaseFrame / send.frames` in the existing +unwrapped angular range. Opening resets the presentation angle; opponent and +capture observations keep their existing semantics. This is a read-only scalar +projection: native x/y, timing, sound, mon reveal, catch math and queue clocks +are not changed, and the native painter remains as before. It also follows +imported send-out durations instead of deriving progress from fitted pixels. + +Current-main frozen `89bf734b9d` passes176 CPU groups including1808 new checks +across five GBA editions and alternate imported durations. Repeated observations +leave the actual native timeline equal to an independently advanced native +control. The identical fixture on native predecessor `981b6f57`, run +`839d5839f5`, reproduces288 failures out of1808 checks. Older-public composition +`d54354c682` passes67 groups including the same1808 checks; its existing native +ball/stage/capture prerequisite is retained and only the scalar hunk is applied. +No newer native simulation/controller body is overlaid. A new fork workflow +covers the observation, stage/capture identity and native Gen2 scene regressions; +hosted CI remains separate from these local results. + +The newer fork main `c548cd16` was then merged as `ebcf5f48`. Frozen +`b2c647a315` passes179 groups, including the new canonical Gen2 species/runtime +and RS native ice/continue tests, plus the ball and preceding scope checks. + +The actual unchanged private pure consumers, pinned at `bddb5181`, were then +executed against the native clocks in isolated run `0ad22d0adf`:170 checks pass +across five editions. The old observation leaves the real consumer at the hand +and grip scale through frame24, jumping to the target at25. The corrected +observation moves at frame1 and smoothly transitions to flight scale. Only +unused factory dependency declarations were stubbed in that isolated math test; +it is not complete private controller or rendered-pixel acceptance. No private +consumer bodies are included in the offered code. + +Real FireRed scene `58f56b0181` completed without loader errors, but its extra +flight instrumentation observed no ball draws. The wrapper explicitly rejects +that run as flight acceptance. Earlier `ed2737a261` failed in QA instrumentation +before the native intro, and `e32e72bdf1` also lacked ball rows; their receipts +are retained. The genuine native send-out/render admission still needs a valid +flight capture. The earlier Ruby menu/terrarium smoke is not flight acceptance. +QA uses only owned profiles/copies with the20GiB free-space floor. Player +profiles and private mod files are untouched. diff --git a/docs/qa/native-foundation/ball-intro-phase.md b/docs/qa/native-foundation/ball-intro-phase.md new file mode 100644 index 000000000..08138e0cb --- /dev/null +++ b/docs/qa/native-foundation/ball-intro-phase.md @@ -0,0 +1,38 @@ +# Keep ball observations in the actual native entry phase + +The native send-out inserts its wait after the intro observation was bound. +That row is absent from the original observation map, so looking through the +queue reports the following message/action while the real ball still flies. +This also prevents consumers from identifying their genuine send-out window. + +While the actual native ball is playing, retain the bound send-out step only +when its send-out side and battler identity match the current native player or +opponent. The existing slide still wins before release; after the ball retires, +normal native row synchronization resumes. Keep only scalar step indices in +the token. No new rows, callbacks, timeline, private trainer animation, native +controller or permission to skip an action are added. + +`native_ball_intro_phase.lua` runs actual native trainer intros across five +GBA editions. It provides explicit synthetic sprite centres; this is phase/ +queue/clock validation, not a rendered-geometry qualification. Repeated reads +leave the full native frame/queue/wait/ball-position/HP/PP trace equal to the +module-absent control, and completion retires the observation. Focused run +`a072cb532f` passes12 groups, including3475 new checks. Exact predecessor +`dd5a507d`, run `3b0c93abd8`, reproduces1035 failures out of3475 with the identical +fixture and no native controller overlay. + +Frozen current-main composition `d05bd459f0` passes186 CPU groups including +main `0af0b3cf` appearance/new-game/ending changes. Source hashes match the +committed phase module and fixture. Older-public composition `dac22e1a00` +passes70 groups, including all3475 checks, preserving its existing native +simulation and cartridge prerequisites. The offered raw branch reuses the +previous entry observation/binding component (preimage already offered in +#329); qualification applies only the new observation hunk to that preimage. + +The accepted real FireRed flight `368069098b` predates this phase addition. +It qualifies preceding import/progress/paint fixes only. Full trainer-specific +throw poses, the native tween notification/extension contract, switches and +other views still require separate native ownership work; exposing a correct +step identity alone does not implement those features. Hosted CI is separate. +Only owned profiles/copies are used, with a20GiB free floor. Player saves and +private files remain untouched. diff --git a/docs/qa/native-foundation/ball-opening-projection.md b/docs/qa/native-foundation/ball-opening-projection.md new file mode 100644 index 000000000..97192b88a --- /dev/null +++ b/docs/qa/native-foundation/ball-opening-projection.md @@ -0,0 +1,46 @@ +# Fit native opening effects to a source send-out + +A source renderer can fit a send-out ball through its existing +`Ui.presentation.ballPosition` callback. Its cell then follows the scene, +while the borrowed native opening ring previously remained in cartridge +screen coordinates, producing oversized particles away from the opening. + +For a standalone send-out opening pass, borrow that same current callback +and map the actual native effect origin/size to its returned position/scale. +Use the existing native ring, shiny and star painters; no private renderer, +new particle timeline or native controller is introduced. Zero scale hides +the opening with its cell; an absent projection retains native geometry. +Reject nonfinite geometry or an admission changed by the callback. The +existing graphics guard restores caller state even after a provider error. + +Capture providers already transform their whole opening pass, so capture +does not borrow this projection again. Base calls inside the native whole-ball +boundary retain its complete native painter. Unmodified presentation remains +native, and no projected observation feeds back into native drawing. + +Validation: + +- Focused `8191e50543`: 16 groups pass, including 460 native effect/projection + checks across five GBA editions and player/opponent/capture balls. +- Frozen `5fe451e231` on fork main `72c2a946`: 198 CPU groups pass. The later + fixture-only diagnostic correction adds error descriptions; production + hashes and positive assertions are unchanged. +- Exact predecessor `85b7ae61`, `ed19cde0c3`: 240 failures out of 450 checks. + The ten callback-local checks are absent because the predecessor never + invokes the provider. The earlier negative run hit a missing fixture error + description after detecting wrong geometry; its receipt is retained. +- Older-public composition `4402bdd757`: 74 groups pass, including all 460 + projection assertions, with its existing native controllers retained. +- Real Metal Ruby `3fec7460ac`, unchanged consumer `bddb5181`, imported TIANA + portrait 2: all 189 observed opening-particle draws have the current fitted + ball centre (maximum framebuffer error `0.00000763` pixels). The frame at + opponent presentation tick 62 now shows the ring at the actual ball rather + than outside the scene. Both native send-outs, 11 captured throw images, + the command menu and cleanup pass without loader errors. The 553 pre-player- + throw observations and 82 ball-hidden observations retain zero visibility + violations. There are zero duplicate cells in 378 opening passes. + All views/portraits, capture image acceptance and stable frame rates remain + separate. Hosted CI is separate too. + +Owned QA profiles/copies only, with a 20 GiB free floor. Private sources, +original ROMs and player saves/profiles are unchanged. diff --git a/docs/qa/native-foundation/ball-sendout-import.md b/docs/qa/native-foundation/ball-sendout-import.md new file mode 100644 index 000000000..e8d1dfc4a --- /dev/null +++ b/docs/qa/native-foundation/ball-sendout-import.md @@ -0,0 +1,44 @@ +# Cartridge-native ball send-out import + +FireRed and LeafGreen imported ball art but no send-out timing: the default +operand profile read Emerald instruction addresses. Real scene `a221bb6033` +confirmed both native send-out requests refused despite valid battler sprite +geometry. The scene completed, but its empty ball observations were explicitly +rejected as flight acceptance. + +Use one detached, SHA-1-selected FR/LG instruction profile, checked against +matching pret/pokefirered ELFs at ce6406d8c2eaf46d543c8a2606de0c3cb1951982. +The existing native decoder still validates each instruction and matching target +offsets. Explicit caller and manifest profiles keep precedence; unknown hashes +cannot borrow this profile. Read the actual normal hand (48,70) and tutorial +hand (32,64), selecting the latter only for the actual player tutorial send-out. +The existing ball clock, queue wait, catch math and native renderer stay intact. +Only generated FR/LG import caches receive a revision marker; save formats do +not change. No private controller implementation is included. + +Local validation: + +- `native_ball_sendout_import.lua`:142 checks, including opcode refusal, + detached profile isolation, tutorial selection and edition-scoped cache + upgrades. Synthetic image centres qualify factory/queue ownership, not art. +- Frozen current-main composition `1ce0584258`:180 CPU groups pass on main + `c548cd16`. Focused run `fabe63d246`:10 groups pass. +- Actual cartridge/ELF audit and native operand import `e4c340227f`:55 checks + pass across five GBA editions. FR/LG change from missing to valid send-out; + Emerald/Ruby/Sapphire before/after timing is identical. Image writing was + disabled in this operand-only test; it is not rendered-pixel acceptance. +- Older-public composition `e2cbe1b740`:68 groups pass, including142 new checks. + It retains the previously offered cartridge/art/stage prerequisites and old + native controller bodies. The raw older branch lacks the complete LeafGreen + manifest and RS art prerequisites; full cartridge integration needs those + separately offered components. +- Real Metal FireRed run `b3d97aa123`, unchanged private consumer `bddb5181`: + both native send-outs are accepted, with69 native clock/position/draw rows, + and no loader errors. Frame captures expose a second issue: the source + renderer paints its fitted ball and its opening-effect call also paints the + complete native ball. This import change does not solve that duplicate or + constitute final visual flight/size acceptance. Earlier failed probes and + their receipts remain preserved. + +Hosted CI is separate. Tests use owned copies/profiles with a20GiB free-space +floor. Private mod files and player saves/profiles remain untouched. diff --git a/docs/qa/native-foundation/battle-move-facts.md b/docs/qa/native-foundation/battle-move-facts.md new file mode 100644 index 000000000..0421ff2d2 --- /dev/null +++ b/docs/qa/native-foundation/battle-move-facts.md @@ -0,0 +1,39 @@ +# Native battle move and ownership facts + +Expose the actual native menu move lock, native Encore move, OT name and known +fateful mark through the admitted battle views. Recharge uses its actual native +special kind; move locks retain their actual native move key. Encore reads the +existing native owner when loaded; the older native controller's live field/PP +decision is observed without running its invalid-encore cleanup. Queries do not +change combat, RNG, native frames, queue rows or a pending PP edit. + +`engine.isTradedMon(state, mon)` accepts only a resident of that exact active +battle. It shares the existing native summary's OT name plus ID decision, +including its historical policy for missing OT fields. The predicate is factored +from the summary without changing its rules. Native ribbon words determine a +known encoded fateful mark; otherwise an existing boolean is read, and absent or +malformed metadata remains unknown. No true/false default is fabricated. + +The fields participate in existing typed iteration/snapshots and remain +read-only. Foreign/retired residents cannot lend ownership authority. Native +obedience, source contact continuation and a scoped interactive-choice policy +remain separate work; this packet does not enable Action Card. + +Frozen current-main source `a6c22718d3` passes167 selected CPU groups, including +474 targeted fact checks,138 resolver checks,216 callback checks and native +summary regressions. Older-public component `4f3c930d`, composition `89c8349e34`, +passes62 groups including the same474 fact checks on the older Encore owner. +The existing owned BattleView prerequisite from #329 is reused; its older PP +calculation is retained. No newer native controller/AI body is overlaid. +After qualification, only the helper's authorship/provenance comment changed. + +Native/player save profiles and private mod bytes are untouched. QA uses fresh +owned profiles and the20GiB free-space floor, preserving the requested8GiB. + +Unchanged OASC109 `bddb5181`, Ruby `8714d38018`, frozen `a6c22718d3`: +native new-game/GRUNT_003 trainer entry, paper/native/terrarium/native/paper, +move swap/cancel, actual RAGE PP20 to19 / enemy HP66 to58 / player HP63, +dex retirement and clean source teardown pass without loader errors. Terrarium +and after-move captures were inspected upright. Owned fresh profile; this is +not full story, contact timing, all-terrain, visible-desktop or stable-FPS +acceptance. All own GPU children and managed copies are cleaned up;30GiB free. diff --git a/docs/qa/native-foundation/callback-context-ownership.md b/docs/qa/native-foundation/callback-context-ownership.md new file mode 100644 index 000000000..351b112c6 --- /dev/null +++ b/docs/qa/native-foundation/callback-context-ownership.md @@ -0,0 +1,47 @@ +# Source callback context ownership + +An API3 owner that imports the Game3/battle namespace receives the same admitted +battle identity in callback contexts as `Battle.getState()`. Its battler IDs use +the actual native slots, and move numbers derive from imported move definitions. +Native/follower-only consumers retain their native handles, including event +payload identities. Namespace selection already exists; no mod rewrite or +manifest-specific conversion is introduced. + +A source can copy a bound context to wrap its RNG, retaining the same native +transaction and opaque battle identity. Foreign battle identities and changed +move-number facts cannot be committed. A copied context remains a record, not a +second native action or combat executor. An RNG override is lent to the actual +native invocation and restored afterwards if still owned by that transaction. + +Only actual record views create a coroutine lease. Native-handle hooks such as +core.update can legitimately replace a native save during their body. Record +transactions validate phase/cohort/battle before staging and before applying +writes. Their RNG handles require the same visible native ancestry and close +with the transaction. Public RNG closures hold weak leases. Source event +transactions also close on return/error. + +The existing classic hook walk rejects revoked coroutines before entering/falling +back to vanilla and before returning a cached result. A source yielding before or after +next cannot use a fallback or late commit to advance HP/RNG after its parent +phase has returned. Existing ordinary failure fallback/once-only/nil-tuple +behavior remains covered by the native hook and callback regressions. + +This is prerequisite safety and callback data interoperability. It does not +preempt already-suspended arbitrary Lua statements, provide a complete native +HP/RNG continuation, install a resolver or enable Action Card. Native default +combat, Gen2 and native-owner ABI qualification are separate explicit checks. +Player saves and private mod files are not changed by QA. + +Older-public qualification: component `7cba4427`, exact composition +`1b6ef135a5`, passes60 selected CPU groups, including186 source context checks, +1516 existing unified callback checks and111 API3 entry checks. This host has +one classic hook walk; no newer native hook/controller body was overlaid. +Declared foundations are the already offered API3 entry packet #102 and native +source/session packets #265/#329/#346. Existing API3 changes were merged against +the actual older files, resolving only the battle command registration, loader +retirement ordering and retained native TextBox reveal/update behavior. Three +already-owned added helpers were retained. The absent Gen4MoveEffects preimage +was explicitly omitted from this Gen3 callback composition: this is not full +API3/Gen4 qualification. Eight existing native presentation/weather/follower +hooks now have explicit callback decisions; absent native hooks are not invented. +The current-fork qualification and locked-Mac Ruby run above are separate evidence. diff --git a/docs/qa/native-foundation/choice-policy-scope.md b/docs/qa/native-foundation/choice-policy-scope.md new file mode 100644 index 000000000..edbc6f9d1 --- /dev/null +++ b/docs/qa/native-foundation/choice-policy-scope.md @@ -0,0 +1,43 @@ +# Choice policy scoped to native move resolution + +The typed battle state's `interactiveChoices` observation reads the actual +native preference. Boolean/nil overrides are admitted only inside that exact +live native move invocation. They live in the existing coroutine scope registry, +never on the persistent battle or as a raw view extension. Called native moves +borrow their visible ancestor's policy; a nested override retires with its own +scope. A suspended child cannot lend its policy to the caller or a sibling, or +write it into a later invocation. Error paths restore the registry's local head. + +The fork's existing Gen3BatonPass owner consults this scoped policy at its current +native selection branch. Ordinary native preference, explicit native chooser, +link-request ownership, authored effect composition, switch/HP/item scheduling +and PP spending stay with their existing owners. No party-choice interpreter, +new coroutine runner or persistent preference is introduced. + +Qualification on fork main `73b2c0f8`: frozen `b5adb2acd0` passes175 selected CPU +groups, including the final production scope code, native Baton Pass UI/link/ +composition/packet checks and full five-edition HP/PP/every-RNG/frame/phase +no-op traces. The final focused fixture splits the original140-check combined +case into101 dynamic-scope checks plus65 native choice/called-move checks. +The added called-move cases retain all native effect records in their fixture; +earlier malformed partial-registry failures remain recorded. No production +change was made to bypass those fixture failures. + +Older-public component `64205537`, composition `d2ff81f538`, passes65 groups +including the same101 dynamic-scope checks. That older base does not contain the +fork's Gen3BatonPass owner. This public foundation therefore offers the typed +policy and native-owner query seam, without claiming that missing consumer's +selection behavior. Its complete native-owner integration remains separate +work; newer native controller bodies are not overlaid for qualification. +The native predecessor fails the new scope fixture (including missing policy +accessors and accepted persistent writes); the exact test hash is in its receipt. + +Unchanged OASC109 `bddb5181`, Ruby `a64d1f90d9`, earlier frozen `2b1dd03c76`: +trainer entry, paper/native/terrarium/native/paper, move swap/cancel, actual RAGE +PP20 to19 / enemyHP66 to58 / playerHP63 to52, dex retirement and clean teardown +pass with no loader errors. That GPU run predates the final called-move policy +inheritance adjustment. It qualifies the option ownership/render path, not +Action Card, contact timing, all terrain or stable FPS. Hosted CI is separate. + +QA uses only owned profiles/copies and the20GiB free floor. Player profiles and +private mod bytes remain untouched. Contact continuation is still pending. diff --git a/docs/qa/native-foundation/door-metadata.md b/docs/qa/native-foundation/door-metadata.md new file mode 100644 index 000000000..a64cd0d1e --- /dev/null +++ b/docs/qa/native-foundation/door-metadata.md @@ -0,0 +1,15 @@ +# Native door descriptor queries + +Third-person world construction requested the cartridge door metadata query, which was missing and disabled the presentation renderer. The importer now reads the real descriptor tables for authenticated FireRed, LeafGreen, Emerald, Ruby and Sapphire cartridges, retaining metatile IDs, sound kind, size, tile location and palette slots. The edition-specific generated-cache marker changes; save formats do not change. + +The query resolves canonical or native map IDs through the admitted native world, reads the current metatile and semantic behavior, and returns detached descriptor and geometry metadata. Non-door cells, padded/out-of-bounds coordinates, foreign identities, missing imports and retired worlds do not create a door. The compatibility manifest is a detached per-cohort snapshot. Native map cells, warps, save data, simulation clocks and the existing Petalburg slide remain authoritative. + +Validation: 66 ROM-free binary-reader assertions, 95 native query assertions, 75 existing slide-observation assertions and the native map/weather/cache regressions pass. All 185 descriptors from the five authenticated cartridges were read successfully (32/32/53/34/34). The frozen current-main composition passes 250 CPU groups. An independent older public composition passes 78 groups using its original native controllers and the existing map/session foundations; only the owned import insertions and detached door module were added. Earlier differing import prerequisites were retained. ROM assets and private source are not published. + +The unchanged consumer now completes the native FireRed Cerulean water-bank matrix: clear/rain times Normal/Third/First, twelve on/off images, restored scene reflections, nonempty real scene targets and 9,368 observed native actor mirror draws. Native collision/elevation/metatile/warp snapshots compare equal. All six enabled images and the Third on/off pairs were inspected. The owned QA accelerates puddle fill from 45 seconds to 0.25 seconds; production fill timing is unchanged. No private runtime or renderer was mounted or edited. The native process refreshed its own disposable generated cache and contains the genuine descriptor import. + +This supplies descriptors and read-side queries. General door animation/task/sound coordination, other editions/scenes and stable field FPS remain separate qualifications. The image run still shows long frames; its captures, draw observation and accelerated fill are not FPS acceptance. + +Only disposable QA profiles were used. Private presentation files and player saves were not changed. Storage helpers enforced 20 GiB free, above the requested 8 GiB reserve. + +Contributions by Roxas2712 — https://github.com/Roxas2712. Original additions are offered under LICENSE-ROXAS2712.md, including specific integration/distribution permission for Cedric / UNDERdecoded with visible credit. Existing upstream/third-party licences and rights remain intact. diff --git a/docs/qa/native-foundation/move-resolution-scope.md b/docs/qa/native-foundation/move-resolution-scope.md new file mode 100644 index 000000000..b6cdaf802 --- /dev/null +++ b/docs/qa/native-foundation/move-resolution-scope.md @@ -0,0 +1,45 @@ +# Native move resolution scope + +`battle.engine.resolveMove` decorates the actual `BattleState:performMove` +invocation before the existing native body starts. Encore/target correction, +PP, RNG, announcement, called/spread moves, Substitute and item frames remain +inside that unchanged body. There is no second combat executor or event queue. + +An admitted source receives the existing battle/battler view identities, +the actual move key and its learned-slot index when one exists. Adapter/output +arguments are absent rather than fabricated. Base calls must return the exact +admitted arguments. The base consumes the native body at most once, caches nil +return tuples and cannot retry a failed native body. Calls outside the native +invocation cannot resolve a move. + +Before consuming the body, the scope checks edition, dataset/identifier tables, +save, loader/content cohort, stack/top, queue, result, actor/position identity, +move-definition identity and the selected PP owner. Current native action records +use their already-loaded slot owner; older native bodies debit the move instance +itself. No newer action/AI module is imported to change the older native body. +A retained callback cannot move its claim to another action or native phase. + +Default native and Gen2 paths remain direct. Merely importing the source module +on a non-admitted native battle does not instantiate another controller. This +packet supplies a real resolver boundary, but does not project an action-ready +phase, change UI busy semantics, permit a suspended contact continuation or +provide ownership/obedience facts. Those remain separate native-owner work. +Action Card/contact timing and complete source animation playback are not +qualified by this packet. + +The initial broad source `b53a8e0875` passes155 selected CPU groups, including +133 initial resolver checks. Its exact pre-resolver source comparison +`73ad374572` matches complete HP, PP, every RNG call and frame/phase traces for +all five GBA editions, both with the new module absent and with it loaded. +Final actor/mon/PP-slot ownership guards expand the focused suite to138 checks. +Final frozen `c00886af4b` passes155 selected CPU groups. Older-public component +`96f758ec`, exact composition `64b35cafd8`, passes61 groups including the same +138 resolver checks. The owned fixture runs native battler placement explicitly +because it bypasses the native enter animation; no placement body was changed. +Only the owned method declaration/wrapper was adapted over the older native +move body, which remains intact. No newer controller/AI body was copied. + +Only public engine and owned QA files change. QA uses fresh owned profiles and +a20GiB free-space floor. Player saves and private mod bytes are untouched. + +Merged current main `73b2c0f8` as `80b4cc2f`: exact frozen `269e87ae07` passes164 selected CPU groups, including the new GSC sleep/status/SFX, native audio shutdown and five-edition field-return/weather battle groups. Exact actual-main predecessor `aff668b6b2` compares the unmodified `73b2c0f8` native source with this merged candidate, both module-absent and module-loaded: full HP/PP/every-RNG/frame-phase traces match in all five GBA editions. diff --git a/docs/qa/native-foundation/opponent-sendout-tween.md b/docs/qa/native-foundation/opponent-sendout-tween.md new file mode 100644 index 000000000..1fc9f4895 --- /dev/null +++ b/docs/qa/native-foundation/opponent-sendout-tween.md @@ -0,0 +1,60 @@ +# Native opponent send-out visual tween + +The existing public tween seam was never invoked by a native send-out, so a +presentation provider could not begin its opponent throw/return actor. Offer +that seam from the actual opponent ball constructor and drive it from the +same native update owner. No private actor/controller body is included. + +The35-unit presentation protocol has16 opening-delay units,12 emergence units +and7 trainer-exit units. Map those coordinates to the actual imported delay/ +fade durations; they are not advertised as cartridge frame counts. A provider +may extend the visual interval. Each mapped step consumes the existing native +ball update at most once per native tick; sounds/reveal remain in that body. +The actual native wait is admitted once, retained through a visual return and, +when necessary, the remaining native shiny tail. Default and delegating paths +keep the entire native frame/queue/wait/position/HP/PP/sound trace identical. + +Only the genuine native opponent intro is admitted. Detached task observations +grant no timing authority. Callbacks are usable only inside their current +native visual phase/coroutine ancestry; later callers and retired suspended +children cannot spend a saved step. Hook/owner removal, reset and callback +errors discard source callbacks and resume the remaining native ball without +replaying its queue row. Cohort replacement revokes the old observation. Five +native owners collect even when a task/callback is retained; the existing last +completed battle snapshot remains scalar/read-only by design. + +Validation: + +- Frozen `e7f6427315` on fork main `0af0b3cf`:187 CPU groups pass. Its actual + production hashes match the committed implementation. Final focused + `85d2e611df`:13 groups pass, including2120 native tween assertions with + normal/shiny, extension/cancel/reset/owner/error/nonfinite/suspended-child + and cohort/GC cases. The final fixture uses shared personality/OT identity + instead of a newer shiny cache flag; no native Pokemon body is replaced. +- Existing-API admission probe:350 checks pass; identical probe on exact + predecessor `613f788f`, run `7d8c512a91`, reports20 failures out of35 checks + because no offers/callbacks occur (callback-dependent checks are absent). + No new module or native controller is overlaid on that predecessor. +- Older-public composition `7f838044d0`:72 groups pass, including2120+350. + Its qualified native ball tick body is byte-identical before/after the + dispatch adaptation. The raw offered branch reuses the unchanged public + animation adapter prerequisite from #329, and factors its own older inline + ball tick with unchanged body bytes apart from indentation. It does not + copy a newer controller. Previously offered cartridge/stage/clock bindings + remain required for complete raw-branch integration. +- Real Metal Ruby `44952637b4`, unchanged private consumer `bddb5181`, uses + imported TIANA/portrait2. Both native starts and player flight pass; the + genuine opponent actor supplies26 visible flight rows and6 captured + opponent frames. There are108 native clock samples,76 visible ball rows, + and no duplicate cell in the source opening pass or loader errors. The + intro reaches the native command menu and source cleanup completes. + +Earlier GC fixture failure wrongly required collection of the intentional +completed scalar snapshot; its receipt remains. Earlier older-public failure +used a newer shiny cache flag, unsupported by that older native Pokemon body; +that receipt also remains. Shared native genotype fixes only the fixture. +The real images expose a separate missing battler hidden/scale observation +(the mon can appear before opening). That interface, switches, other portraits +and complete scene/FPS acceptance remain separate. Hosted CI is separate. +QA uses owned copies/profiles with a20GiB floor; private files and player saves +remain untouched. diff --git a/docs/qa/native-foundation/option-subscriber-ownership.md b/docs/qa/native-foundation/option-subscriber-ownership.md new file mode 100644 index 000000000..59276a82d --- /dev/null +++ b/docs/qa/native-foundation/option-subscriber-ownership.md @@ -0,0 +1,39 @@ +# Native lifecycle and option subscriber ownership + +Wrappers installed by a native option toggle or action must belong to the +subscriber that installed them, so the existing source-call attribution and +owner teardown can find them. Synchronous `mods.loaded`, `game.ready`, +`mod.options_changed` and `mod.option_action` callbacks on the current session's +bus now use the existing shallow mutation journal with their actual owner. +The old aggregate lifecycle journal remains for coroutine callers. Callback +coroutine identity, event ordering, error containment and the native frame-event +paths are retained. Unknown/foreign later replacements remain untouched by an +owner's unwind. This does not capture arbitrary per-frame module mutations. + +A retained require closure loses its snapshot references and namespace admission +authority when the window closes, including a close during module loading. +Close is idempotent. Journal execution preserves trailing nil return values. +No private controller body, manifest rewrite or alternate gameplay loop is used. + +Qualification on fork main `73b2c0f8`: + +- Frozen source `18e8a8ee37`: 168 selected CPU groups pass, including 133 new + checks through actual SDK loading, the native options manager, action rows, + five GBA editions and Crystal. Focused run `4a7aafe473`: 10 groups pass. +- The identical 133-check fixture on unmodified native predecessor `83836402`, + run `90b90e0413`, reproduces 44 failures. Native controllers were not overlaid. +- Older-public component `5987b723`, composition `5ab6f3e79f`: 64 groups pass, + including the same 133 checks and 17 native diagnostic checks. It retains the + older classic event walk, reuses the already offered namespace-journal + prerequisite, and adapts attributed errors to its existing Runtime feed. + The Loader change is a checked four-line hunk over the previously qualified + older composition, not a replacement with the newer Loader. +- The fork's 10,000 single-listener event probe remains at 0 KB allocations on + its lean path with JIT off. This is a frame-event allocation check, not FPS + or older-public lean-path acceptance. + +These are local CPU qualifications; hosted CI and unchanged-mod GPU acceptance +of this packet remain separate. The earlier Ruby scene qualification predates +these event changes. Typed contact continuation and Action Card remain pending. +All runs use owned QA profiles/copies and a 20 GiB free-space floor, preserving +the requested 8 GiB reserve. No player profiles or private mod files are edited. diff --git a/docs/qa/native-foundation/player-sendout-phases.md b/docs/qa/native-foundation/player-sendout-phases.md new file mode 100644 index 000000000..8590e6bea --- /dev/null +++ b/docs/qa/native-foundation/player-sendout-phases.md @@ -0,0 +1,13 @@ +# Native player throw and opening phases + +The native battle now offers the existing presentation callback a player windup/flight phase (57 public units: 32 windup and 25 flight) and a separate emergence phase (12 public units mapped to the actual native fade). The actual native ball, battler, queue and simulation clock remain authoritative. A source decorator may hold its visual return; default and delegating providers retain exactly the native trace. + +An opening notification borrows the actual native release transition after its opening sound has been spent. It does not manufacture a second ball or opening effect. The scoped original callback rejects another actor, nonfinite positions, replay and callbacks retained beyond their native frame/cohort. This lets an unchanged presentation retire its throw pose at the actual opening. + +Validation on the merged fork main `6ab3709e`, owned implementation `263f7585`: 244 frozen CPU groups passed. The player fixture checks 2,110 assertions across five editions, including exact default/delegating traces, decorated windup/flight/emergence, cancellation, error fallback and stale phase retirement. The opening fixture checks 345 assertions, including sound ordering, actor/frame identity, pure coordinate borrowing and throw-to-return handoff. The prior implementation fails 40 of the player fixture's first 70 assertions; its native controllers were not overlaid. + +The older public integration is checked separately over its original native controllers. This change does not qualify all switch/capture presentations or sustained field rendering performance. The current water-view investigation separately reproduced a roughly five-second frame and a missing general door metadata query in Third Person; those issues remain outside this battle change. Real unchanged-consumer player return remains a visual follow-up; CPU phase qualification is not visual acceptance. + +Only owned disposable profiles were used. No player saves or private presentation source were changed. Storage helpers enforced at least 20 GiB free (above the user's 8 GiB requirement). + +Contributions by Roxas2712 — https://github.com/Roxas2712. Original additions are offered under LICENSE-ROXAS2712.md, including specific integration/distribution permission for Cedric / UNDERdecoded with visible credit. Existing upstream/third-party licences and rights remain intact. 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..0b966f3f0 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 @@ -2604,6 +2609,34 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end +function BattleState:stepGen3Ball(anim) + local before = anim.phase + anim:update() + if anim.sound then + require("src.core.Sound").playId(self.data, anim.sound) + end + if before ~= "release" and anim.phase == "release" then + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source then source._nativeBallOpen(self, anim) end + end + -- the trainer is gone by the time his Pokemon is out of the ball + -- ...unless the trainer's own walk-off is still running (#407): the + -- cartridge frees that sprite at the end of the fifty frames, not when + -- the ball opens, and cutting it short hides most of the throw + if anim.sendOut == "player" and self.showPlayerBack + and anim.phase ~= "throw" + and not (self.backWalkOff and self.backWalkOff > 0) then + self.showPlayerBack = false + self:slidePic("back") + end + if anim:isDone() then + self.gen3BallPlaying = false + -- a caught Pokemon leaves the ball resting on screen through the text, + -- exactly as the Game Boy chain does + if not anim.caught then self.gen3Ball = nil end + end +end + function BattleState:updateQueue() if self.waitingUI then if self.game.stack:top() ~= self then return true end @@ -2636,29 +2669,10 @@ function BattleState:updateQueue() -- ...and the ball's own timeline, ticked in the same place and for the same -- reason: its frames are counted where the hold is counted, or it would -- stand still for exactly as long as it is meant to be moving. - if self.gen3BallPlaying and self.gen3Ball then - local anim = self.gen3Ball - anim:update() - if anim.sound then - require("src.core.Sound").playId(self.data, anim.sound) - end - -- the trainer is gone by the time his Pokemon is out of the ball - -- ...unless the trainer's own walk-off is still running (#407): the - -- cartridge frees that sprite at the end of the fifty frames, not when - -- the ball opens, and cutting it short hides most of the throw - if anim.sendOut == "player" and self.showPlayerBack - and anim.phase ~= "throw" - and not (self.backWalkOff and self.backWalkOff > 0) then - self.showPlayerBack = false - self:slidePic("back") - end - if anim:isDone() then - self.gen3BallPlaying = false - -- a caught Pokemon leaves the ball resting on screen through the text, - -- exactly as the Game Boy chain does - if not anim.caught then self.gen3Ball = nil end - end - end + local sendout=GameVersion.isGen3()and package.loaded['src.core.game3.battle.sendout_tween'] + local borrowed=sendout and sendout._step(self,self.gen3Ball,BattleState.stepGen3Ball) + if self.gen3BallPlaying and self.gen3Ball and not borrowed then self:stepGen3Ball(self.gen3Ball)end + if sendout and sendout._holds(self)then return true end -- a queued hold (faint slide, hit blink) counts down before the next row if self.waitFrames and self.waitFrames > 0 then self.waitFrames = self.waitFrames - 1 @@ -3806,6 +3820,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='opponent_sendout'end self:shinyAnimAppend(self.enemy, true) queueEnemyCry() elseif self.kind == "link" then @@ -3819,6 +3834,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='opponent_sendout'end self:shinyAnimAppend(self.enemy, true) queueEnemyCry() end @@ -3858,6 +3874,7 @@ function BattleState:enter() -- then the POOF plays and the mon appears with its cry -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='player_throw'end self:shinyAnimAppend(self.player, false) self:act(function() self.sendingOut = false @@ -3884,6 +3901,8 @@ function BattleState:enter() self:abilitySwitchIn(self.player) end }) end + local intro=GameVersion.isGen3()and package.loaded['src.core.game3.battle.intro_seq'] + if intro then intro._nativeBind(self)end Runtime.emit("battle.started", { battle = self, kind = self:battleKind(), trainerId = self.trainer and self.trainer.id, @@ -3895,6 +3914,8 @@ end -- any pop (finish, script teardown) must silence the alarm loop -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() + local intro=package.loaded['src.core.game3.battle.intro_seq'] + if intro then intro._nativeRelease(self)end -- Teachy TV temporarily replaces the player's party exactly like -- SavePlayerParty/LoadPlayerParty in teachy_tv.c. finish() normally -- restores it before the pop, but exit is the safety net for a forced state @@ -4440,7 +4461,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 +4487,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 +4567,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 +4596,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 +4621,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 +4644,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 @@ -8168,7 +8078,7 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) +local function nativePerformMove(self, user, target, moveInst, isCalled, suppressAnimation) local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8418,6 +8328,14 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end end +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + local resolver=GameVersion.isGen3()and package.loaded['src.core.game3.battle.engine'] + if resolver then + return resolver._native(self,user,target,moveInst,isCalled,suppressAnimation,spreadContinuation,nativePerformMove) + end + return nativePerformMove(self,user,target,moveInst,isCalled,suppressAnimation,spreadContinuation) +end + function BattleState:continueTrapping(user, target) self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user))) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText @@ -10392,6 +10310,8 @@ function BattleState:gen3SendOut(battler) sendOut = (battler == self.player) and "player" or "opponent", battler = battler, to = { x = cx, y = cy }, + from = self.pokedudeDemo and battler == self.player + and timing.sendOut.fromPokedude or nil, -- ...AND IF IT SPARKLES, IT SPARKLES HERE. TryShinyAnimation is called -- out of the send-out, once the mon is out of the ball, which is exactly -- what this animation owns -- so the shiny beat rides it rather than @@ -10420,7 +10340,10 @@ function BattleState:gen3SendOut(battler) end end self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { wait = anim:estimate() }) + local waitRow={wait=anim:estimate()} + table.insert(self.queue,self.nextInsert,waitRow) + local source=package.loaded['src.core.game3.battle.anim'] + if source then source._nativeSendOut(self,anim,waitRow)end return true end diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..ec8710f48 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -2905,7 +2905,7 @@ local function ballSheet(battle, key) return (ok and img) or nil end -function Gen3Battle.drawBall(battle) +local function drawNativeBall(battle, effectsOnly) local record, anim = Gen3Battle.ballRecord(battle) if not anim then return end local g = love.graphics @@ -2944,20 +2944,24 @@ function Gen3Battle.drawBall(battle) g.setColor(1, 1, 1, 1) end - -- the ball - local sheet = ballSheet(battle, "balls") - local bx = anim.x - size / 2 + (anim.shakeOffset or 0) - local by = anim.y - size / 2 - g.setColor(1, 1, 1, 1 - (anim.whiteout or 0)) - if sheet then - local iw, ih = sheet:getDimensions() - local f = math.max(1, math.min(frames, anim.ballFrame or 1)) - 1 - local q = love.graphics.newQuad(f * size, anim.ball * size, size, size, - iw, ih) - g.draw(sheet, q, bx, by) - else - g.setColor(0.9, 0.2, 0.2, 1 - (anim.whiteout or 0)) - g.rectangle("fill", bx + 2, by + 2, size - 4, size - 4) + -- A source renderer already draws its fitted ball cell. Its opening-effect + -- pass borrows the same native effects without painting that cell again. + if not effectsOnly then + -- the ball + local sheet = ballSheet(battle, "balls") + local bx = anim.x - size / 2 + (anim.shakeOffset or 0) + local by = anim.y - size / 2 + g.setColor(1, 1, 1, 1 - (anim.whiteout or 0)) + if sheet then + local iw, ih = sheet:getDimensions() + local f = math.max(1, math.min(frames, anim.ballFrame or 1)) - 1 + local q = love.graphics.newQuad(f * size, anim.ball * size, size, size, + iw, ih) + g.draw(sheet, q, bx, by) + else + g.setColor(0.9, 0.2, 0.2, 1 - (anim.whiteout or 0)) + g.rectangle("fill", bx + 2, by + 2, size - 4, size - 4) + end end -- ...THE SHINY SPARKLE, which is not the capture's stars. @@ -3013,6 +3017,17 @@ function Gen3Battle.drawBall(battle) g.setColor(1, 1, 1, 1) end +-- Separate paint components share the actual native timeline; neither ticks it. +function Gen3Battle.drawBallEffects(battle) + return drawNativeBall(battle, true) +end + +function Gen3Battle.drawBall(battle) + local source=package.loaded['src.core.game3.battle.ball_open'] + if source then return source._nativeDraw(battle,drawNativeBall) end + return drawNativeBall(battle) +end + function Gen3Battle.draw(battle) local g = love.graphics battle:drawBattleField() 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/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua new file mode 100644 index 000000000..352ac2793 --- /dev/null +++ b/src/compat/gen1/BattleView.lua @@ -0,0 +1,261 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live, typed source observations over one admitted native battle. Views own +-- their arrays; they never lend the native controller/save under source names. +local Ids=require('src.compat.gen1.BattleIds') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local residents=setmetatable({},{__mode="k"}) +function M.nativeMon(view,readOnly) + local d=residents[view];if not d then return nil,false end + local e=d.environment + if not e.context or not e.valid()then return nil,true end + if not readOnly then e:flush()end + return d.entry.raw,true,e.context.game,e.context.screen +end +local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} +local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} +local function status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,tonumber(mon.sleepTurns)or 1))end + if mon.status=='PSN'and mon.toxic then return 128 end + return statusBits[mon.status]or 0 +end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +local monKeys={'species','nickname','level','hp','maxHp','attack','defense','speed','spAtk','spDef', + 'moves','pp','maxPp','item','status','ability','personality','friendship','isEgg','otId','otSecretId','otName','fatefulEncounter'} +local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex','expLockedMove','expEncoreMove'} +local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', + 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng','interactiveChoices'} +local reserved={} +for _,key in ipairs(stateKeys)do reserved[key]=true end +function M.new(game,screen,valid,identifiers) + local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), + caches={},arrays={},valid=valid,retired=false} + local ids=identifiers or Ids.new(game.data) + local function at(pos) + local s=e.context and e.context.screen;if not s then return end + if pos==0 then return s.player elseif pos==1 then return s.enemy end + return s.battlerAt and s:battlerAt(pos) + end + local read + local function collection(name,values,convert) + local a=e.arrays[name] + if not a then a={view={}};e.arrays[name]=a end + local out=a.view;local old=#out + for i,value in ipairs(values or{})do out[i]=convert and convert(value,i)or value end + for i=#(values or{})+1,old do out[i]=nil end + return out + end + local function proxy(kind,raw) + if not raw then return nil end + local cache=e.caches[kind];if not cache then cache=setmetatable({},{__mode='kv'});e.caches[kind]=cache end + local existing=cache[raw];if existing then return existing end + local d={kind=kind,raw=raw};local view={};cache[raw]=view;e.entries[view]=d + if kind=="mon"then residents[view]={entry=d,environment=e}end + setmetatable(view,{__index=function(_,key) + if d.snapshot then return d.snapshot[key]end + if not e.context or not e.valid()then return nil end + return read(d,key) + end,__newindex=function(t,key,value) + assert(e.context and e.valid(),'native battle view retired') + if kind=='mon'and(key=='hp'or key=='nickname'or key=='item'or key=='status')then + local mon=d.raw + if key=='hp'then assert(integer(value,0,mon.stats.hp),'invalid native HP');mon.hp=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');mon.nickname=value + elseif key=='item'then + local item=ids:native('items',value);assert(value==0 or item,'unknown native held item');mon.item=item + else + assert(integer(value,0,255),'invalid native status') + local inverse={[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'} + assert(value==0 or value<=7 or inverse[value],'unsupported combined native status') + mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil + end + elseif kind=='state'and key=='interactiveChoices'then + local engine=package.loaded['src.core.game3.battle.engine'] + assert(engine and engine._setInteractiveChoices,'native move choice policy is unavailable') + engine._setInteractiveChoices(e.context.screen,t,value) + elseif kind=='state'and not reserved[key]then + -- Presentation extensions belong to this view; they cannot replace the + -- controller's command queue, party aliases or completion result. + rawset(t,key,value) + else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end + end}) + -- LuaJIT does not consult __pairs. Keep registry values weak so its + -- weak key cannot retain itself through this environment's public view. + local observation=setmetatable({environment=e,entry=d},{__mode='v'}) + Iteration.register(view,function(spec,source) + local environment,entry=spec.environment,spec.entry + assert(environment and entry,'native battle view retired') + local out={};for key,value in Iteration.rawPairs(source)do out[key]=value end + if entry.snapshot then for key,value in pairs(entry.snapshot)do out[key]=value end;return out end + assert(environment.context and environment.valid(),'native battle view retired') + local keys=entry.kind=='state'and stateKeys or entry.kind=='battler'and battlerKeys or entry.kind=='mon'and monKeys + or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'} + for _,key in ipairs(keys)do out[key]=environment.read(entry,key)end + return out + end,observation) + return view + end + local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end + local function party()local c=e.context;return c.screen.playerParty or c.game.save.party or{}end + local function moveArrays(mon) + local d=e.moveArrays and e.moveArrays[mon] + if not d then + e.moveArrays=e.moveArrays or{};d={moves={},pp={},maxPp={},baseline={}};e.moveArrays[mon]=d + end + -- PP edits use an explicit native transaction before the next native body. + if not d.pending then + for _,key in ipairs{'moves','pp','maxPp'}do for i in pairs(d[key])do d[key][i]=nil end end + d.baseline={};d.ids={};d.maximum={} + for i,mv in ipairs(mon.moves or{})do + d.moves[i]=ids:read('moves',mv.id);d.pp[i]=mv.pp;d.baseline[i]=mv.pp + local def=(e.context.data.moves or{})[mv.id] + d.maxPp[i]=def and def.pp and def.pp+math.floor(def.pp/5)*(mv.ppUps or 0)or nil + d.ids[i]=d.moves[i];d.maximum[i]=d.maxPp[i] + end + d.pending=true + end + return d + end + read=function(d,key) + local raw=d.raw;local c=e.context + if d.kind=='mon'then + if key=='species'then return ids:read('pokemon',raw.species)end + if key=='item'then return ids:read('items',raw.item)end + if key=='ability'then + local b;for pos=0,3 do local actual=at(pos);if actual and actual.mon==raw then b=actual;break end end + return ids:read('abilities',b and b.ability or raw.ability) + end + if key=='hp'then return raw.hp end + if key=='maxHp'then return raw.stats and raw.stats.hp end + if statNames[key]then return raw.stats and raw.stats[statNames[key]]end + if key=='status'then return status(raw)end + if key=='moves'or key=='pp'or key=='maxPp'then return moveArrays(raw)[key]end + if key=='fatefulEncounter'then return require('src.pokemon.Gen3Ownership').fateful(raw)end + if key=='otName'then return raw.ot end + if key=='otId'then return raw.otId or raw.otID end + if key=='otSecretId'then return raw.otSecretId or raw.secretId end + if key=='friendship'then return raw.friendship or raw.happiness end + if key=='nickname'or key=='level'or key=='personality'or key=='isEgg'then return raw[key]end + elseif d.kind=='battler'then + local pos=raw.position + if pos==nil then for i=0,3 do if at(i)==raw then pos=i;break end end end + if key=='id'then return pos end + if key=='side'then return pos and(pos%2==0 and'player'or'enemy')end + if key=='flank'then return pos and(pos<2 and'left'or'right')end + if key=='mon'then return proxy('mon',raw.mon)end + if key=='species'then return ids:read('pokemon',raw.species or raw.mon.species)end + if key=='ability'then return ids:read('abilities',raw.ability)end + if key=='item'then return ids:read('items',raw.mon.item)end + if key=='status'then return status(raw.mon)end + if key=='fainted'then return raw.fainted==true or raw.mon.hp<=0 end + if key=='partyIndex'then return partyIndex(pos and pos%2==0 and party()or c.screen.enemyParty,raw.mon)or 1 end + if key=='stages'then return proxy('stages',raw.stages or{})end + if key=='expLockedMove'then + local action=c.screen.menuLockedAction and c.screen:menuLockedAction(raw) + return action and(action.id or action.special) + end + if key=='expEncoreMove'then + local owner=package.loaded['src.battle.Gen3Encore'] + if owner and owner.menu then local action=owner.menu(raw);return action and action.id end + -- The earlier native fight owner checks the live Encore fields and PP. + -- Observe that decision without invoking its invalid-encore cleanup. + if raw.encoreTurns and raw.encoreMove then + for _,move in ipairs(raw.curMoves or raw.mon.moves or{})do + if move.id==raw.encoreMove and(move.pp or 0)>0 then return move.id end + end + end + end + if key=='type1'or key=='type2'then return ids:read('types',(raw.curTypes or{})[key=='type1'and 1 or 2])end + elseif d.kind=='stages'then return raw[statNames[key]or key] + elseif d.kind=='state'then + if key=='player'or key=='enemy'then return proxy('battler',raw[key])end + if key=='battlers'then + e.battlers=e.battlers or{};for pos=0,3 do e.battlers[pos]=proxy('battler',at(pos))end;return e.battlers + end + if key=='party'or key=='playerParty'then return collection('party',party(),function(mon)return proxy('mon',mon)end)end + if key=='enemyParty'then + e.wildParty=e.wildParty or{};e.wildParty[1]=raw.enemy and raw.enemy.mon + return collection('enemyParty',raw.enemyParty or e.wildParty,function(mon)return proxy('mon',mon)end) + end + if key=='turn'then return raw.turnCount or 0 end + if key=='wild'then return raw.kind=='wild'end + if key=='double'then return raw.isDouble and raw:isDouble()or raw.double==true end + if key=='over'then return raw.result~=nil or raw.dead==true end + if key=='result'then return raw.result end + if key=='weather'then return raw.field and raw.field.weather end + if key=='weatherTurns'then return raw.field and raw.field.weatherTurns end + if key=='playerIndex'then return partyIndex(party(),raw.player and raw.player.mon)end + if key=='enemyIndex'then return raw.enemyIndex or(raw.enemy and 1)end + if key=='trainerClass'then return raw.trainer and raw.trainer.class or raw.trainerClass end + if key=='trainerName'then return raw.trainerName or raw.trainer and raw.trainer.name end + if key=='trainerPicId'then return raw.trainer and(raw.trainer.picIndex or raw.trainer.index)end + if key=='session'then return require('src.core.Game3').forNative(c.game).session end + if key=='link'then return raw.kind=='link'end + if key=='safari'then return raw.safari~=nil and raw.safari~=false end + if key=='ghostBattle'then return raw.ghost==true end + if key=='tutorial'or key=='oldManTutorial'then return raw.demo==true or raw.dudeDemo==true end + if key=='pokedude'then return raw.pokedudeDemo~=nil and raw.pokedudeDemo~=false end + if key=='firstBattle'then return raw.firstBattle==true end + if key=='kinds'then + e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds + end + if key=='rng'then return e.rng end + if key=='interactiveChoices'then + local engine=package.loaded['src.core.game3.battle.engine'] + if engine and engine._interactiveChoices then return engine._interactiveChoices(raw,e.view)end + return raw.interactiveChoices + end + end + end + e.read=read + e.view=proxy('state',screen) + e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end + function e:flush() + if not self.context or not self.valid()then return false end + local writes={} + for mon,d in pairs(self.moveArrays or{})do + for i,value in pairs(d.moves)do assert(value==d.ids[i],'source move replacement requires native move learning')end + for i,value in pairs(d.ids)do assert(d.moves[i]==value,'source move replacement requires native move learning')end + for i,value in pairs(d.maxPp)do assert(value==d.maximum[i],'source maximum PP is read-only')end + for i,value in pairs(d.maximum)do assert(d.maxPp[i]==value,'source maximum PP is read-only')end + for i in pairs(d.baseline)do assert(d.pp[i]~=nil,'source PP slot cannot be removed')end + for i,value in pairs(d.pp)do + assert(d.baseline[i]~=nil,'unknown source PP slot') + if value~=d.baseline[i]then + local mv=mon.moves and mon.moves[i];local maximum=d.maxPp[i] + assert(mv and mv.pp==d.baseline[i],'native move PP changed during source edit') + assert(integer(value,0,maximum or 99),'invalid source move PP') + writes[#writes+1]={move=mv,value=value} + end + end + end + for _,write in ipairs(writes)do write.move.pp=write.value end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + return true + end + function e:retire() + if self.retired then return end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + -- Populate only declared observations. No native queue, functions, save or + -- data table is copied into the completed record or retained by its views. + local seen={} + repeat + local pending=false + for view,d in pairs(self.entries)do if not seen[view]then + pending=true;seen[view]=true;local snapshot={} + for _,key in ipairs(d.kind=='state'and stateKeys or d.kind=='battler'and battlerKeys or d.kind=='mon'and monKeys or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'})do + if key~='session'and key~='rng'then snapshot[key]=read(d,key)end + end + d.snapshot=snapshot + end end + if not pending then break end + until false + for _,d in pairs(self.entries)do d.raw=nil end + self.context,self.moveArrays,self.wildParty,self.valid=nil,nil,nil,function()return false end + self.retired=true;ids=nil + end + return e +end +return M diff --git a/src/compat/gen1/NativeBallObservation.lua b/src/compat/gen1/NativeBallObservation.lua new file mode 100644 index 000000000..f9308267c --- /dev/null +++ b/src/compat/gen1/NativeBallObservation.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared scalar projection of the actual native ball, not another timeline. +local M={keys={'visible','x','y','frame','ballId','side','battler','rot','scale','alpha'}} +function M.read(screen,ball,key) + if key=='visible'then return not ball.done or ball.caught==true end + if key=='x'then return ball.x+(ball.shakeOffset or 0)end + if key=='y'then return ball.y end + if key=='frame'then return math.max(0,(ball.ballFrame or 1)-1)end + if key=='ballId'then return ball.ball end + if key=='battler'or key=='side'then + local id=1 + for i=0,3 do + local b=i==0 and screen.player or i==1 and screen.enemy or screen:battlerAt(i) + if b and ball.battler==b then id=i;break end + end + return key=='battler'and id or(id%2==0 and'player'or'enemy') + end + if key=='rot'then + -- Presentation consumers use the unwrapped send-out angle as their flight + -- coordinate. Project the actual native throw clock; never advance a second + -- animation or infer progress from a screen-space position. + if ball.sendOut=='player'and ball.phase=='throw'then + local duration=ball.send and ball.send.frames or 25 + if type(duration)=='number'and duration>0 and duration