Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/api3-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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
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: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36
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
path: ${{ runner.temp }}/api3-report.json
if-no-files-found: error
42 changes: 42 additions & 0 deletions .github/workflows/enemy-action-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Enemy move choice contracts
on:
pull_request:
paths:
- '.github/workflows/enemy-action-contract.yml'
- 'src/**'
- 'examples/portable_enemy_action/**'
- 'tests/enemy_actions/**'
- 'tests/engine/portable_enemy_action_*.lua'
- 'tools/enemy-action-contract-check/**'
push:
branches: [main, dev]
permissions:
contents: read
concurrency:
group: enemy-action-contract-${{ github.ref }}
cancel-in-progress: true
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: gen2
- uses: actions/checkout@v7
with:
repository: bryanthaboi/gen1recomp
ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91
path: gen1-reference
- name: Install LuaJIT
run: sudo apt-get update && sudo apt-get install -y luajit
- name: Actual public hook and native move execution
run: >-
python3 gen2/tools/enemy-action-contract-check/check.py
--gen1 gen1-reference --gen2 gen2
--report "${{ runner.temp }}/enemy-action-report.json"
- uses: actions/upload-artifact@v7
if: always()
with:
name: enemy-action-contract-report
path: ${{ runner.temp }}/enemy-action-report.json
if-no-files-found: error
145 changes: 145 additions & 0 deletions docs/api3-portability.md
Original file line number Diff line number Diff line change
@@ -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:<key>` 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.<modId>.<key>"` 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.
96 changes: 96 additions & 0 deletions docs/battle-actions-api.md
Original file line number Diff line number Diff line change
@@ -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.
Loading