Compare commits

..

17 Commits

Author SHA1 Message Date
bryanthaboi 2c2a5a4220 LOTS OF UPDATES
LOTS OF UPDATES - SEE ALL ISSUES THIS CLOSED
2026-07-21 14:57:32 -04:00
bryanthaboi f64666c6ce discord rich presence, and MORE COLORS, video options, shiddddd so much stuff 2026-07-21 14:52:45 -04:00
bryanthaboi 3f4aaccbf5 attacks should not animate if miss or fail 2026-07-21 13:52:21 -04:00
bryanthaboi 20ad4e6c41 fix first blue battle, fossil stuff, and another color palette option 2026-07-21 13:41:34 -04:00
bryanthaboi 60b3cebdce ok the auto driver works but needs work 2026-07-21 13:19:53 -04:00
bryanthaboi 9468ffccdf solving more things and making the auto-player work almost perfectly 2026-07-21 09:55:03 -04:00
bryanthaboi 948ae8abbd Merge pull request #16 from bryanthaboi/bryanthaboi-support-1
Add support section to README
2026-07-21 07:37:17 -04:00
bryanthaboi cb22684c2a Add support section to README
Added a section for supporting the project with a 'Buy Me a Coffee' link.
2026-07-21 07:36:43 -04:00
bryanthaboi 94be169e35 Merge pull request #13 from bryanthaboi/speedrun_conv_bug_fix
work in progress bug fix via speed run
2026-07-21 05:53:01 -04:00
bryanthaboi 36188ef18a work in progress 2026-07-21 05:50:40 -04:00
bryanthaboi 7a1b4e5c45 Merge pull request #10 from bryanthaboi/CI-update
CI Update
2026-07-20 09:22:48 -04:00
bryanthaboi eba6e8d752 CI Update 2026-07-20 09:20:56 -04:00
bryanthaboi ec5a72b365 Merge pull request #3 from bryanthaboi/underground-path-warps 2026-07-19 16:43:50 -04:00
bryanthaboi 9325d56046 Fix underground path route 5-8 exit warps 2026-07-19 16:42:30 -04:00
bryanthaboi d650e605b0 Merge pull request #2 from bryanthaboi/modding-updates - big ass modding update
big ass modding update
2026-07-19 16:21:56 -04:00
bryanthaboi 47923d95b3 big ass modding update 2026-07-19 16:18:18 -04:00
bryanthaboi b5a673b252 Update README.md 2026-07-18 08:56:30 -04:00
294 changed files with 50642 additions and 2531 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Bug report
description: Something in the game is broken, wrong, or not behaving like the original.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
A screenshot is worth more than any description. If you can grab one, grab one.
If you genuinely can't, that's fine, but then the details below need to be thorough
enough that someone can find the bug without ever seeing your screen.
- type: input
id: summary
attributes:
label: One line summary
description: What's broken, in a sentence.
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
validations:
required: true
- type: dropdown
id: os
attributes:
label: Which build are you running
options:
- macOS
- Windows
- Both
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The release you downloaded. It's in the release title, like 0.1.4.
placeholder: 0.1.4
validations:
required: true
- type: input
id: location
attributes:
label: Where in the game
description: >
Be specific. Route, town, building, floor, menu, battle. If it's a map
transition, say which side you came in from.
placeholder: Underground path between Route 5 and Route 8, south entrance
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: Screenshot, or a detailed description if you couldn't take one
description: >
Drag the image straight into this box. If you couldn't get a screenshot,
write "no screenshot" and then describe exactly what was on screen: where
the player sprite was, what the tiles around it looked like, any text on
screen, whether anything was frozen or flickering. The more concrete, the better.
placeholder: |
Drop the image here.
Or, with no screenshot:
Screen faded out like a normal warp, then faded back in with the player
standing on the grass tile north of the Route 6 gate house instead of
inside the tunnel. Player was facing down. No text box. Music kept playing
the underground path track instead of switching to the Route 6 track.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to make it happen
description: Numbered steps, starting from a point someone else can get to.
placeholder: |
1. Start a new game, get to Cerulean
2. Go south to Route 5, enter the underground path building
3. Walk to the south end of the tunnel
4. Step onto the stairs
validations:
required: true
- type: textarea
id: expected
attributes:
label: What should have happened
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Does it happen every time or only sometimes. Did it start after a specific
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,88 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
- type: input
id: summary
attributes:
label: One line summary
description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike
validations:
required: true
- type: textarea
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing
description: >
What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Does this change how the original game plays
description: >
Some requests are quality of life, some change the actual game. Both are fine,
it just helps to know which one you're asking for.
options:
- Quality of life, original game is untouched
- Changes how the game plays
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, how another game does it, related issues. Leave blank
if nothing comes to mind.
validations:
required: false
+163
View File
@@ -0,0 +1,163 @@
name: ci
# The ROM-free test run (21-testing-and-ci §CI).
#
# CI has no ROM and never will: data/generated/ is produced by a SHA-1
# verified import of a cartridge dump, and no ROM bytes are ever committed.
# That is why the suite is tiered -- T1 (primitives), T2 (engine invariants)
# and T4 (mod SDK) run against the committed tests/fixture_data dataset, so
# they need no ROM, no display and no assets beyond what is in the repo.
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
# data/generated/ is absent and skips it rather than failing.
#
# Runs alongside release.yml, which is untouched by this file.
on:
push:
branches: [main]
pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
# written to Lua 5.1 semantics, so CI must run the interpreter the
# game actually ships with or it would green-light 5.4-only syntax.
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: interpreter version
run: luajit -v
- name: run every ROM-free tier
run: ./scripts/test.sh
fixture-dataset:
name: fixture dataset integrity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
# the fixture PNGs are committed (they are 8x8 placeholders, not
# ROM-derived); assert they are still readable 4-shade images rather
# than regenerating them, so a corrupted commit is caught
- name: fixture assets are valid PNGs
run: |
python3 - <<'PY'
import glob, sys
from PIL import Image
paths = sorted(glob.glob("tests/fixture_data/assets/*.png"))
if not paths:
sys.exit("no fixture assets found")
for path in paths:
with Image.open(path) as image:
image.load()
print(f"ok {path} {image.size} {image.mode}")
print(f"\n{len(paths)} fixture assets valid")
PY
# the fingerprint golden is the parity tripwire; prove it still
# matches the dataset on a clean checkout
- name: fingerprint gate
run: luajit tests/engine/gate_fingerprint.lua
- name: parity-guarantee meta-test
run: luajit tests/engine/gate_meta_coverage.lua
# Only the differ is under test here, and the job is named for that. The
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
# chunk runs after main.lua has already booted the game, and
# src/core/Data.lua has no POKEPORT_DATA_DIR branch, so no LOVE process
# can be pointed at tests/fixture_data. There is deliberately no step
# here that runs scripts/test.sh with WITH_SHOTS: it would have nothing
# to capture and nothing to diff, and a job that green-lights on skipped
# work is worse than an absent one.
shot-differ:
name: screenshot differ (capture not yet wired)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 -m pip install --upgrade pillow
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
# flags a deliberately corrupted golden and passes the clean one.
# That is the half of the pipeline this repo can actually prove.
- name: compare_shots self-test
run: |
set -e
python3 - <<'PY'
import os
from PIL import Image
os.makedirs("/tmp/g", exist_ok=True)
os.makedirs("/tmp/s", exist_ok=True)
base = Image.new("RGB", (160, 144), (255, 255, 255))
for x in range(0, 160, 8):
for y in range(0, 144, 8):
base.putpixel((x, y), (0, 0, 0))
base.save("/tmp/g/clean.png")
base.save("/tmp/s/clean.png")
base.save("/tmp/g/broken.png")
bad = base.copy()
for x in range(40, 60):
for y in range(40, 60):
bad.putpixel((x, y), (255, 0, 0))
bad.save("/tmp/s/broken.png")
PY
if python3 tools/compare_shots.py /tmp/g /tmp/s; then
echo "compare_shots passed a corrupted golden -- differ is broken"
exit 1
fi
rm /tmp/g/broken.png /tmp/s/broken.png
python3 tools/compare_shots.py /tmp/g /tmp/s
# an empty golden directory must not read as success
- name: differ refuses to pass vacuously
run: |
set -e
mkdir -p /tmp/empty-goldens /tmp/empty-shots
if python3 tools/compare_shots.py /tmp/empty-goldens /tmp/empty-shots; then
echo "compare_shots passed with no goldens"
exit 1
fi
lint:
name: mod lint (no ROM-derived content)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# the MK305 dump check key-diffs shipped tables through luajit, and
# modkit treats a missing interpreter as a fatal MK100 -- without
# this install the gate would fail instead of failing open
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
# constraint 1, enforced automatically: a committed mod that ships
# ROM-derived bytes fails the build. `lint` is the no-ROM-content
# check; `validate` is deliberately not run here because it resolves
# a mod against the fixture dataset, and a Red-content mod such as
# example_mew_starter legitimately does not resolve against it.
- name: lint every committed mod
run: |
set -e
found=0
for mod in mods/*/; do
[ -f "$mod/manifest.json" ] || continue
found=1
echo "== $mod"
python3 tools/modkit.py lint "${mod%/}"
done
if [ "$found" = "0" ]; then
echo "no committed mods to lint"
fi
+41
View File
@@ -14,6 +14,12 @@ name: Release
on:
push:
branches: [main]
# CI/workflow and docs-only changes don't ship anything to users, so they
# don't earn a release. A push touching these *and* real source still
# releases; only pushes confined entirely to these paths are skipped.
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
inputs:
version:
@@ -23,6 +29,8 @@ on:
permissions:
contents: write
issues: read
pull-requests: read
concurrency:
group: release
@@ -206,7 +214,40 @@ jobs:
v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}"
# Issues this release closes, per GitHub's own "closing issues" links
# (works for squash, rebase, and merge commits alike). Scans every
# commit since the previous tag so a skipped release run doesn't drop
# issues on the floor.
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
closed=""
for pr in $(git log --pretty=%H "$range" \
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
--jq '.[].number' 2>/dev/null \
| sort -un || true); do
closed+="$(gh api graphql \
-f owner="${GITHUB_REPOSITORY%/*}" \
-f name="${GITHUB_REPOSITORY#*/}" \
-F pr="$pr" \
-f query='
query($owner:String!, $name:String!, $pr:Int!) {
repository(owner:$owner, name:$name) {
pullRequest(number:$pr) {
closingIssuesReferences(first:50) { nodes { number title } }
}
}
}' \
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
done
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
notes="Download the correct version for your computer below."
if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$tag" \
--target "$GITHUB_SHA" \
+326
View File
@@ -0,0 +1,326 @@
# Contributing to the mod platform
Two routes
| You are... | Lane | Review bar |
|---|---|---|
| adding a mod to the gallery, or listing one in the showcase | [Lane A](#route-a--contributing-a-mod) | template + polish checklist + green `modkit validate` |
| changing the loader, a registry schema, an event/hook name, or a manifest field | [Lane B](#route-b--contributing-an-engine--mod-api-change) | RFC + backward-compat statement + parity test + generated docs |
If you are not sure which lane you are in, ask this: **could my change make
somebody else's existing mod behave differently?** If yes, it is Lane B.
---
## Route A — contributing a mod
### 1. Scaffold
```sh
python3 tools/modkit.py scaffold my_mod --profile content
```
`--profile` is one of `content`, `overhaul`, `total_conversion`. The
scaffold refuses to overwrite an existing directory, and prints the next
commands.
Or copy the gallery entry closest to your intent — that is what the gallery
is for:
| You want to... | Copy |
|---|---|
| change numbers | `mods/examples/example_balance_tweaks` |
| change art | `mods/examples/example_shiny_palette` |
| add music or cries | `mods/examples/example_jukebox` |
| add a quest, NPC or dialogue | `mods/examples/example_lost_parcel` |
| change how battles work | `mods/examples/example_weather` |
| add a screen or a tool | `mods/examples/example_dexnav` |
| build a whole new game | `mods/examples/example_mini_conversion` |
### 2. What the PR must contain
1. **A green `modkit validate`.** CI runs it; so should you.
```sh
python3 tools/modkit.py validate mods/examples/<id> --base imported
python3 tools/modkit.py lint mods/examples/<id>
```
`validate` drives the *real* loader headlessly, so a mod that passes
here does not surface load errors in game. `--base imported` folds
against the full vanilla id space; without it, rules that can only be
decided against real Red content (`MK103`, the patch-target check) are
reported as skipped rather than guessed at.
2. **A `tests/` directory** with at least one suite that loads the mod
through the headless loader and asserts its *stated effect* — not just
that it loaded.
```lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data"); Data:load()
local run = T.sdk.loadMod("mods/examples/my_mod", { data = Data })
T.eq(#run.errors, 0, "loads clean")
T.eq(Data.pokemon.PIKACHU.baseStats.speed, 120, "the patch landed")
run.release()
T.finish("my_mod")
```
Add a `.modkitignore` listing the suite so it stays out of the
distributed package — a test requiring engine modules is a
private-require finding against the shipped archive, and `pack` treats
warnings as fatal.
3. **A `README.md`** that opens with one sentence saying what the mod does,
names its persona, and gives the three commands to try it. No
prerequisites the scaffold did not already create.
4. **A `mod.card`** meeting the [§3.2 schema](#modcard):
```lua
return {
summary = "One sentence, <=100 chars.",
author = "Your handle", -- never blank; no author is anonymous by omission
tags = { "balance", "beginner" },
differences = { changed = {…}, added = {…}, known = {…} },
credits = { { who = "…", for_ = "original chiptune arrangement" } },
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
```
5. **A `CHANGELOG.md`** in keep-a-changelog format, with a heading matching
`manifest.version`. `validate` warns when the version advanced without
one.
6. **Disabled by default.** Gallery entries live in `mods/examples/`, which
the loader's one-level discovery does not walk, so a fresh install
discovers none of them and the vanilla game is unchanged.
7. **No ROM-derived bytes.** Art and audio ship as originals or as a
`transforms.lua` operating on the player's own cache. `modkit lint`
is the hard floor; see
[the legal posture](#legal-posture-non-negotiable).
CI checks 1, 2, 6 and 7 mechanically. A reviewer checks 3, 4, 5 and the
polish checklist.
### 3. Category
`manifest.category` is a closed vocabulary. An unknown value is a warning,
not a hard error, so the list can grow without breaking old mods.
| category | Meaning | Typical profile |
|---|---|---|
| `TWEAK` | Small data edits: stats, prices, learnsets, encounter tables | content |
| `BALANCE` | Systematic rebalance across many records or a ruleset | content / overhaul |
| `CONTENT` | New species / moves / items / maps / trainers | content |
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
| `AUDIO` | Music, sfx, cries | content |
| `UI` | New or modified screens, menus, overlays | content / overhaul |
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
| `TOTAL_CONVERSION` | Full re-theme; owns its own tri-ledger | total_conversion |
| `OTHER` | Fallback | any |
`GAMEPLAY` is accepted as an alias for `TWEAK`, so `example_mew_starter`
keeps validating with the value it has shipped since before the taxonomy
existed.
### 4. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this,
what it changes, what it does not do yet. It is never read by the loader's
merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load.
Two fields deserve their own note:
- **`differences`** is a self-declared tri-ledger, mirroring the discipline
the engine holds itself to. `changed` and `added` let a player see the
blast radius before installing; `known` is where you are honest about
what is rough. A card with an empty `known` on a complex mod reads as
carelessness, not polish.
- **`screenshots[].transform`** describes a screenshot by the *driver
script* that regenerates it from the player's build, rather than shipping
the pixels. That is the legal posture extended to your marketing: a
distributed mod never carries ROM-derived bytes, not even in its preview
images.
### 5. Tags
Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`,
`data-only`, `quality-of-life`, `hardcore`, `cosmetic`, `story`, `ruleset`,
`audio`, `ui`, `total-conversion`.
---
## Route B — contributing an engine / mod-API change
Changing the loader, a registry schema, an event or hook name, or a manifest
field touches the **compatibility surface** the project promises to hold
stable. Those PRs carry five obligations.
### 1. An RFC
`docs/rfcs/NNNN-<slug>.md`, covering:
- **Motivation** — the mod that cannot be written today.
- **The decision it extends or amends** — name the D-number and the plan
file, so the change is traceable to the design it modifies.
- **The exact API delta** — new registry names, new schema fields, new
event/hook names and their payload shapes and call sites.
- **A migration note for existing mods** — what an author has to do, if
anything. "Nothing" is a valid and preferred answer.
### 2. A backward-compatibility statement
Show that the v1 surface still works: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields,
and `pokemon.before_give`.
**A change that would break a v1 mod is rejected unless it is
additive-with-alias.** `mods/example_mew_starter` is the live proof: it is
api 1, uses `category = "GAMEPLAY"`, copies a whole species record because
`patch` did not exist yet, and it must keep loading unchanged.
### 3. A parity-guarantee test
Two tests, not one:
- **The no-mod test** — vanilla behavior is unchanged with nothing
installed. A new hook with no subscriber must return the vanilla value;
a new registry must be a provable no-op when empty; a new event must not
allocate its payload when nothing wants it (`Runtime.wants(name)` /
`Runtime.wantsHook(name)` guard the hot paths).
- **The mod-API test** — the new seam, exercised through the *public* mod
API rather than by reaching into internals. If the test has to require a
private module to drive your seam, the seam is not finished.
### 4. Docs with the change
The reference pages are generated from `src/mods/Schemas.lua`, so a new
registry or a new schema field lands with its catalog entry in the same PR
and the generator runs clean:
```sh
luajit tools/gen_registry_docs.lua # in-repo default
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout
```
The prose reference lives in the GitHub wiki; the generated pages are
written into a checkout of it, so they cannot drift from the engine.
### 5. Deprecation etiquette
**Nothing is removed.** A superseded seam is marked deprecated in the
generated reference with its replacement named, keeps firing and working,
and is listed in the deprecations page.
`pokemon.before_give` is the worked precedent: the `pokemon.give` hook
supersedes it, and it is grandfathered forever anyway.
### Review
PRs touching `src/mods/`, `src/mods/Schemas.lua`, or the event/hook catalog
need the RFC label and a green parity gate before merge.
---
## The polish checklist
Every gallery example and every community mod the guide recommends meets
this bar. `[auto]` items are checked by `modkit validate`; `[review]` items
by a human.
### Error messages
- `[auto]` No bare `error()` or `assert()` in mod callbacks. Every failure
path uses `mod.log:warn` / `mod.log:error` — the loader already prefixes
`[modid]` — **and names a remediation**:
```lua
-- no
local mew = assert(mod.content.pokemon:get("MEW"), "Mew is missing")
-- yes
if not mod.content.pokemon:get("MEW") then
mod.log:warn("MEW missing from the merged view -- is a species mod "
.. "loaded before this one? speed patch skipped")
return
end
```
- `[review]` Every registration is validated against its schema, so a typo
is a load-time message naming the field, not a nil-index crash three
screens later.
### Empty states
- `[review]` Every screen a mod adds renders a sentence when its data set
is empty — "No songs registered", "Nothing seen yet" — never a blank box.
`ListMenu` gives you this for free.
### First-run experience
- `[review]` The README opens with one sentence of what the mod does, then
the commands to try it.
- `[auto]` The mod loads clean on a fresh install — zero `Loader.errors` —
with only its declared dependencies.
- `[review]` Options have sane defaults, so the mod does something useful
before the player opens its options pane.
### Credits and honoring authors
- `[review]` `mod.card.credits` names every upstream contribution — art,
music arrangement, borrowed code — and what it was for. A mod that ports
another community work credits it and links it.
- `[auto]` `mod.card.author` (or `authors`) is present and non-empty. The
showcase and the manager both surface it, so no author is anonymous by
omission.
- `[review]` Asset provenance is honest: originals declared original,
cache-derived output produced by a declared transform, third-party assets
credited and license-compatible.
### Legal posture (non-negotiable)
- `[auto]` **No ROM-derived bytes in the packaged mod.** `modkit pack`
refuses otherwise, and `pack` runs `validate --strict`, so even warnings
block the archive.
- `[review]` A total conversion carries the TC legal callout: the Red
import still runs and supplies fallback infrastructure, the conversion
overrides on top, and it distributes recipes rather than extracted
content.
---
## Versioning etiquette
Three version numbers coexist.
**Engine version** — `src/core/Version.lua`. Major = a breaking change to
the mod-facing schemas or API; minor = new backward-compatible seams;
patch = bugfix.
**Mod API version** — the integer `modApi`, currently `2`. Bumped only on a
breaking change to the `mod` object surface. A manifest's `api` field pins
the surface the mod was written against, so an api-2 mod keeps working when
the engine ships api 3.
**Your mod's version** — the manifest `version`, semver:
| bump | when |
|---|---|
| patch | data fixes; no save-shape change, no new content ids |
| minor | new content ids, new options with defaults, new optional deps |
| major | removed or renamed content ids, a changed `mod.save` shape (needs a `mod.migrations:add(sinceVersion, fn)` entry), or a raised `game_version` floor |
Declare the engine range you target in `game_version` (a semver range, e.g.
`">=1.0.0 <2.0.0"`). The loader checks it on load; a mismatch is a clear,
mod-attributed manager error, never a silent partial load.
Every version change gets a `CHANGELOG.md` heading. `modkit validate` warns
when `manifest.version` advanced without one.
+50 -101
View File
@@ -4,23 +4,17 @@ A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red ROM is the only game
content input.
```text
first boot
Pokemon Red ROM -> in-app Lua importer -> private LÖVE save directory
-> generated Lua data and PNGs
-> compact audio channel programs
-> LÖVE2D engine
```
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again.
## Packaged App
## Quick Start
Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
@@ -28,114 +22,69 @@ automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
The packaged app contains neither a ROM nor pre-extracted game data.
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
Music, sound effects, and cries are synthesized while the game runs from
compact Game Boy audio channel programs copied out of the verified ROM. No
WAV or OGG library is bundled or generated.
## Controls
## Source Checkout
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
The source launchers retain an optional Python workflow for developers. Place
the ROM in the project folder and double-click `Play-Mac.command` or
`Play-Windows.bat`, or run:
## Running From Source
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
`Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
scripts/run.sh
```
Windows PowerShell:
then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
```powershell
powershell -ExecutionPolicy Bypass -File scripts\setup.ps1 -Rom C:\path\red.gb
powershell -ExecutionPolicy Bypass -File scripts\run.ps1
```
## Modding
The setup scripts also accept `ROM_PATH`. With no argument, they use the first
`.gb` file in the project root.
The game ships a native mod platform: content registries, events and hooks,
per-mod saves and options, and an in-game manager. The full modding book —
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
reference — lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
## Developer Data Build
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
Requirements: Python 3.10+ and Pillow.
## Bugs and Ideas
```sh
python3 -m pip install pillow
python3 tools/build_data.py --rom "/path/to/Pokemon Red.gb" --clean
```
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
that looks wrong, text in the wrong box, anything that does not match the
original game.
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
Attach a screenshot if you can. It saves a lot of back and forth, and if you
can't get one, the form asks you to describe what you saw instead.
This command produces the data modules and 495 PNGs in the source tree for
development and parity checks. It is not used by the packaged app.
Thought of a feature that could be good, or a way to improve one that already
exists?
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
Say what you want, why it is worth doing, and how you picture it working. A
request with real detail is one that can actually get built.
## Running
## More
Requires LÖVE 11.x:
```sh
love .
```
Controls: arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
## Link Play
START > LINK connects two copies directly over UDP. The host chooses HOST A
GAME and shares the shown address; the other player chooses JOIN A GAME.
The default port is 7777 and can be overridden with `POKEPORT_LINK_PORT`.
## Save Editor
Edit party, boxes, items, events, map location, and Pokédex flags without
playing through the game. Close the game first, then from the repo root:
```sh
love . --editor
# or
POKEPORT_EDITOR=1 love .
# open a specific save
love . --editor --save "/path/to/save.lua"
```
By default it loads the game's LÖVE save (`save.lua`):
- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/save.lua (or without the LOVE in a built version)`
- Linux: `~/.local/share/love/pokemon-love2d/save.lua`
- Windows: `%APPDATA%\love\pokemon-love2d\save.lua`
If that file is missing or you want another copy, use **Open...**, drop a
`save.lua` onto the window, or pass `--save`. Each write makes a
`save.lua.bak-YYYYMMDD-HHMMSS` backup first.
See `tools/save-editor/README.md` for headless tests.
## Layout
```text
tools/ ROM decoder, save editor, and developer verification tools
data/generated/ generated Lua game data (gitignored)
data/scripts/ hand-ported map behavior
assets/generated generated graphics and compact audio cache (gitignored)
src/ hand-written LÖVE engine
scripts/ setup, run, and packaging helpers
mobile/ Android and iOS build trees
tests/ headless behavior and parity suites
docs/ architecture, behavior notes, and platform docs
```
See `docs/architecture.md` for runtime details and
`docs/behavior-porting-notes.md` for formula provenance.
## Delete generated files (mac)
```sh
rm -rf data/generated assets/generated \
"$HOME/Library/Application Support/LOVE/pokemon-love2d/data/generated" \
"$HOME/Library/Application Support/LOVE/pokemon-love2d/assets/generated"
rm -f "$HOME/Library/Application Support/LOVE/pokemon-love2d/rom-cache.complete"
```
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- `docs/architecture.md` — runtime details;
`docs/behavior-porting-notes.md` — formula provenance.
## Special Thanks
This project would not be possible without [pret](https://github.com/pret) > the pret band of decompiling maniacs > and their [pokered](https://github.com/pret/pokered) disassembly.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
+5 -1
View File
@@ -16,7 +16,11 @@ function love.conf(t)
t.window.height = 800
else
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
t.window.title = "Pokemon Red (Gen 1 Recompilation Project)"
-- Version.lua has zero requires, so it is loadable this early; fall
-- back to the plain title if the source is not mounted yet
local ok, Version = pcall(require, "src.core.Version")
t.window.title = ok and Version.title()
or "Pokemon Red (Gen 1 Recompilation Project)"
t.window.width = 160 * 4
t.window.height = 144 * 4
end
File diff suppressed because it is too large Load Diff
+10 -32
View File
@@ -1,22 +1,18 @@
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
--
-- Not ported here (already handled elsewhere / not talk-reachable):
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
-- gates themselves are already implemented via story5.lua's onStep
-- chain (viridianOldManStep / viridianGymLock) for this map.
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
-- scripted-battle cutscene outside this task's Commands vocabulary
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
-- exists); we port the real YES/NO branch text he speaks but the
-- "yes" branch here just shows the "I'll show you how" line rather
-- than actually starting the minigame, since that machinery isn't
-- ported to this map yet.
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
-- live in data/scripts/story.lua, which owns this map's onStep gate
-- and can reach the `old_man_demo` command for the real catch
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
-- duplicate here would silently win the merge.
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
local M = {}
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
end
end,
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
-- his coffee, he asks (YES/NO) whether you want to learn how to
-- catch Pokemon. YES leads into the catch-training minigame
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
-- see file header); NO just brushes you off ("Time is money...").
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
if yes then
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
else
push(game, t._ViridianCityOldManTimeIsMoneyText
or "Time is money...\nGo along then.", done)
end
end)
end,
},
}
+26 -27
View File
@@ -5,18 +5,26 @@
-- onBoulderMoved = fn } where a script is a list of { "command", args... }
-- rows executed by src/script/ScriptRunner.lua. Every hand-ported script
-- cites the pokered source it was ported from.
--
-- The modules land in src/script/MapScripts.lua as the engine's base
-- contribution: attachBase keeps the historical merge (talk tables merge
-- per TEXT constant, other hooks are replaced by later files, so
-- different files can each add NPCs to the same map), and mod
-- contributions from the map_scripts registry compose on top of it.
local registry = {
PALLET_TOWN = require("data.scripts.pallet_town"),
OAKS_LAB = require("data.scripts.oaks_lab"),
REDS_HOUSE_1F = require("data.scripts.reds_house"),
CELADON_MANSION_ROOF_HOUSE = require("data.scripts.celadon_eevee"),
}
local MapScripts = require("src.script.MapScripts")
-- story-critical scripts, one table per map. Later files MERGE into
-- earlier ones: talk tables merge per TEXT constant, other hooks
-- (onEnter, onVictory, ...) are replaced, so different files can each
-- add NPCs to the same map.
for _, mapEntry in ipairs({
{ "PALLET_TOWN", "data.scripts.pallet_town" },
{ "OAKS_LAB", "data.scripts.oaks_lab" },
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
}) do
MapScripts.attachBase(mapEntry[1], require(mapEntry[2]))
end
-- story-critical scripts, one table per map, in the order the old merge
-- loop required them
for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
"data.scripts.story3", "data.scripts.story4",
"data.scripts.story5", "data.scripts.story6",
@@ -24,33 +32,24 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
"data.scripts.safari", "data.scripts.seafoam",
"data.scripts.gyms" }) do
for mapId, mod in pairs(require(file)) do
local existing = registry[mapId]
if not existing then
registry[mapId] = mod
else
for k, v in pairs(mod) do
if k == "talk" and existing.talk then
for textConst, script in pairs(v) do
existing.talk[textConst] = script
end
else
existing[k] = v
end
end
end
MapScripts.attachBase(mapId, mod)
end
end
local M = {}
function M.get(mapId)
return registry[mapId]
return MapScripts.get(mapId)
end
-- script to run when the player talks to an object with this TEXT_ constant
function M.talkScript(mapId, textConst)
local mod = registry[mapId]
return mod and mod.talk and mod.talk[textConst] or nil
return MapScripts.talkScript(mapId, textConst)
end
-- attribution for that script's run: the owning mod's source, nil for base
function M.talkSource(mapId, textConst)
return MapScripts.talkSource(mapId, textConst)
end
return M
+51 -39
View File
@@ -10,8 +10,8 @@
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
-- afterwards he gloats or sulks and marches out of the lab
-- (OaksLabRivalBattleEndScript).
-- afterwards HealParty + flag always, then he gloats or sulks and
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
@@ -58,33 +58,40 @@ return {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "jump_if_true", 14 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
{ "jump", 30 }, -- 11
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
{ "jump_if_false", 27 }, -- 13
{ "check_item", "POKE_BALL" }, -- 14
{ "jump_if_true", 25 }, -- 15
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
{ "jump_if_false", 29 }, -- 17
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
{ "jump_if_true", 25 }, -- 19
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "give_item", "POKE_BALL", 5, false }, -- 21
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
{ "jump", 30 }, -- 24
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
{ "jump", 30 }, -- 26
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
{ "jump", 30 }, -- 28
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -100,9 +107,9 @@ return {
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 18 }, -- 5
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
@@ -113,18 +120,20 @@ return {
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16
{ "jump", 21 }, -- 17
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18
{ "jump", 25 }, -- 19
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20
-- battle aftermath: on a win the rival sulks and marches out
-- (OaksLabRivalBattleEnd); on a loss the blackout already warped
-- us away, so the script just ends
{ "jump_if_false", 25 }, -- 21
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22
{ "move_npc_to", 1, 4, 11 }, -- 23
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end)
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
{ "heal_party" }, -- 16
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
{ "jump", 23 }, -- 18
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
{ "jump", "end" }, -- 20
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
{ "jump", "end" }, -- 22
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
{ "jump_if_false", 25 }, -- 23
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
},
},
@@ -171,9 +180,12 @@ return {
local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- a loss blacks out to another map: end the script there
table.insert(rows, { "jump_if_false", base + 7 })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
+134 -36
View File
@@ -10,6 +10,31 @@ local M = {}
-- -------------------------------------------------------------------
M.VIRIDIAN_MART = {
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
-- script, not a talk. Entering with a starter and no parcel runs
-- ViridianMartDefaultScript -- the clerk calls out, then
-- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it.
--
-- The talk branch below is kept as the fallback for a save that reaches
-- the counter without this having fired.
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
if not f.EVENT_GOT_STARTER then return end
ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
})
end,
talk = {
TEXT_VIRIDIANMART_CLERK = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
M.VIRIDIAN_CITY = {
talk = {
-- the old man napping on the north path (scripts/ViridianCity.asm)
-- after his coffee, the old man offers the catch tutorial: not in a
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
-- only ever grumbles and shoves you back down -- he never wakes,
-- moves or hides. The coffee ask and the catch tutorial belong to
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
{ "jump_if_true", 5 }, -- 2
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
{ "jump", 14 }, -- 4
{ "face_player" }, -- 5
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
{ "jump_if_true", 12 }, -- 7 (in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
{ "old_man_demo" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
{ "move_player", "down", 1 }, -- 2
},
-- The walking old man at (17,5), shown once the Pokédex swaps him in
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
-- off, NO leads into the catch tutorial: he explains, demos a catch
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
TEXT_VIRIDIANCITY_OLD_MAN = {
{ "face_player" }, -- 1
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
{ "old_man_demo" }, -- 5
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
{ "jump", 9 }, -- 7
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
},
},
-- the sleeping old man lies across the Route 22 path (18,9): until
-- he moves you can't slip past on either side (scripts/ViridianCity
-- blocks the whole corridor, not just his tile)
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
-- and the girl (17,9). With the Pokédex the check returns immediately
-- and you simply walk past at x=19.
onStep = function(game, ow, x, y)
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
if gone then return false end
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
if y <= 8 and x >= 17 and x <= 19 then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end
return false
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
if x ~= 19 or y ~= 9 then return false end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!",
function() ow:scriptMove(ow.player, "down", 1) end))
return true
end,
}
@@ -104,7 +135,7 @@ M.BILLS_HOUSE = {
talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = {
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
{ "jump_if_true", 11 }, -- 2
{ "jump_if_true", 13 }, -- 2
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
-- the cell-separator PC throws its switch
@@ -118,8 +149,21 @@ M.BILLS_HOUSE = {
{ "give_item", "S_S_TICKET", 1, false }, -- 7
{ "show_text", "_SSTicketReceivedText" }, -- 8
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
-- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
-- house's south door at (27,11), and that house is one of the two
-- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit -- which is
-- exactly what stranded the bot after it beat Misty.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
{ "jump", 14 }, -- 12
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
},
},
}
@@ -208,7 +252,17 @@ M.POKEMON_TOWER_7F = {
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
{ "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8
-- pokered warps to wDestinationWarpID $1 (0-based) -- the house's
-- SECOND warp, the door mat at (3,7) -- facing UP
-- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP +
-- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put
-- the player at the Pokédex table, and the route's first waypoint
-- (3,7) then stepped onto a LIVE door mat and exited the house
-- before ever talking to Fuji -- so the POKE_FLUTE was never
-- collected and the Route 16 SNORLAX sealed the map. The arrival
-- mat itself is inert until stepped off (warpEntryCell), which is
-- what makes the vanilla coordinates safe.
{ "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8
},
},
}
@@ -343,6 +397,50 @@ M.WARDENS_HOUSE = {
-- -------------------------------------------------------------------
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
-- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while
-- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text,
-- walks him three tiles down (.GiovanniMovement), and starts the fight.
-- He also has no trainer-header entry, so sight engagement never fires
-- either. Without this hook he was a talk-only statue four tiles away
-- from anything the route (or a vanilla-faithful player walking the same
-- line) would touch, and the whole Silph ending -- the flag, the Master
-- Ball, the Saffron streets clearing -- silently never happened.
--
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
-- sets nothing, so the trigger re-arms exactly as vanilla does.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
local gio
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
end
if not gio or ow:trainerDefeated(gio) then return false end
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
end
end)
end)
return true
end,
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
end
end,
talk = {
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
{ "face_player" }, -- 1
+79 -14
View File
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
-- (engine/items/inventory.asm), which walks the same three item ids and
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
local function takeGuardDrink(game)
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
return true
end
end
return false
end
local function saffronGate(guardText, triggers, horizontal)
return {
talk = {
@@ -289,18 +306,11 @@ local function saffronGate(guardText, triggers, horizontal)
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
return
end
for _, drink in ipairs(DRINKS) do
if (game.save.inventory[drink] or 0) > 0 then
game.save.inventory[drink] = game.save.inventory[drink] - 1
if game.save.inventory[drink] == 0 then
game.save.inventory[drink] = nil
end
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
return
end
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
return
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
@@ -317,6 +327,27 @@ local function saffronGate(guardText, triggers, horizontal)
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- Stepping on the trigger WITH a drink hands it over right here.
--
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
-- `farcall RemoveGuardDrink` before it decides anything: the coord
-- trigger itself takes the drink and sets
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
-- gets the thirsty line and the walk-back. We had the removal on the
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
-- bag was turned away and the four gates stayed shut unless you
-- happened to talk to him -- which vanilla never requires.
--
-- Saffron is the middle of the map, so this sealed it: every route
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
-- short way) was unreachable, and the bot could not get to Lavender
-- for the POKE_FLUTE at all.
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!")))
return true
end
local back
if horizontal then
back = ow.player.facing == "left" and "right" or "left"
@@ -386,6 +417,26 @@ M.BIKE_SHOP = {
-- the Cinnabar lab (the wait is skipped).
-- -------------------------------------------------------------------
-- The Super Nerd (object index 1) claims both fossils and blocks the way
-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his
-- header carries no range, so MtMoonB2FDefaultScript force-triggers the
-- battle the instant the player steps onto (13,8) -- the chokepoint tile
-- to his right -- and reaching for a fossil intercepts you too.
local function superNerdBeaten(ow)
local nerd = ow:npcByIndex(1)
return not nerd or ow:trainerDefeated(nerd)
end
local function engageSuperNerd(game, ow, onDone)
local nerd = ow:npcByIndex(1)
if not nerd or ow:trainerDefeated(nerd) then
if onDone then onDone() end
return
end
nerd:facePlayer(ow.player)
ow:engageTrainer(nerd, onDone)
end
local function mtMoonFossil(itemId, otherName)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
@@ -394,7 +445,13 @@ local function mtMoonFossil(itemId, otherName)
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
return
end
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
@@ -403,7 +460,6 @@ local function mtMoonFossil(itemId, otherName)
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
@@ -412,6 +468,15 @@ local function mtMoonFossil(itemId, otherName)
end
M.MT_MOON_B2F = {
-- MtMoonB2FDefaultScript forces the Super Nerd battle when the player
-- steps onto (13,8), the tile beside him guarding the fossils.
onStep = function(game, ow, x, y)
if x == 13 and y == 8 and not superNerdBeaten(ow) then
engageSuperNerd(game, ow, nil)
return true
end
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
+28 -14
View File
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
-- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until identified with the Silph Scope and defeated.
-- (10,16) until defeated.
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
-- -------------------------------------------------------------------
M.POKEMON_TOWER_6F = {
@@ -71,26 +79,32 @@ M.POKEMON_TOWER_6F = {
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
if x ~= 10 or y ~= 16 then return false end
local TextBox = require("src.render.TextBox")
if not game.save.inventory.SILPH_SCOPE then
game.stack:push(TextBox.new(game,
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
function()
local back = ow.player.facing == "up" and "down" or "up"
ow:scriptMove(ow.player, back, 1)
end))
return true
end
local t = game.data.text
game.stack:push(TextBox.new(game,
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
if not game.save.inventory.SILPH_SCOPE then
battle:makeGhost()
end
battle.onFinish = function(result)
if result == "win" then
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
-- "and a / jr nz"): losing writes $1 and running writes $2, but
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
-- script reads 0 -- defeated. That is the famous Poke Doll
-- trick, and the speedrun route this bot follows depends on it.
if result == "win" or battle.pokeDollEscape then
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
game.stack:push(TextBox.new(game,
"The restless soul\ncalmed down and\ndeparted!"))
t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
elseif result ~= "lose" then
-- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that
-- immediately re-fires.
ow:scriptMove(ow.player, "right", 1)
end
ow:afterBattle(result)
ow:afterBattle(result, battle)
end
game.stack:push(battle)
end))
+90
View File
@@ -633,4 +633,94 @@ M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with
-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells
-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32)
-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset.
-- Without this script the doorway never opened, so the whole arena --
-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off
-- from the entrance hall and the league dead-ended here ("goto (6,11)
-- unreachable on LANCES_ROOM").
--
-- LancesRoomDefaultScript's coordinate triggers, all inert once
-- EVENT_BEAT_LANCE is set:
-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate
-- trigger, not a talk; victories.lua OPP_LANCE#1 sets
-- EVENT_BEAT_LANCE on the win, so a loss re-arms)
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- -------------------------------------------------------------------
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
ow:replaceBlock(3, 6, locked and 0x73 or 0x32)
end
local function lanceLockDoor(game, ow)
if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
require("src.core.Sound").play(game.data, "Go_Inside")
lanceEntranceBlocks(game, ow)
end
local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
end
M.LANCES_ROOM = {
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
-- fires for completed steps -- start the walk-in here, the same
-- way Lorelei's auto walk-in runs from its onEnter
if not game.save.flags.EVENT_BEAT_LANCE
and ow.player.cellX == 24 and ow.player.cellY == 16 then
lanceWalkIn(game, ow)
end
end,
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_LANCE then return false end
if (x == 5 and y == 1) or (x == 6 and y == 2) then
local lance
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end)
return true
end
if (x == 5 or x == 6) and y == 11 then
lanceLockDoor(game, ow)
return false
end
if x == 24 and y == 16 then
lanceWalkIn(game, ow)
return true
end
return false
end,
}
return M
+10 -1
View File
@@ -360,7 +360,7 @@ local rocketRows = {
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 16 }, -- 8
{ "jump_if_false", 18 }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
@@ -368,6 +368,15 @@ local rocketRows = {
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
}
M.CERULEAN_CITY = {
+32 -9
View File
@@ -5,24 +5,46 @@
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
-- sets to retire unfought non-leader trainers (PewterGym.asm
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
local function range(prefix, first, last)
local t = {}
for i = first, last do
t[#t + 1] = prefix .. i
end
return t
end
return {
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE" },
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM" },
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT" },
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN" },
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC" },
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE" },
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST" },
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE" },
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
@@ -31,7 +53,8 @@ return {
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+14 -53
View File
@@ -1,58 +1,19 @@
# Native modding
The game has a built-in Lua mod runtime. Mods are installed under the LÖVE
save directory in `mods/<id>/` and are loaded after the verified ROM data has
been imported but before the title screen is created.
The modding book lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
## Minimal mod
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
— install a mod, write a first one, enable and disable it.
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
— twelve dependency-ordered rungs, each a runnable mod.
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
— task-sized recipes.
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
— every registry, generated from `src/mods/Schemas.lua`.
```text
mods/example_mod/
├── manifest.json
└── main.lua
Regenerate the reference straight into a wiki checkout:
```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
`manifest.json`:
```json
{
"id": "example_mod",
"name": "Example Mod",
"version": "1.0.0",
"entry": "main.lua",
"priority": 0,
"dependencies": [],
"optional_dependencies": [],
"conflicts": []
}
```
`main.lua`:
```lua
return function(mod)
mod.log:info("hello from a native mod")
mod.content.pokemon:override("PIKACHU", {
name = "PIKACHU",
types = { "ELECTRIC" },
base_stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 },
})
mod.events:on("battle.start", function(context)
context.mod_message = "A native mod changed this battle."
end)
end
```
Mods should use registries and events instead of requiring private engine
modules. Registries currently cover Pokémon, moves, items, maps, tilesets,
encounters, trainers, sprites, music, audio, text, scripts, and UI.
Enablement is stored in the normal persistent `options.lua` file alongside
audio, display, and battle settings, so starting a new game does not disable
the selected mods. Changes take effect after restarting the game.
The loader deliberately does not import or execute arbitrary ROM-hack patches.
The supported content source remains the verified base Pokémon Red ROM plus
native mods.
+2 -1
View File
@@ -132,6 +132,7 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
+47 -12
View File
@@ -13,6 +13,19 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- coroutine that receives `Game` and yields once per
-- frame; used headless (xvfb) for scripted screenshots
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
local function scriptedIterations()
if not (autopilot or driverCo) then return 1 end
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
local function bootGame()
Game = require("src.core.Game")
Game:load()
@@ -24,6 +37,10 @@ local function bootGame()
local fn = assert(loadfile(driverPath))()
driverCo = coroutine.create(fn)
end
-- After the two above are known: a scripted run drives the multiplier
-- from love.update's loop, so the in-engine one must stay at 1 or the
-- two would compound (10x10 = 100 steps per observation).
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
end
function love.load(args)
@@ -33,6 +50,8 @@ function love.load(args)
editorMode = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then
speedOverride = tonumber(args[i + 1])
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
@@ -67,23 +86,36 @@ function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the
-- logic step. Fast-forwarding them by scaling the step inside
-- Game:update would run N steps per observation: a held direction walks
-- through all N, the player slides past the waypoint, and the script
-- re-plans from an overshot cell. So iterate the whole act+step loop
-- instead -- same script, just more of it per rendered frame.
local iterations = scriptedIterations()
if autopilot then
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
for _ = 1, iterations do
autopilot.update()
Game:update(1 / 60) -- deterministic stepping for the autopilot
end
return
end
if driverCo then
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
for _ = 1, iterations do
local ok, err = coroutine.resume(driverCo, Game)
if not ok then
print("driver error: " .. tostring(err))
love.event.quit(1)
return
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
end
if coroutine.status(driverCo) == "dead" then
love.event.quit()
return
end
Game:update(1 / 60)
return
end
Game:update(dt)
@@ -191,6 +223,9 @@ function love.quit()
if editorMode and EditorApp.quit then
return EditorApp.quit() -- return true to abort quit
end
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
end
function love.filedropped(file)
+29
View File
@@ -0,0 +1,29 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
--
-- Gallery entry #0, the legacy example. Its manifest stays api 1 with
-- category GAMEPLAY on purpose: it is the compatibility proof that a mod
-- written before manifest v2 keeps loading unchanged. The v2 examples
-- live in mods/examples/.
return {
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "beginner", "cosmetic", "data-only", "legacy" },
differences = {
changed = {
"the Oak's Lab starter gift becomes a level 20 Mew nicknamed HOGHEAD",
"MEW's front and back battle sprites point at inverted copies",
},
added = {},
known = {
"assets/ is empty until tools/generate_example_mod_sprite.py runs "
.. "against an imported cache",
"api 1: uses override where a v2 mod would use patch",
},
},
credits = {
{ who = "pret/pokered", for_ = "the Mew sprites the local generator inverts" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 1 },
}
+72
View File
@@ -0,0 +1,72 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
Copy the one closest to what you want to build.
| # | Mod | Persona | Category | What it does |
|---|---|---|---|---|
| 0 | [`../example_mew_starter`](../example_mew_starter) | (legacy) | `GAMEPLAY` | Oak's gift becomes a L20 Mew. The api-1 compatibility proof. |
| 1 | [`example_balance_tweaks`](example_balance_tweaks) | Tweaker | `BALANCE` | Faster starters, half-price TMs, a re-slotted Route 1 |
| 2 | [`example_shiny_palette`](example_shiny_palette) | Artist | `GRAPHICS` | A teal player recolor derived from your own cache |
| 3 | [`example_jukebox`](example_jukebox) | Musician | `AUDIO` | An authored chip song, a new cry, a jukebox screen |
| 4 | [`example_lost_parcel`](example_lost_parcel) | Quest author | `QUEST` | A two-town fetch quest over vanilla NPCs |
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
## None of these load by default
The engine discovers mods one level below `mods/`. The gallery lives one
level deeper, in `mods/examples/`, so a fresh install finds none of them
and the vanilla game is unchanged — the parity invariant holds by
construction.
To run one, copy it up a level:
```sh
cp -r mods/examples/example_balance_tweaks mods/
python3 tools/modkit.py validate mods/example_balance_tweaks --base imported
```
then enable it in `options.lua` (`mods = { example_balance_tweaks = true }`)
or toggle it in the F10 mod manager.
## Coverage
Between them the gallery writes into `pokemon`, `items`, `encounters`,
`maps`, `sprites`, `palettes`, `icons`, `music`, `cries`, `screens`,
`map_scripts`, `commands`, `tokens`, `statuses`, `rulesets`, `constants`
and `field`, and exercises:
- the write verbs `register`, `override` and `patch`, plus `get` and
`each` on the merged view
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
parallel scripts, `mod:` field routing, replaying an overridden base talk
handler through `MapScripts.baseTalk`, and the no-ROM-content posture
## What every entry has
```
mods/examples/<id>/
manifest.json api = 2, a category from the taxonomy, a semver engine range
main.lua the entry chunk
mod.card sharing metadata: summary, author, tags, differences, credits
README.md what it demonstrates, which persona, the commands to try it
CHANGELOG.md keep-a-changelog; headings match manifest.version
tests/ one runnable suite asserting the mod's stated effect
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- Base speed patches for VENUSAUR, CHARIZARD and BLASTOISE.
- TM price halving driven off `content.items:each()`.
- Route 1 grass re-slot.
@@ -0,0 +1,59 @@
# Balance Tweaks Example
Raises the final-stage Kanto starters to 100 base speed, halves every TM
price, and re-slots the Route 1 grass table — all without copying a single
record whole.
**Persona: the Tweaker.** A player who wants one number changed and copies
an example to get there. This is the shortest complete mod in the gallery
and the one to start from.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_balance_tweaks --base imported
luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
```
Then enable it: add `example_balance_tweaks = true` under `mods` in your
`options.lua`, or toggle it in the F10 mod manager.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.pokemon:patch` | `main.lua` — deep-merges one leaf, leaves the rest alone |
| `content.items:each` | `main.lua` — walks the merged view to find TMs instead of listing them |
| `content.encounters:patch` | `main.lua` — a list leaf replaces wholesale even inside a patch |
| `content.<r>:get` | `main.lua` — the guard that turns a missing id into a log line, not a crash |
`patch` is the point. The legacy `mods/example_mew_starter` has to copy
every field of Mew to change two sprite paths, because api 1 only had
`override`. With `patch` you name the leaf:
```lua
mod.content.pokemon:patch("VENUSAUR", { baseStats = { speed = 100 } })
```
Everything not named — `learnset`, `types`, `evolutions`, `spriteFront`
keeps its base value, and a second mod patching `baseStats.attack` on the
same species composes with this one instead of clobbering it.
## Exact changes
- `VENUSAUR` base speed 80 → 100
- `BLASTOISE` base speed 78 → 100
- `CHARIZARD` base speed 100 → 100 (already there; kept for symmetry)
- every item whose `machine.kind == "TM"` has its `price` halved
(`TM_TOXIC` 4000 → 2000, and 49 others; HMs carry `machine.kind == "HM"`
and are left alone)
- `ROUTE_1` grass `rate` 25 → 20, slot table re-weighted to include
`SPEAROW`
Field meanings are in the generated registry reference (`modkit docs`),
section `pokemon`, `items` and `encounters`.
## Credits
Base stat and mart price tables come from the player's own imported ROM;
this mod ships numbers, not data.
@@ -0,0 +1,44 @@
-- Gallery #1 (Tweaker): pure data, no engine seams. Everything here is
-- patch + each, so no record is ever copied whole and another mod editing
-- the same species keeps its own fields.
return function(mod)
-- patch deep-merges only the leaves it names: learnset, sprites, types
-- and evolutions all survive this speed change untouched
for _, id in ipairs({ "VENUSAUR", "CHARIZARD", "BLASTOISE" }) do
if mod.content.pokemon:get(id) then
mod.content.pokemon:patch(id, { baseStats = { speed = 100 } })
else
-- degrade instead of crashing: a species mod loaded ahead of this
-- one may have removed the vanilla starter line
mod.log:warn("%s missing from the merged view; speed patch skipped", id)
end
end
-- each() walks the merged view (engine records plus every mod ahead of
-- this one), so the TM list is discovered rather than hard-coded
local halved = 0
for id, item in mod.content.items:each() do
local machine = item.machine
if machine and machine.kind == "TM" and type(item.price) == "number"
and item.price > 0 then
mod.content.items:patch(id, { price = math.floor(item.price / 2) })
halved = halved + 1
end
end
mod.log:info("halved %d TM prices", halved)
-- lists replace wholesale even inside a patch, so a re-slotted encounter
-- table is written out in full while the rate rides along as a leaf
mod.content.encounters:patch("ROUTE_1", {
grass = {
rate = 20,
slots = {
{ level = 3, species = "PIDGEY" }, { level = 3, species = "RATTATA" },
{ level = 4, species = "SPEAROW" }, { level = 2, species = "RATTATA" },
{ level = 2, species = "PIDGEY" }, { level = 3, species = "SPEAROW" },
{ level = 3, species = "PIDGEY" }, { level = 4, species = "RATTATA" },
{ level = 4, species = "PIDGEY" }, { level = 5, species = "SPEAROW" },
},
},
})
end
@@ -0,0 +1,15 @@
{
"id": "example_balance_tweaks",
"name": "Balance Tweaks Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Tweaker gallery entry: patch and each over the merged view, with no whole-record copies."
}
@@ -0,0 +1,21 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "balance", "data-only", "beginner" },
differences = {
changed = {
"VENUSAUR and BLASTOISE base speed raised to 100 (CHARIZARD already was)",
"every TM item's price halved",
"Route 1 grass rate 25 -> 20 and SPEAROW added to the slot table",
},
added = {},
known = { "TM prices halve once per load, not once per install" },
},
credits = {
{ who = "pret/pokered", for_ = "the base stat and mart price tables this patches over" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,28 @@
-- Standalone: luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
-- Loads the mod through the real headless loader and asserts its stated
-- effect against the player's imported dataset.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_balance_tweaks", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.state, "loaded", "reached the loaded state")
T.eq(Data.pokemon.VENUSAUR.baseStats.speed, 100, "VENUSAUR speed patched")
T.eq(Data.pokemon.BLASTOISE.baseStats.speed, 100, "BLASTOISE speed patched")
-- the patch named one leaf, so everything else survived
T.check(#Data.pokemon.VENUSAUR.learnset > 0, "VENUSAUR keeps its learnset")
T.eq(Data.pokemon.VENUSAUR.types[1], "GRASS", "VENUSAUR keeps its types")
T.eq(Data.items.TM_TOXIC.price, 2000, "TM price halved")
T.eq(Data.items.POTION.price, 300, "a non-TM item is untouched")
T.eq(Data.encounters.ROUTE_1.grass.rate, 20, "Route 1 grass rate patched")
T.eq(Data.encounters.ROUTE_1.grass.slots[3].species, "SPEAROW",
"Route 1 slot table re-slotted")
run.release()
T.finish("example_balance_tweaks")
+12
View File
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- The `ExampleDexNav` screen and its START-menu row.
- `SORT BY` and `SHOW UNSEEN` mod options.
- `countSeen`, `countOwned` and `species` exports.
+88
View File
@@ -0,0 +1,88 @@
# DexNav Example
A START-menu overlay listing every species in the merged dex with its
seen/owned state, sortable, and publishing a small API other mods can call.
**Persona: the Tool Builder.** Consume the merged view, never `require` a
private module, expose a stable inter-mod surface. This example is the
reference for all three.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_dexnav --base imported
luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
```
Enable it (`example_dexnav = true` under `mods` in `options.lua`, or the
F10 manager), then press START → **DEXNAV**. Its two options live in the
manager's per-mod options pane.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.screens:register` | `main.lua` — a factory the engine instantiates by id |
| `hooks:wrap("ui.start_menu.items")` | `main.lua` — decorate, do not replace |
| `mod.ui.insertBefore` | `main.lua` — anchor on a label, not a row index |
| `mod.options:define` / `:get` | `main.lua` — auto-rendered rows in the manager |
| `mod.exports` | `main.lua` — the inter-mod API |
| `content.pokemon:each` | `main.lua` — the whole world, engine records included |
## Reading, not reaching
Every fact this mod displays comes from two public sources:
- `mod.content.pokemon:each()` — the merged species view. A tool that
hard-codes 151 breaks the moment another mod registers a species; this
one just gets longer.
- `game.save.pokedex` — the seen/owned tables, handed in by the engine.
No `require("src.pokemon.…")`, no permission declared, nothing that a later
refactor of an engine module can break.
## Anchoring a menu row
```lua
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
local out = next(game, items)
if type(out) ~= "table" then return out end
return mod.ui.insertBefore(out, "SAVE", { label = "DEXNAV", onSelect = ... })
end)
```
Two rules, both load-bearing:
1. **Call `next` first, then decorate what comes back.** Build a fresh list
instead and every other mod's row disappears.
2. **Anchor on a stable label, not an index.** `insertBefore` appends when
the anchor is missing, so the row is always reachable even in a total
conversion that renamed `SAVE`.
## Exporting an API
```lua
mod.exports.countSeen = function(game) ... end
```
Another mod reads it as:
```lua
local nav = mod.find("example_dexnav")
if nav then print(nav.exports.countSeen(game)) end
```
`mod.find` returns nil when the other mod is absent, disabled, failed, or
has not run yet — so a dependent degrades instead of crashing. Declare it
in `optional_dependencies` if you can live without it and `dependencies`
if you cannot.
## Empty state
`SHOW UNSEEN` off on a fresh save means an empty list. `ListMenu` draws
`Nothing here.` and B still exits — never a blank frame with no way out.
The title still reports `DEXNAV 0/0`, so the screen explains itself.
## Credits
- pret/pokered — the START-menu layout the row is anchored into.
+98
View File
@@ -0,0 +1,98 @@
-- Gallery #6 (Tool builder): a read-mostly overlay. Everything it knows
-- comes from the merged view through the public mod API -- no private
-- require, no engine table reached behind the loader's back -- and what it
-- knows is published as a stable export other mods can call.
local SCREEN = "ExampleDexNav"
return function(mod)
mod.options:define({
{ key = "sort", label = "SORT BY", type = "choice", default = "dex",
choices = { { "DEX NO.", "dex" }, { "NAME", "name" } } },
{ key = "unseen", label = "SHOW UNSEEN", type = "toggle", default = true },
})
-- ------- the merged view, read once per open
-- content.pokemon:each() yields the engine's species AND every mod's,
-- which is the whole point: a tool that hard-codes 151 breaks the moment
-- someone adds a species.
local function species()
local rows = {}
for id, mon in mod.content.pokemon:each() do
rows[#rows + 1] = { id = id, name = mon.name or id, dex = mon.dex or 9999 }
end
return rows
end
local function dexOf(game)
return (game and game.save and game.save.pokedex) or { seen = {}, owned = {} }
end
local function counts(game)
local dex = dexOf(game)
local seen, owned = 0, 0
for _, row in ipairs(species()) do
if dex.seen[row.id] then seen = seen + 1 end
if dex.owned[row.id] then owned = owned + 1 end
end
return seen, owned
end
-- ------- the inter-mod API
-- Another mod reads this with mod.find("example_dexnav").exports; it is
-- the supported way to depend on this one, and the reason nothing here
-- reaches into a private module.
mod.exports.countSeen = function(game) return (counts(game)) end
mod.exports.countOwned = function(game) return select(2, counts(game)) end
mod.exports.species = species
-- ------- the screen
mod.content.screens:register(SCREEN, {
new = function(game)
local dex = dexOf(game)
local showUnseen = mod.options:get("unseen")
local rows = species()
if mod.options:get("sort") == "name" then
table.sort(rows, function(a, b) return a.name < b.name end)
else
table.sort(rows, function(a, b)
if a.dex ~= b.dex then return a.dex < b.dex end
return a.id < b.id
end)
end
local items = {}
for _, row in ipairs(rows) do
local state = dex.owned[row.id] and "OWN"
or (dex.seen[row.id] and "SEEN" or "----")
if showUnseen or state ~= "----" then
items[#items + 1] = { label = row.name, right = state, value = row.id }
end
end
local seen, owned = counts(game)
-- ListMenu draws "Nothing here." for an empty set, so a brand new
-- save with SHOW UNSEEN off reads as a sentence, not a blank frame
return mod.ui.ListMenu.new(game,
("DEXNAV %d/%d"):format(owned, seen), items, {
pageJump = true,
onChoose = function(_, menu) menu:close() end,
})
end,
})
-- ------- reaching it
-- Call next() first, then decorate the list it returns: another mod's
-- row survives, and the vanilla rows are never rebuilt by hand.
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
local out = next(game, items)
if type(out) ~= "table" then return out end
return mod.ui.insertBefore(out, "SAVE", {
label = "DEXNAV",
onSelect = function() mod.ui.push(game, SCREEN) end,
})
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_dexnav",
"name": "DexNav Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Tool-builder gallery entry: a start-menu overlay over the merged dex, with mod options and a stable inter-mod export."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "ui", "tool", "quality-of-life" },
differences = {
changed = { "the START menu gains a DEXNAV row above SAVE" },
added = {
"ExampleDexNav screen",
"mod options: SORT BY and SHOW UNSEEN",
"exports countSeen / countOwned / species for other mods",
},
known = { "the list is built when the screen opens, so catching a mon mid-session needs a reopen" },
},
credits = {
{ who = "pret/pokered", for_ = "the start-menu layout the row is anchored into" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,54 @@
-- Standalone: luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
-- Exercises the export surface, the start-menu wrap and the screen factory.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local Font = require("src.render.Font")
Font.load(Data)
local run = T.sdk.loadMod("mods/examples/example_dexnav", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local exports = run.loader.exports.example_dexnav
T.check(type(exports.countSeen) == "function", "countSeen is exported")
T.check(type(exports.species) == "function", "species is exported")
T.eq(#exports.species(), 151, "the export reads the whole merged dex")
local game = {
data = Data,
save = { pokedex = { seen = { PIKACHU = true, MEW = true },
owned = { PIKACHU = true } } },
}
T.eq(exports.countSeen(game), 2, "countSeen counts the seen set")
T.eq(exports.countOwned(game), 1, "countOwned counts the owned set")
-- an empty dex is a legal state, not a crash
T.eq(exports.countSeen({ data = Data, save = {} }), 0, "a fresh save counts zero")
-- ------- the start-menu wrap decorates rather than replaces
local vanilla = { { label = "POKéDEX" }, { label = "SAVE" }, { label = "QUIT" } }
local hooked = Runtime.call("ui.start_menu.items",
function(_, items) return items end, game, vanilla)
T.eq(#hooked, 4, "the wrap added exactly one row")
T.eq(hooked[2].label, "DEXNAV", "the row is anchored before SAVE")
T.eq(hooked[3].label, "SAVE", "the vanilla rows are still in order")
-- ------- the screen factory builds a real state
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get(game, "ExampleDexNav")
T.check(factory and factory.new, "the screen resolves through the registry")
local screen = factory.new(game)
T.check(screen.items ~= nil and #screen.items == 151,
"the list shows every species with SHOW UNSEEN on")
T.eq(screen.title, "DEXNAV 1/2", "the title carries the owned/seen counts")
run.release()
Screens.invalidate()
T.finish("example_dexnav")
@@ -0,0 +1,13 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `Music_ExamplePalletRain`, a two-pulse loop authored in `song.lua`.
- A `music.select` wrap that swaps the Pallet Town map theme.
- An authored chip cry for MEW.
- The `ExampleJukebox` screen and its OPTIONS row.
+99
View File
@@ -0,0 +1,99 @@
# Jukebox Example
Adds one song authored note-by-note in Lua, swaps it in as the Pallet Town
theme, replaces Mew's cry, and ships a jukebox screen that lists every song
in the merged registry.
**Persona: the Musician.** Nothing here is a `.ogg`. The song is a
Game Boy channel program assembled at load time by `ChipAsm`.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_jukebox --base imported
luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
# render the song to a wav to hear it without launching the game
python3 tools/modkit.py bounce Music_ExamplePalletRain --seconds 20 --out bounce
```
Enable it (`example_jukebox = true` under `mods` in `options.lua`, or the
F10 manager), then open **OPTIONS → JUKEBOX**.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.music:register` (ChipAsm DSL) | `song.lua` + `main.lua` |
| `content.cries:override` (chip program) | `main.lua` |
| `hooks:wrap("music.select")` | `main.lua` — one choke point covers map, battle and jingle music |
| `content.screens:register` | `main.lua` — a screen factory the engine instantiates by id |
| `hooks:wrap("ui.options.rows")` | `main.lua` — how the player reaches the screen |
| `content.music:each` | `main.lua` — the jukebox list is the merged view, not a hard-coded array |
| `mod:read` | `main.lua` — loading a sibling file through the loader's filesystem |
## Authoring a song
`song.lua` returns what `ChipAsm.song{...}` builds: a self-contained
program blob plus its channel layout. Events are Lua tables, one per
command:
```lua
{ duty = 2 },
{ notetype = { speed = 12, volume = 11, fade = 2 } },
{ octave = 4 },
{ label = "lead" },
{ note = "E", len = 6 }, { note = "D", len = 2 },
{ loop = { count = 0, to = "lead" } },
```
The assembler is the validator. An unknown note name or an out-of-range
length raises *there*, naming the channel and event index, and `main.lua`
turns that into one mod-attributed load error. The music system never
latches on a bad program, because a bad program never reaches it.
Two shapes share the `music` registry and are dispatched per definition,
not by a global flag: `{ chip = ... }` for an authored program and
`{ file = "..." }` for an audio file. This example uses the first; a file
track is one line:
```lua
mod.content.music:register("Music_MyTheme", { file = mod.path .. "/theme.ogg" })
```
Note the cry uses `ChipAsm.sfx{...}.chip` — the assembler returns
`{ chip = program }`, and a cry record wants the program under its own
`chip` key.
## Deferring is the parity guarantee
```lua
mod.hooks:wrap("music.select", function(next, chosen, ctx)
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
return next(SONG_ID, ctx)
end
return next(chosen, ctx)
end)
```
Every path that is not Pallet Town calls `next(chosen, ctx)` with the
argument it was given, so playback everywhere else is exactly what it was
before the mod loaded.
## Empty state
The jukebox is a `ListMenu`, which draws `Nothing here.` when its item list
is empty rather than an empty frame. That cannot happen with the engine's
45 songs present, but it is the behavior a mod screen owes the player.
## Permissions
This mod declares `engine_internals`, because playing a song from a screen
currently needs `require("src.core.Music")` — the mod surface has no audio
playback facade yet. Declaring it is the honest path: `modkit validate`
accepts a declared require and flags an undeclared one.
## Credits
- Pallet Rain arrangement: this project.
- pret/pokered: the channel command set `ChipAsm` assembles to.
+96
View File
@@ -0,0 +1,96 @@
-- Gallery #3 (Musician): one authored chip song, one hook that swaps the
-- Pallet Town theme, one derived cry, and a jukebox screen that lists the
-- merged music registry.
local SONG_ID = "Music_ExamplePalletRain"
return function(mod)
-- the song lives in its own file; mod:read + load keeps it addressable
-- through the loader's filesystem instead of the host package.path, so
-- the mod works the same installed as it does in the repo
local source = mod:read("song.lua")
if not source then
mod.log:error("song.lua missing from %s -- reinstall the mod", mod.path)
return
end
local chunk, compileErr = load(source, "@" .. mod.path .. "/song.lua")
if not chunk then
mod.log:error("song.lua did not compile: %s", tostring(compileErr))
return
end
-- a malformed note table raises inside ChipAsm; catching it here turns
-- the whole mod into a mod-attributed load error instead of latching the
-- music system at playback time
local ok, song = pcall(chunk)
if not ok then
mod.log:error("song.lua failed to assemble: %s", tostring(song))
return
end
mod.content.music:register(SONG_ID, song)
-- a derived cry: the ChipAsm effect command set (channels 5-8), keyed by
-- species exactly like the vanilla cry table
mod.content.cries:override("MEW", {
-- .chip, not the whole return: ChipAsm hands back { chip = program }
-- and a cry record carries the program under its own chip key
chip = require("src.audio.ChipAsm").sfx{
channels = {
{ hw = 1, program = {
{ pitchSweep = { pace = 3, subtract = false, shift = 2 } },
{ squareNote = { len = 6, volume = 14, fade = 2, frequency = 0x5C0 } },
{ squareNote = { len = 8, volume = 12, fade = 3, frequency = 0x680 } },
} },
},
}.chip,
pitch = 128, length = 128,
})
-- music.select is the single choke point every song choice passes
-- through. Defer to next() for everything that is not the case this mod
-- cares about: with the mod installed but off the map, playback is
-- byte-for-byte what it was.
mod.hooks:wrap("music.select", function(next, chosen, ctx)
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
return next(SONG_ID, ctx)
end
return next(chosen, ctx)
end)
-- the jukebox itself: a screen factory in the screens registry
mod.content.screens:register("ExampleJukebox", {
new = function(game)
local ids = {}
for id in mod.content.music:each() do ids[#ids + 1] = id end
table.sort(ids)
local items = {}
for _, id in ipairs(ids) do
items[#items + 1] = { label = id:gsub("^Music_", ""), value = id }
end
-- ListMenu draws "Nothing here." on an empty set, so the empty state
-- is a sentence rather than a blank box
return mod.ui.ListMenu.new(game, "JUKEBOX", items, {
onChoose = function(item)
require("src.core.Music").play(game.data, item.value, true,
{ reason = "direct" })
end,
onCancel = function()
require("src.core.Music").stop()
end,
})
end,
})
-- reachable from OPTIONS; call next() first and decorate what comes back,
-- so every other mod's rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
local out = next(game, rows)
if type(out) ~= "table" then return out end
out[#out + 1] = {
id = "example_jukebox",
label = "JUKEBOX",
value = function() return "OPEN" end,
activate = function(g) mod.ui.push(g, "ExampleJukebox") end,
}
return out
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_jukebox",
"name": "Jukebox Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Musician gallery entry: an authored ChipAsm song, a music.select hook, a new cry and a jukebox screen."
}
+24
View File
@@ -0,0 +1,24 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "audio", "chiptune", "ui" },
differences = {
changed = {
"Pallet Town's map theme becomes Music_ExamplePalletRain",
"MEW's cry is replaced with an authored chip effect",
},
added = {
"Music_ExamplePalletRain song record",
"ExampleJukebox screen, reachable from the OPTIONS menu",
},
known = { "the jukebox plays looping songs only; jingles stop on their own" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the Pallet Rain arrangement" },
{ who = "pret/pokered", for_ = "the channel command set ChipAsm assembles to" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
+40
View File
@@ -0,0 +1,40 @@
-- "Pallet Rain": a two-pulse loop authored in the ChipAsm note-event DSL
-- (13-audio-modding.md). ChipAsm is on the loader's supported-require
-- list, so authoring a song needs no permissions.
--
-- The assembler is the validator: an out-of-range length or an unknown
-- note name raises here, at load, naming the channel and event index --
-- not silently at playback.
local ChipAsm = require("src.audio.ChipAsm")
return ChipAsm.song{
tempo = 0x120,
channels = {
-- lead: a four-bar descending figure that loops forever
{ hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 11, fade = 2 } },
{ octave = 4 },
{ label = "lead" },
{ note = "E", len = 6 }, { note = "D", len = 2 },
{ note = "C", len = 6 }, { rest = 2 },
{ note = "A", len = 4 }, { note = "G", len = 4 },
{ note = "C", len = 8 },
{ note = "E", len = 6 }, { note = "G", len = 2 },
{ note = "A", len = 8 },
{ rest = 8 },
{ loop = { count = 0, to = "lead" } },
} },
-- counter-line: same length, one octave down, softer
{ hw = 2, program = {
{ duty = 1 },
{ notetype = { speed = 12, volume = 7, fade = 1 } },
{ octave = 3 },
{ label = "bass" },
{ note = "C", len = 8 }, { note = "G", len = 8 },
{ note = "A", len = 8 }, { note = "F", len = 8 },
{ note = "C", len = 8 }, { note = "G", len = 8 },
{ loop = { count = 0, to = "bass" } },
} },
},
}
@@ -0,0 +1,70 @@
-- Standalone: luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
-- Asserts the song assembles, the cry merges, and music.select swaps only
-- the map this mod claims.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_jukebox", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
-- ------- the authored song
local song = Data.audio.songs.Music_ExamplePalletRain
T.check(type(song) == "table", "the song registered")
T.check(type(song.chip) == "table" and #song.chip.blob > 0,
"it assembled to a non-empty program blob")
T.eq(#song.chip.channels, 2, "both channels are laid out")
T.eq(song.chip.channels[1].address, 0x4000,
"the first channel is based at the 0x4000 window")
-- ------- the cry
local cry = Data.audio.cries.MEW
T.check(type(cry) == "table" and type(cry.chip) == "table",
"the MEW cry is an authored chip program")
T.check(#cry.chip.blob > 0, "the cry program is non-empty")
T.eq(cry.chip.channels[1].number, 5,
"an sfx program lives on the effect channels (5-8)")
-- ------- the hook swaps exactly one map
local function select(song_, ctx)
return Runtime.call("music.select", function(chosen) return chosen end, song_, ctx)
end
T.eq(select("Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }),
"Music_ExamplePalletRain", "Pallet Town gets the new theme")
T.eq(select("Music_Routes1", { reason = "map", mapId = "ROUTE_1" }),
"Music_Routes1", "every other map defers to the vanilla choice")
T.eq(select("Music_Battle", { reason = "battle", kind = "wild" }),
"Music_Battle", "battle music defers too")
T.eq(select("Music_PalletTown", nil), "Music_PalletTown",
"a direct play with no context defers")
-- ------- the screen and its options row
local Font = require("src.render.Font")
Font.load(Data)
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get({ data = Data }, "ExampleJukebox")
T.check(factory and factory.new, "the jukebox resolves through the screens registry")
local screen = factory.new({ data = Data })
T.check(#screen.items > 0, "the jukebox lists the merged music registry")
local listed = false
for _, item in ipairs(screen.items) do
if item.value == "Music_ExamplePalletRain" then listed = true end
end
T.check(listed, "the mod's own song is in the list")
local rows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
T.eq(#rows, 2, "the options hook added exactly one row")
T.eq(rows[2].id, "example_jukebox", "the row is the jukebox entry")
run.release()
Screens.invalidate()
T.finish("example_jukebox")
@@ -0,0 +1,15 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- The two-town fetch quest over `VIRIDIAN_CITY` and `PEWTER_CITY`.
- `EXAMPLE_LOST_PARCEL_PARCEL` key item and the `{EXAMPLE_PARCEL_REWARD}` token.
- The `example_lost_parcel:count_ask` verb and the ambient parallel script.
- `example_lost_parcel:base_nerd_chat`, which replays the Pewter super
nerd's base handler through `MapScripts.baseTalk` so the branches the
quest does not own keep the vanilla YES/NO conversation intact.
+131
View File
@@ -0,0 +1,131 @@
# The Lost Parcel
A courier in Viridian City dropped a parcel somewhere in Pewter City.
Fetch it and he pays you a NUGGET.
**Persona: the Quest Author.** Two towns, two vanilla NPCs, a branching
conversation, a key item, a reward and some ambience — and not one line of
map data or engine source changed.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_lost_parcel --base imported
luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
```
Enable it (`example_lost_parcel = true` under `mods` in `options.lua`, or
the F10 manager), then:
```
VIRIDIAN CITY PEWTER CITY
┌───────────────────┐ ┌───────────────────┐
│ GAMBLER ◀──────┼── accept ───┼──▶ SUPER NERD │
│ (quest giver) │ │ (has the │
│ ▲ │ │ parcel) │
└───────┼───────────┘ └─────────┬─────────┘
└──────────── return ───────────────┘
```
Talk to the gambler in Viridian (the one south of the Poké Mart), say
`SURE`, walk to Pewter, talk to the super nerd by the museum, walk back.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.map_scripts:register` (compose) | `main.lua` — two maps, no map edits |
| talk override on a real `TEXT_` constant | `main.lua``TEXT_VIRIDIANCITY_GAMBLER1`, `TEXT_PEWTERCITY_SUPER_NERD1` |
| `choice` + `label` + `jump_if_true/false` | `main.lua` — a five-branch conversation |
| `MOD_` flag convention | `main.lua``MOD_EXAMPLE_LOST_PARCEL_*` |
| `set_field "mod:key"` | `main.lua` — quest scratch state in `save.modData[mod.id]` |
| `mod.save:get/set` | `main.lua` — the same value through the loader-side namespace |
| `content.commands:register` (table form) | `main.lua` — a `foreground` verb of the mod's own |
| `MapScripts.baseTalk` (replay the overridden handler) | `main.lua``example_lost_parcel:base_nerd_chat` |
| `content.tokens:register` | `main.lua``{EXAMPLE_PARCEL_REWARD}` |
| `content.items:register` | `main.lua` — the parcel key item |
| a parallel ambient script | `main.lua``scripts.example_nerd_pace` + `onEnter` |
| `events:on` / `events:emit` | `main.lua` — announcing completion under `mod.<id>.*` |
## How the compose merge works
`map_scripts` is a **compose** registry, not a record registry. Registering
does not replace the engine's contribution for a map; it prepends to an
ordered chain, and each key composes by its own rule
(`09-scripting-and-quests.md` §4.4):
| key | rule |
|---|---|
| `talk`, `scripts` | single winner per name; `false` suppresses and falls through |
| `onEnter`, `onVictory`, `onBoulderMoved` | all contributions run, each `pcall`-guarded |
| `onStep`, `onInteract` | first truthy return consumes the step |
So this mod's `onEnter` for Pewter City runs *alongside* the engine's, not
instead of it. Its `talk` entry for `TEXT_PEWTERCITY_SUPER_NERD1` does win
outright — the engine's handler for that constant stops being dispatched
the moment this mod loads. Giving it back is the last branch's whole job:
```lua
{ "label", "vanilla" },
{ "example_lost_parcel:base_nerd_chat" },
```
```lua
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
base(ctx.game, ctx.overworld, ctx.npc, function() ctx.runner:resume() end)
ctx.runner:yield()
```
`MapScripts.baseTalk` reaches the engine handler still sitting behind the
override (`09-scripting-and-quests.md` §6) — the supported replacement for
re-wrapping it. A `{ "show_text", "TEXT_PEWTERCITY_SUPER_NERD1" }` row
*looks* like it does the same thing and does not: this NPC's base handler
is a Lua function that asks YES/NO and answers with one of two follow-ups,
while `show_text` resolves the constant to its opening line and stops. So
before the quest starts and after the parcel is taken, the player gets the
whole conversation they always got, choice included. The test drives both
answers, in both states, and asserts every line.
This is also why the manifest declares `engine_internals`: replaying a base
handler means requiring `src.script.MapScripts`.
## Flags, fields and mod state
Three storage routes, three jobs:
- **`MOD_`-prefixed flags** — the quest's public state machine. In the
normal flag namespace so `check_flag` works, prefixed so it can never
collide with a pokered event constant.
- **`set_field "mod:asked_count"`** — script-visible scratch state, routed
into `save.modData[mod.id]` by the owning contribution's attribution.
Two copies of a quest cannot collide on one key.
- **`mod.save:get/set`** — the same namespace from Lua, for code that is
not a script row.
## Verb metadata
The custom verb is registered in the table form:
```lua
mod.content.commands:register("example_lost_parcel:count_ask", {
foreground = true,
fn = function(ctx) ... end,
})
```
`foreground = true` marks it illegal inside a parallel script, so the
ambient runner can never touch quest state. Namespacing the verb with the
mod id keeps it from colliding with another mod's — `register` on a name
the engine already owns is an error, and replacing one requires `override`.
## Validation
Every row is checked against the merged command set once all entry chunks
have run. Typo a verb or jump to a label that does not exist and this mod
fails at *load* with the row number, is rolled back whole, and says so in
the manager — it never half-loads into a broken conversation.
## Credits
- pret/pokered — the `TEXT_` constants and base conversations this
composes with.
+172
View File
@@ -0,0 +1,172 @@
-- Gallery #4 (Quest author): the worked multi-map fetch quest from
-- 09-scripting-and-quests.md 6. A courier in Viridian City lost a parcel
-- in Pewter City; the player retrieves it for a NUGGET.
--
-- No map is edited and no engine file is touched. Both NPCs are vanilla
-- objects addressed by their real TEXT_ constants, and the base
-- conversation is still reachable on every branch the quest does not own.
local MapScripts = require("src.script.MapScripts")
local PARCEL = "EXAMPLE_LOST_PARCEL_PARCEL"
local REWARD = "NUGGET"
-- flags a mod writes are MOD_-prefixed by convention, so a save never
-- confuses them with the pokered event namespace
local STARTED = "MOD_EXAMPLE_LOST_PARCEL_STARTED"
local TAKEN = "MOD_EXAMPLE_LOST_PARCEL_TAKEN"
local DONE = "MOD_EXAMPLE_LOST_PARCEL_DONE"
-- the Pewter super nerd's object index on PEWTER_CITY; the ambient script
-- makes this one fidget while the parcel is still lying around
local NERD_INDEX = 3
return function(mod)
-- ------- the reward item
mod.content.items:register(PARCEL, {
id = PARCEL,
name = "PARCEL?",
price = 0,
keyItem = true,
tossable = false,
})
-- ------- a text token, so the reward name is written once
mod.content.tokens:register("EXAMPLE_PARCEL_REWARD", function(game)
local item = game and game.data and game.data.items[REWARD]
return item and item.name or REWARD
end)
-- ------- a script verb of this mod's own
-- The table form carries dispatch metadata: foreground marks it illegal
-- inside a parallel script, which is what keeps the ambient runner from
-- ever touching quest state.
mod.content.commands:register("example_lost_parcel:count_ask", {
foreground = true,
fn = function(ctx)
-- mod: fields route into save.modData[owner], so quest scratch state
-- is attributable and two quests never collide on one key
local base = ctx.save.modData and ctx.save.modData[mod.id]
local asked = (base and base.asked_count or 0) + 1
ctx.save.modData = ctx.save.modData or {}
ctx.save.modData[mod.id] = ctx.save.modData[mod.id] or {}
ctx.save.modData[mod.id].asked_count = asked
-- the same number through the loader-side namespace, which is what a
-- screen or another mod would read
mod.save:set("asked_count", asked)
end,
})
-- ------- handing a branch back to the base conversation
-- talk dispatch is single-winner, so the Pewter rows below replace the
-- engine's handler outright. Re-running the TEXT_ constant with
-- show_text would only replay its opening line: the base handler is a
-- Lua function that asks YES/NO and answers with one of two follow-ups,
-- and none of that survives a text lookup. baseTalk reaches the handler
-- still sitting behind the override (09 6), so the branches the quest
-- does not own play the whole vanilla conversation.
mod.content.commands:register("example_lost_parcel:base_nerd_chat", {
foreground = true,
fn = function(ctx)
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
if not base then return end
local runner = ctx.runner
base(ctx.game, ctx.overworld, ctx.npc, function() runner:resume() end)
runner:yield()
end,
})
-- ------- Viridian City: the quest giver
mod.content.map_scripts:register("VIRIDIAN_CITY", {
talk = {
TEXT_VIRIDIANCITY_GAMBLER1 = {
{ "check_flag", DONE },
{ "jump_if_true", "after" },
{ "check_flag", STARTED },
{ "jump_if_true", "pending" },
{ "show_text", "I dropped a parcel\nsomewhere in\nPEWTER CITY..." },
{ "choice", { "SURE", "NO WAY" } },
{ "jump_if_false", "refused" },
{ "set_flag", STARTED },
{ "set_field", "mod:asked_count", 0 },
{ "show_text", "Thanks! A {EXAMPLE_PARCEL_REWARD}\nawaits you!" },
{ "jump", "end" },
{ "label", "pending" },
{ "example_lost_parcel:count_ask" },
{ "check_item", PARCEL },
{ "jump_if_false", "remind" },
{ "take_item", PARCEL },
{ "give_item", REWARD },
{ "set_flag", DONE },
{ "emote", "player", "happy", 45 },
{ "show_text", "You found it!\nHere, as promised!" },
{ "jump", "end" },
{ "label", "remind" },
{ "show_text", "It's a small brown\nparcel. PEWTER CITY!" },
{ "jump", "end" },
{ "label", "refused" },
{ "show_text", "Aww. GYMs are\nclosed anyway..." },
{ "jump", "end" },
{ "label", "after" },
{ "show_text", "Thanks again,\n{PLAYER}!" },
},
},
})
-- ------- Pewter City: the parcel, and some ambience while it is lost
mod.content.map_scripts:register("PEWTER_CITY", {
talk = {
TEXT_PEWTERCITY_SUPER_NERD1 = {
{ "check_flag", STARTED },
{ "jump_if_false", "vanilla" },
{ "check_flag", TAKEN },
{ "jump_if_true", "vanilla" },
{ "show_text", "Someone dropped\nthis parcel by the\nMUSEUM." },
{ "give_item", PARCEL, 1, false },
{ "set_flag", TAKEN },
{ "show_text", "{PLAYER} got the\nparcel back!" },
{ "jump", "end" },
-- every branch the quest does not own replays the base handler, so
-- the vanilla conversation is never lost to the override
{ "label", "vanilla" },
{ "example_lost_parcel:base_nerd_chat" },
},
},
-- all-run: this composes with the engine's own onEnter for the map
-- instead of replacing it
onEnter = function(game, ow)
local flags = game.save and game.save.flags or {}
if flags[STARTED] and not flags[TAKEN] then
ow:queueScript({ { "run_parallel", "PEWTER_CITY/example_nerd_pace" } })
end
end,
scripts = {
-- background-legal verbs only; the runner rejects foreground rows in
-- a parallel slot, and the script dies on map exit
example_nerd_pace = {
{ "label", "top" },
{ "march_in_place", NERD_INDEX, true }, { "wait", 90 },
{ "march_in_place", NERD_INDEX, false }, { "wait", 150 },
{ "jump", "top" },
},
},
})
-- quest completion is worth announcing to other mods; a mod may only
-- broadcast under its own prefix
mod.events:on("flag.changed", function(ev)
if ev.name == DONE and ev.value then
mod.events:emit("mod.example_lost_parcel.completed", { reward = REWARD })
end
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_lost_parcel",
"name": "The Lost Parcel",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Quest-author gallery entry: a two-town fetch quest over real TEXT constants, with choices, labels, MOD_ flags and a parallel ambient script."
}
@@ -0,0 +1,27 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "quest", "story", "scripting" },
differences = {
changed = {
"the Viridian gambler and the Pewter super nerd gain quest branches; "
.. "their vanilla lines still play on every other branch",
},
added = {
"EXAMPLE_LOST_PARCEL_PARCEL key item",
"{EXAMPLE_PARCEL_REWARD} text token",
"example_lost_parcel:count_ask script verb",
"example_lost_parcel:base_nerd_chat script verb, which replays the "
.. "super nerd's base conversation the quest branches around",
"an ambient parallel script on PEWTER_CITY while the parcel is lost",
},
known = { "the parcel can be tossed from the bag; the quest then stalls at the reminder line" },
},
credits = {
{ who = "pret/pokered", for_ = "the TEXT_ constants and base conversations this composes with" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,163 @@
-- Standalone: luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
-- Plays the quest end to end headlessly: accept, fetch, hand over.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local Font = require("src.render.Font")
local ScriptRunner = require("src.script.ScriptRunner")
local MapScripts = require("src.script.MapScripts")
-- the engine attaches its hand-ported scripts as the base contribution at
-- boot; the quest composes on top of them, so the harness needs them too
require("data.scripts.init")
Font.load(Data)
local run = T.sdk.loadMod("mods/examples/example_lost_parcel", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.check(Data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "the parcel item merged")
T.check(Data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "the reward token merged")
T.check(Data.commands["example_lost_parcel:count_ask"] ~= nil,
"the mod's script verb merged")
-- a stack whose boxes are answered from OUTSIDE the coroutine: a text box
-- pushed by show_text resolves on the next drive tick, never re-entrantly
local choice = 1
local function newGame()
local game = { data = Data, save = {
flags = {}, inventory = {}, modData = {},
player = { name = "RED", rival = "BLUE" },
} }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
game.stack = stack
game.shown = {} -- first line of every text box, in order
game.asked = 0 -- YES/NO boxes the conversation put up
return game
end
-- advance one pending box or emote hold; choice menus pick `choice`
local function settle(game, ow)
if ow.emote then
local held = ow.emote
ow.emote = nil
held.onDone()
return true
end
local top = table.remove(game.stack.states)
if not top then return false end
if top.pages then
game.lastText = top.pages[1] and top.pages[1][1]
game.shown[#game.shown + 1] = game.lastText
end
if top.items then
local item = top.items[choice]
if item and item.onSelect then item.onSelect() end
elseif top.onChoose then
-- a bare ChoiceBox: what the engine's own Lua talk handlers ask with
game.asked = game.asked + 1
top.onChoose(choice == 1)
elseif top.onDone then
top.onDone()
end
return true
end
-- the first line of a generated text constant, which is what a TextBox
-- paginates onto its first row
local function firstLine(s) return (tostring(s):match("^[^\n\f\v]*")) end
-- the overworld the talk dispatch would supply; its map label is what
-- show_text resolves a bare TEXT_ constant through
local function overworldFor(mapId)
return { map = { id = mapId, def = { label = Data.maps[mapId].label } } }
end
local function talk(game, mapId, textConst)
local rows = MapScripts.talkScript(mapId, textConst)
T.check(rows ~= nil, mapId .. "." .. textConst .. " has a talk script")
local ow = overworldFor(mapId)
local runner = ScriptRunner.new(game, ow)
runner:run(rows, { source = MapScripts.talkSource(mapId, textConst) })
for _ = 1, 400 do
if not runner:isRunning() then break end
if not settle(game, ow) then runner:update() end
end
T.check(not runner:isRunning(), "the " .. textConst .. " script completed")
end
-- ------- branch 1: refuse the quest
do
choice = 2
local game = newGame()
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(not game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED,
"refusing the choice leaves the quest unstarted")
end
-- ------- branch 2: accept, fetch, deliver
local game = newGame()
choice = 1
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED, "accepting sets the started flag")
T.eq(game.save.modData.example_lost_parcel.asked_count, 0,
"set_field mod: wrote into the mod's own save namespace")
-- The override wins talk dispatch outright, so the branches the quest does
-- not own owe the player the whole base conversation -- which for this NPC
-- is an opening line, a YES/NO prompt and one of two follow-ups, not a
-- single line. Both answers are driven, before the quest starts and again
-- once the parcel is gone.
local NERD_INTRO = Data.text._PewterCitySuperNerd1DidYouCheckOutMuseumText
local NERD_YES = Data.text._PewterCitySuperNerd1WerentThoseFossilsAmazingText
local NERD_NO = Data.text._PewterCitySuperNerd1YouHaveToGoText
T.check(NERD_INTRO and NERD_YES and NERD_NO,
"the base super nerd conversation is in the generated text")
local function readsVanillaNerd(when, flags)
for _, answer in ipairs({ 1, 2 }) do
local plain = newGame()
for name, value in pairs(flags or {}) do plain.save.flags[name] = value end
choice = answer
talk(plain, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
T.check((plain.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
when .. ": the base branch never hands out the parcel")
T.eq(plain.asked, 1, when .. ": the vanilla YES/NO prompt still comes up")
T.eq(#plain.shown, 2, when .. ": the opening line and a follow-up both play")
T.eq(plain.shown[1], firstLine(NERD_INTRO), when .. ": the vanilla opening line")
T.eq(plain.shown[2], firstLine(answer == 1 and NERD_YES or NERD_NO),
when .. ": the follow-up answers the choice the player made")
end
end
readsVanillaNerd("before the quest")
readsVanillaNerd("after the parcel is taken", {
MOD_EXAMPLE_LOST_PARCEL_STARTED = true,
MOD_EXAMPLE_LOST_PARCEL_TAKEN = true,
})
choice = 1
talk(game, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_TAKEN, "the parcel is taken")
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) > 0,
"the parcel is in the bag")
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_DONE, "the quest completes")
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
"the parcel is consumed")
T.check((game.save.inventory.NUGGET or 0) > 0, "the NUGGET reward is paid")
T.eq(game.save.modData.example_lost_parcel.asked_count, 1,
"the mod's own verb counted the one pending visit")
-- the ambient script is background-legal: no foreground verb in it
local rows = MapScripts.namedScript("PEWTER_CITY", "example_nerd_pace")
T.check(rows ~= nil, "the parallel ambient script is registered")
run.release()
T.finish("example_lost_parcel")
@@ -0,0 +1,13 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- Three original species with cries and icons, and their art generator.
- The `SABLE_COVE` map and encounter table.
- `SABLE_TIDE_BADGE` and the one-badge constants override.
- The `SableTitle` screen and the `field.boot` override that reaches it.
@@ -0,0 +1,103 @@
# Sable Cove (Mini Conversion)
The smallest thing that is recognizably a *different game*: its own title
screen, its own starting town, its own three-species dex, its own single
badge — running on the same engine, with the same import.
**Persona: the Total-Conversion Team.** This is the capstone skeleton. It
is deliberately incomplete as a game and deliberately complete as a
demonstration of which seams a conversion owns.
## Legal callout (read this first)
A total conversion on this engine is a **recipe, not a redistribution**.
- The Red import still runs. It supplies the fallback infrastructure this
conversion sits on: the `OVERWORLD` tileset, the font, the move table,
the type chart. That data lives on the player's machine, decoded from
the player's own ROM.
- The conversion overrides on top. Everything under `assets/` here is
original work, plotted pixel by pixel by
`tools/make_assets.py` — run it yourself and diff the output.
- It never ships extracted content, and it never launders extracted
content into "new" species by transforming Red sprites. If your
conversion wants to *derive* art from the player's cache, that is what
`assets_transforms` is for — see
`mods/examples/example_shiny_palette/` for the worked pattern.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_mini_conversion --base imported
python3 tools/modkit.py lint mods/examples/example_mini_conversion
luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
# regenerate the original art from its shape tables
python3 mods/examples/example_mini_conversion/tools/make_assets.py
```
Enable it (`example_mini_conversion = true` under `mods` in `options.lua`,
or the F10 manager) and start the game. You land on the SABLE COVE title
screen; NEW GAME spawns you in Sable Cove with 1500 money as SABLE.
## What it demonstrates
| Seam | Where |
|---|---|
| `profile = "total_conversion"` | `manifest.json` — implies `affects_link` |
| `content.field:patch("boot", …)` | `main.lua` — spawn, names, money, boot screens |
| `content.constants:patch` / `:override` | `main.lua` — dex size, level cap, badge list |
| `content.pokemon:register` | `main.lua` — three species with full records |
| `content.cries:register` (ChipAsm) | `main.lua` — one authored effect per species |
| `content.icons:register` | `main.lua` — party icons keyed by species id |
| `content.maps:register` | `main.lua` — one map on the imported tileset |
| `content.encounters:register` | `main.lua` — its wild table |
| `content.items:register` | `main.lua` — the badge, which is an item |
| `content.screens:register` | `main.lua` — the title screen the boot config names |
| `events:on("game.ready")` | `main.lua` — checking the boot merge actually took |
## patch vs override on a deep registry
`constants` and `field` are **deep** registries. Two rules differ from the
record registries, and both bite a conversion:
1. `register` and `patch` are the same verb. A partial payload is the
normal case; only the keys you name move.
2. **Lists append.** That is deliberate — two mods each adding a badge both
land. But a conversion wants to *replace* the badge list, and appending
would leave Kanto's eight in front of its one:
```lua
mod.content.constants:patch("badges", { }) -- 8 + 1 = 9 badges
mod.content.constants:override("badges", { }) -- 1 badge
```
`override` is the verb that drops a list. The test asserts both behaviors.
`field.boot` is the opposite case: `patch` is right there, because the keys
this conversion does not name (`startFacing`, the `splash` and `newGame`
screen ids) should keep the engine's values.
## Priority
`"priority": 900`. A conversion wants to merge *after* content mods so its
`field.boot` and `constants` win. If another mod still beats it, the
`game.ready` listener says so by name instead of leaving the player on a
map they did not expect.
## What a real conversion adds next
This skeleton stops at the boundary of the mechanism demonstration. A
shipping conversion continues with:
- `map_scripts` for the story (see `mods/examples/example_lost_parcel/`)
- `maps:remove` / `pokemon:remove` tombstones to hide Kanto content
- `content.text` and `text_pointers` for its own dialogue
- `content.music` for its soundtrack (see `mods/examples/example_jukebox/`)
- `link_fields` and an honest `affects_link` so its players do not corrupt
each other's saves in a trade
## Credits
- All original sprite art: this project (`tools/make_assets.py`).
- pret/pokered: the `OVERWORLD` tileset, font and move table this builds on.
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

@@ -0,0 +1,180 @@
-- Gallery #7 (Total-conversion team): the smallest thing that is
-- recognizably a different game. Its own boot config, its own title
-- screen, its own three-species dex, its own single badge, and one map.
--
-- LEGAL POSTURE (constraint 1): the Red import still runs and supplies the
-- fallback infrastructure this conversion sits on -- the OVERWORLD tileset,
-- the font, the move table. The conversion overrides on top. Every pixel
-- under assets/ is original work generated by tools/make_assets.py; nothing
-- here transforms extracted Red art into "new" content.
local MAP = "SABLE_COVE"
local BADGE = "SABLE_TIDE_BADGE"
-- three original species; ids are namespaced so a mixed load cannot
-- collide with Red's
local DEX = {
{ id = "SABLE_EMBERKIT", name = "EMBERKIT", dex = 1, art = "emberkit",
types = { "FIRE" }, stats = { hp = 45, attack = 60, defense = 40,
speed = 65, special = 50 } },
{ id = "SABLE_TIDEPUP", name = "TIDEPUP", dex = 2, art = "tidepup",
types = { "WATER" }, stats = { hp = 50, attack = 48, defense = 60,
speed = 45, special = 55 } },
{ id = "SABLE_MOSSLING", name = "MOSSLING", dex = 3, art = "mossling",
types = { "GRASS", "POISON" }, stats = { hp = 55, attack = 50,
defense = 55, speed = 40,
special = 60 } },
}
return function(mod)
local ChipAsm = require("src.audio.ChipAsm")
-- ------- species, cries and icons
for _, entry in ipairs(DEX) do
mod.content.pokemon:register(entry.id, {
id = entry.id,
name = entry.name,
dex = entry.dex,
types = entry.types,
baseStats = entry.stats,
catchRate = 190,
baseExp = 64,
growthRate = "MEDIUM_FAST",
level1Moves = { "TACKLE" },
learnset = {
{ level = 7, move = "GROWL" },
{ level = 13, move = "QUICK_ATTACK" },
},
evolutions = {},
spriteFront = mod.path .. "/assets/" .. entry.art .. "_front.png",
spriteBack = mod.path .. "/assets/" .. entry.art .. "_back.png",
frontSize = 5,
cry = entry.id,
icon = { image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
frames = 2 },
})
-- one authored chip effect per species, keyed by species id exactly
-- like the vanilla cry table
mod.content.cries:register(entry.id, {
chip = ChipAsm.sfx{
channels = { { hw = 1, program = {
{ pitchSweep = { pace = 2 + entry.dex, subtract = entry.dex == 2,
shift = 3 } },
{ squareNote = { len = 5, volume = 13, fade = 2,
frequency = 0x480 + entry.dex * 0x60 } },
} } },
}.chip,
pitch = 128, length = 128,
})
mod.content.icons:register(entry.id, {
image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
frames = 2,
})
end
-- ------- the badge, which is an item like every vanilla badge
mod.content.items:register(BADGE, {
id = BADGE, name = "TIDEBADGE", price = 0, keyItem = true, tossable = false,
})
-- ------- the rules the engine used to hard-code
-- deep registry: register and patch are the same verb, and only the keys
-- named here move. Everything else keeps its imported value.
mod.content.constants:patch("dexSize", #DEX)
mod.content.constants:patch("dexDigits", 1)
mod.content.constants:patch("levelCap", 50)
-- override, not patch: under deep semantics a list APPENDS, so patching
-- here would leave Kanto's eight badges in front of this one. override
-- is the verb that drops a list, which is exactly what a conversion wants
mod.content.constants:override("badges", { { id = BADGE, name = "TIDE" } })
mod.content.constants:override("hmMoves", { "CUT", "SURF" })
-- ------- the one map
local blocks = {}
for i = 1, 10 * 9 do blocks[i] = 1 end
mod.content.maps:register(MAP, {
id = MAP,
label = "SableCove",
index = 1000,
tileset = "OVERWORLD",
width = 10, height = 9,
blocks = blocks,
borderBlock = 11,
warps = {}, objects = {}, signs = {},
})
mod.content.encounters:register(MAP, {
grass = { rate = 25, slots = {
{ level = 3, species = "SABLE_EMBERKIT" },
{ level = 3, species = "SABLE_TIDEPUP" },
{ level = 3, species = "SABLE_MOSSLING" },
{ level = 4, species = "SABLE_TIDEPUP" },
{ level = 4, species = "SABLE_MOSSLING" },
{ level = 5, species = "SABLE_EMBERKIT" },
{ level = 5, species = "SABLE_TIDEPUP" },
{ level = 5, species = "SABLE_MOSSLING" },
{ level = 6, species = "SABLE_EMBERKIT" },
{ level = 6, species = "SABLE_MOSSLING" },
} },
})
-- ------- the new game itself
mod.content.field:patch("boot", {
startMap = MAP, startX = 5, startY = 4, startFacing = "down",
playerName = "SABLE", rivalName = "CORAL",
startMoney = 1500,
lastHeal = { map = MAP, x = 5, y = 4 },
namePresets = { player = { "SABLE", "WREN", "PIKE" },
rival = { "CORAL", "REEF", "SHOAL" } },
-- the conversion owns the boot flow; splash and newGame keep the
-- engine screens, which is the point of naming them individually
screens = { title = "SableTitle" },
})
-- ------- the title screen
mod.content.screens:register("SableTitle", {
new = function(game, opts)
opts = opts or {}
local Font = mod.ui.Font
local state = { game = game, isOpaque = true, blink = 0 }
function state:update()
self.blink = (self.blink + 1) % 60
local input = game.input
if not input then return end
if input:wasPressed("a") or input:wasPressed("start") then
if opts.onNewGame then opts.onNewGame() end
end
end
function state:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("SABLE COVE", 40, 40)
Font.draw("A MINI CONVERSION", 12, 56)
if self.blink < 40 then Font.draw("PRESS A", 52, 104) end
end
return state
end,
})
mod.events:on("game.ready", function(ev)
-- the payload carries the live Game; a conversion uses it to check
-- that its own boot config actually took
local boot = ev.game and ev.game.data and ev.game.data.field
and ev.game.data.field.boot
if not (boot and boot.startMap == MAP) then
mod.log:warn("another mod owns field.boot; raise this mod's priority "
.. "above %s to win the merge", tostring(boot and boot.startMap))
end
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_mini_conversion",
"name": "Sable Cove (Mini Conversion)",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=1.0.0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Total-conversion gallery entry: a one-town game with its own boot, title, three-species dex and one badge."
}
@@ -0,0 +1,31 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "total-conversion", "capstone" },
differences = {
changed = {
"field.boot spawns on SABLE_COVE as SABLE, rival CORAL, 1500 money",
"constants.dexSize 151 -> 3, dexDigits 3 -> 1, levelCap 100 -> 50",
"constants.badges replaced with one TIDEBADGE",
"constants.hmMoves replaced with CUT and SURF",
"the title screen is SableTitle",
},
added = {
"SABLE_EMBERKIT, SABLE_TIDEPUP and SABLE_MOSSLING with cries and icons",
"SABLE_TIDE_BADGE key item",
"the SABLE_COVE map and its encounter table",
},
known = {
"no gym, no story and no warps yet: this is the skeleton the TC guide elaborates",
"Red's maps and species stay merged and reachable; a real conversion removes them",
},
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "all original sprite art under assets/" },
{ who = "pret/pokered", for_ = "the OVERWORLD tileset, font and move table this builds on" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,70 @@
-- Standalone: luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
-- Asserts the conversion owns the boot flow, the dex and the badge list.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_mini_conversion", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.manifest.profile, "total_conversion",
"the manifest declares the total_conversion profile")
-- ------- boot: the new game the conversion starts
local boot = Data.field.boot
T.eq(boot.startMap, "SABLE_COVE", "boot spawns on the conversion's own map")
T.eq(boot.playerName, "SABLE", "boot renames the player")
T.eq(boot.screens.title, "SableTitle", "the conversion owns the title screen")
-- patch is a merge: the keys the conversion did not name keep their values
T.eq(boot.startFacing, "down", "an unnamed boot key survives the patch")
-- ------- the dex
T.eq(Data.constants.dexSize, 3, "the dex shrinks to three species")
T.eq(#Data.constants.badges, 1, "one badge replaces the eight")
T.eq(Data.constants.badges[1].id, "SABLE_TIDE_BADGE", "the badge is the mod's item")
T.check(Data.items.SABLE_TIDE_BADGE ~= nil, "the badge item merged")
-- constants is a deep registry, so untouched keys keep their imported value
T.eq(Data.constants.partyMax, 6, "an unpatched constant is unchanged")
-- and a deep list REPLACES only because the mod said override
T.eq(#Data.constants.hmMoves, 2, "override drops a deep list instead of appending")
for _, id in ipairs({ "SABLE_EMBERKIT", "SABLE_TIDEPUP", "SABLE_MOSSLING" }) do
local mon = Data.pokemon[id]
T.check(mon ~= nil, id .. " merged into the species table")
T.check(Data.audio.cries[id] ~= nil, id .. " has a cry")
T.check(Data.icons.bySpecies[id] ~= nil, id .. " has an icon")
-- the art it points at really exists, not a path into the void. Read it
-- back through the loader's own filesystem: mod.path is whatever the
-- loader mounted the mod at, which is not the repo-relative directory
for _, path in ipairs({ mon.spriteFront, mon.spriteBack }) do
T.check(run.loader.fs.getInfo(path) ~= nil,
"sprite exists: " .. tostring(path))
end
end
-- ------- the map and its encounters
local map = Data.maps.SABLE_COVE
T.check(map ~= nil, "the conversion's map merged")
T.eq(#map.blocks, map.width * map.height, "the block array matches the map size")
T.eq(#Data.encounters.SABLE_COVE.grass.slots, 10, "the map has a full slot table")
-- ------- the title screen resolves through the registry
local Screens = require("src.ui.Screens")
Screens.invalidate()
local factory = Screens.get({ data = Data }, "SableTitle")
T.check(factory and factory.new, "SableTitle resolves through the screens registry")
local reached = false
local state = factory.new({ data = Data, input = {
wasPressed = function(_, key) return key == "a" end } },
{ onNewGame = function() reached = true end })
state:update()
T.check(reached, "pressing A on the title starts a new game")
run.release()
Screens.invalidate()
T.finish("example_mini_conversion")
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Regenerate this mod's original sprite art.
python3 mods/examples/example_mini_conversion/tools/make_assets.py
Every pixel below is plotted from the shape tables in this file, so the
output is original work and nothing is read from the player's imported
cache. The four colors are the Game Boy shade ramp; the renderer
re-shades them into the active palette, so no trueColor opt-out is needed.
Front sheets are 40x40 (frontSize 5), backs 32x32 and icons 16x32
(two 16x16 frames), matching what the battle and party screens expect.
"""
import os
from PIL import Image
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
# lightest first; index 0 is the transparent-white background
SHADES = [(248, 248, 248), (168, 168, 168), (88, 88, 88), (8, 8, 8)]
# Each species is a coarse silhouette painted from primitives: the point is
# that these are geometric marks, not creature art traced from anything.
SPECIES = {
"emberkit": {"body": "triangle", "accent": 1},
"tidepup": {"body": "diamond", "accent": 1},
"mossling": {"body": "hex", "accent": 2},
}
def blank(w, h):
return [[0] * w for _ in range(h)]
def stroke(grid, x, y, shade):
if 0 <= y < len(grid) and 0 <= x < len(grid[0]):
grid[y][x] = shade
def triangle(grid, cx, cy, r, shade):
for row in range(r * 2):
half = row // 2
for x in range(cx - half, cx + half + 1):
stroke(grid, x, cy - r + row, shade)
def diamond(grid, cx, cy, r, shade):
for dy in range(-r, r + 1):
span = r - abs(dy)
for dx in range(-span, span + 1):
stroke(grid, cx + dx, cy + dy, shade)
def hexagon(grid, cx, cy, r, shade):
for dy in range(-r, r + 1):
span = r if abs(dy) <= r // 2 else r - (abs(dy) - r // 2)
for dx in range(-span, span + 1):
stroke(grid, cx + dx, cy + dy, shade)
SHAPES = {"triangle": triangle, "diamond": diamond, "hex": hexagon}
def outline(grid, shade):
"""Darken every lit pixel that touches an unlit one."""
h, w = len(grid), len(grid[0])
edges = []
for y in range(h):
for x in range(w):
if not grid[y][x]:
continue
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = x + dx, y + dy
if not (0 <= nx < w and 0 <= ny < h) or not grid[ny][nx]:
edges.append((x, y))
break
for x, y in edges:
grid[y][x] = shade
def save(grid, path):
h, w = len(grid), len(grid[0])
img = Image.new("RGBA", (w, h))
img.putdata([SHADES[grid[y][x]] + (255,)
for y in range(h) for x in range(w)])
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
print("wrote", os.path.relpath(path))
def build(name, spec):
shape = SHAPES[spec["body"]]
accent = spec["accent"]
front = blank(40, 40)
shape(front, 20, 22, 13, accent)
shape(front, 20, 12, 5, 2)
for x in (16, 24):
stroke(front, x, 11, 3)
stroke(front, x, 12, 3)
outline(front, 3)
save(front, os.path.join(ROOT, name + "_front.png"))
back = blank(32, 32)
shape(back, 16, 20, 11, accent)
outline(back, 3)
save(back, os.path.join(ROOT, name + "_back.png"))
# two 16x16 frames stacked: the party-menu bob
icon = blank(16, 32)
for frame, lift in enumerate((0, 1)):
shape(icon, 8, 9 + frame * 16 - lift, 5, accent)
outline(icon, 3)
save(icon, os.path.join(ROOT, name + "_icon.png"))
if __name__ == "__main__":
for name, spec in sorted(SPECIES.items()):
build(name, spec)
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `transforms.lua` recolor of the player overworld sheets.
- `EXAMPLE_SHINY` palette record and a `PALLET` town palette override.
- `trueColor` opt-out patches for `SPRITE_RED` and `SPRITE_RED_BIKE`.
@@ -0,0 +1,78 @@
# Shiny Palette Example
Recolors the player's overworld sheets to teal and repaints Pallet Town —
and ships no ROM-derived pixels to do it.
**Persona: the Artist.** This is the canonical answer to "how do I ship a
recolor legally": you ship the *transform*, not the image.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_shiny_palette --base imported
python3 tools/modkit.py lint mods/examples/example_shiny_palette
luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
```
Enable it (`example_shiny_palette = true` under `mods` in `options.lua`, or
the F10 manager) and start the game. On first load the transform runs once
and writes `save/mod-derived/example_shiny_palette/sprites/*.png`. Delete
that directory to force a re-run.
## What it demonstrates
| Seam | Where |
|---|---|
| `assets_transforms` | `manifest.json` + `transforms.lua` — the recipe that derives art |
| `content.palettes:register` | `main.lua` — the v2 named-record palette shape |
| `content.palettes:override` | `main.lua` — the vanilla four-triple shape |
| `content.sprites:patch` | `main.lua``trueColor` opt-out, nothing else touched |
| `events:on("assets.transformed")` | `main.lua` — the empty-state warning |
## The legal pattern
`transforms.lua` runs inside a restricted context with exactly two
filesystem roots: read `assets/generated/**` (the player's own imported
cache) and write `save/mod-derived/example_shiny_palette/**`. There is no
`require`, no `love`, no `io`, no `os`. The only way data leaves the
sandbox is the `ctx` table.
Because the derived file keeps the *same relative name* as the cache file
it came from, the asset resolver finds it automatically:
```
assets/generated/sprites/red.png <- the player's import
save/mod-derived/example_shiny_palette/sprites/red.png <- this mod's recolor
```
Every consumer of the first path transparently gets the second. No
`sprites:override`, no path string in `main.lua` — and `modkit lint` can
prove the repo carries no cache-derived bytes, because it carries no bytes
at all.
The one thing that *does* need a registry entry is the 4-shade contract.
The renderer normally re-shades an overworld sheet into the current
palette's four grays, which would throw the teal away. `trueColor = true`
opts out:
```lua
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
```
`patch`, not `override`: `image`, `frames` and `walker` stay whatever the
merged view already holds, so the derived sheet keeps supplying the pixels.
## Empty state
No ROM imported yet? `ctx.exists(rel)` is false, the transform writes
nothing, the mod still loads, and `main.lua` logs a remediation line naming
the directory to delete once you have imported. It never errors.
## Original assets
`assets/accent_sparkle.png` is a 16x16 four-shade sparkle drawn for this
example. It is the only image in the directory and it is original work.
## Credits
- pret/pokered — the overworld sheet layout the transform recolors.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

@@ -0,0 +1,37 @@
-- Gallery #2 (Artist): a recolor that ships no pixels. transforms.lua
-- derives the sheets from the player's own cache; this file only declares
-- the palette records and the one flag the recolor needs.
return function(mod)
-- v2 record shape: a named table of four colors, lightest first
mod.content.palettes:register("EXAMPLE_SHINY", {
colors = {
{ r = 248, g = 248, b = 248 },
{ r = 120, g = 224, b = 216 },
{ r = 32, g = 128, b = 152 },
{ r = 8, g = 32, b = 64 },
},
})
-- vanilla raw shape: four {r,g,b} triples. Overriding a town palette is
-- the smallest visible artist change there is -- no assets involved.
mod.content.palettes:override("PALLET", {
{ 248, 248, 248 }, { 152, 232, 224 }, { 64, 152, 168 }, { 8, 32, 64 },
})
-- trueColor opts SPRITE_RED out of the 4-shade re-shade so the teal the
-- transform baked in survives to the screen. patch, not override: the
-- image path and frame count stay whatever the merged view already has,
-- which is how the derived sheet keeps supplying the pixels.
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
mod.content.sprites:patch("SPRITE_RED_BIKE", { trueColor = true })
mod.events:on("assets.transformed", function(ev)
if ev.modId ~= mod.id then return end
if ev.count == 0 then
mod.log:warn("no sheets derived -- import your ROM first, then "
.. "delete save/mod-derived/%s to re-run the transform", mod.id)
else
mod.log:info("derived %d recolored sheets", ev.count)
end
end)
end
@@ -0,0 +1,16 @@
{
"id": "example_shiny_palette",
"name": "Shiny Palette Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"assets_transforms": "transforms.lua",
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Artist gallery entry: a recolored player sheet derived from the player's own cache, plus two palette records."
}
@@ -0,0 +1,26 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "A teal player recolor derived from your own cache, plus two palette records.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "cosmetic", "graphics", "beginner" },
screenshots = {
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
},
differences = {
changed = {
"SPRITE_RED and SPRITE_RED_BIKE opt into trueColor",
"the PALLET town palette is recolored",
},
added = { "EXAMPLE_SHINY palette record" },
known = {
"the derived sheets only appear after a ROM import; without a cache "
.. "the transform writes nothing and the vanilla sheets keep rendering",
},
},
credits = {
{ who = "pret/pokered", for_ = "the overworld sheet layout the transform recolors" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,108 @@
-- Standalone: luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
-- Asserts the palette records merge and the transform degrades cleanly
-- when there is no imported cache to read.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = require("src.core.Data")
Data:load()
-- The transform reads assets/generated/** and writes save/mod-derived/**.
-- Hiding the cache is what puts this run on the no-cache path, which is
-- the branch a mod owes the player: write nothing, load anyway. The pixel
-- path needs a real LOVE run (love.image is not in the headless stub).
local MOD = "mods/examples/example_shiny_palette"
local function noCacheFs()
local inner = T.fs.new(".")
local overlay = {}
local hidden = "assets/generated/"
local mount = "mods/example_shiny_palette"
local function map(path)
if path == mount then return MOD end
if path and path:sub(1, #mount + 1) == mount .. "/" then
return MOD .. path:sub(#mount + 1)
end
return path
end
local fs = { root = inner.root }
function fs.read(path)
if path:sub(1, #hidden) == hidden then return nil end
return overlay[path] or inner.read(map(path))
end
function fs.write(path, body) overlay[path] = body return true end
function fs.createDirectory() return true end
function fs.load(path) return inner.load(map(path)) end
function fs.getInfo(path)
if path == "mods" then return { type = "directory" } end
if path:sub(1, #hidden) == hidden then return nil end
if overlay[path] then return { type = "file" } end
return inner.getInfo(map(path))
end
function fs.getDirectoryItems(path)
if path == "mods" then return { "example_shiny_palette" } end
return inner.getDirectoryItems(map(path))
end
return fs
end
local run = T.sdk.loadMod(MOD, { data = Data, fs = noCacheFs() })
T.eq(#run.errors, 0,
"loads clean with no cache to transform (" .. tostring(run.errors[1]) .. ")")
T.eq(run.mod and run.mod.manifest.assets_transforms, "transforms.lua",
"the manifest declares its transform")
-- ------- palettes
local shiny = Data.palettes.palettes.EXAMPLE_SHINY
T.check(shiny ~= nil, "the v2 named palette record merged")
T.eq(#shiny.colors, 4, "it carries exactly four colors")
T.eq(shiny.colors[1].r, 248, "the lightest shade is first")
local pallet = Data.palettes.palettes.PALLET
T.eq(#pallet, 4, "the town palette override kept the raw four-triple shape")
T.eq(pallet[2][2], 232, "the override took")
-- ------- the trueColor opt-out, applied by patch
for _, id in ipairs({ "SPRITE_RED", "SPRITE_RED_BIKE" }) do
local sprite = Data.sprites[id]
T.eq(sprite.trueColor, true, id .. " opted into trueColor")
T.check(sprite.image ~= nil and sprite.image ~= "",
id .. " kept its sheet path (patch named only the flag)")
T.check(sprite.frames ~= nil, id .. " kept its frame count")
end
-- ------- the recipe itself compiles and is a function(ctx)
local chunk = assert(loadfile(MOD .. "/transforms.lua"))
local transform = chunk()
T.check(type(transform) == "function", "transforms.lua returns a function(ctx)")
-- driven with an empty cache it must write nothing and not raise
local wrote = 0
local ok, err = pcall(transform, {
exists = function() return false end,
readImage = function() error("must not read without exists()", 0) end,
writeImage = function() wrote = wrote + 1 end,
recolor = function(img) return img end,
})
T.check(ok, "the recipe survives an empty cache (" .. tostring(err) .. ")")
T.eq(wrote, 0, "and writes nothing rather than failing the mod")
-- with a cache present it derives one file per declared sheet
wrote = 0
local read = {}
T.check(pcall(transform, {
exists = function() return true end,
readImage = function(rel) read[#read + 1] = rel return { rel } end,
writeImage = function() wrote = wrote + 1 end,
recolor = function(img) return img end,
}), "the recipe runs over a populated cache")
T.eq(wrote, #read, "every sheet it read, it wrote back")
T.check(wrote >= 1, "at least one sheet is derived")
run.release()
T.finish("example_shiny_palette")
@@ -0,0 +1,32 @@
-- Asset transform: the whole point of this example. It runs once at
-- install inside the restricted context -- read the player's own imported
-- cache, write under save/mod-derived/<id>/ -- so the repo ships the
-- recipe and never a ROM-derived pixel.
--
-- The derived path mirrors the cache path, so Assets.resolve picks it up
-- for every consumer of assets/generated/sprites/red.png with no registry
-- entry at all. A recolor is exactly this: read, recolor, write back
-- under the same relative name.
local SHEETS = {
"sprites/red.png",
"sprites/red_bike.png",
}
-- lightest shade first; the recolor buckets every ink pixel into one of
-- these four by luminance, matching the importer's own 4-gray split
local TEAL = {
{ 248, 248, 248 },
{ 120, 224, 216 },
{ 32, 128, 152 },
{ 8, 32, 64 },
}
return function(ctx)
for _, rel in ipairs(SHEETS) do
-- a player who has not imported yet simply gets no derived art; the
-- vanilla sheet keeps rendering and the mod stays loaded
if ctx.exists(rel) then
ctx.writeImage(ctx.recolor(ctx.readImage(rel), TEAL), rel)
end
end
end
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `EXAMPLE_RAIN` status record and the `example_weather_battles` ruleset.
- A `battle.damage` wrap that scales WATER and FIRE while it rains.
- Rain lifecycle driven by `battle.started` / `battle.turn_started` / `battle.ended`.
+96
View File
@@ -0,0 +1,96 @@
# Weather Battles Example
Adds rain: for the first five turns of every battle, WATER moves deal 1.5x
damage and FIRE moves deal 0.5x. Only under the `WEATHER` ruleset — pick
`gen1_faithful` and the mod is installed and inert.
**Persona: the Mechanic Designer.** A new battle mechanic, no engine fork.
The status and ruleset registries plus one hook carry the whole thing.
## Try it
```sh
python3 tools/modkit.py validate mods/examples/example_weather --base imported
luajit mods/examples/example_weather/tests/example_weather_test.lua
```
Enable it (`example_weather = true` under `mods` in `options.lua`, or the
F10 manager), then **OPTIONS → RULESET → WEATHER**.
## What it demonstrates
| Seam | Where |
|---|---|
| `content.statuses:register` | `main.lua` — declaring a field effect |
| `content.rulesets:register` | `main.lua` — a ruleset the OPTIONS menu lists automatically |
| `content.rulesets:get` | `main.lua` — deriving from vanilla without requiring a private module |
| `hooks:wrap("battle.damage")` | `main.lua` — the one behavior change |
| `events:on("battle.started" / "battle.turn_started" / "battle.ended")` | `main.lua` — the rain counter |
| `mod.save:get/set` | `main.lua` — per-mod state, not a global |
## Parity, at the mod level
The engine's promise is that a mod-free game is unchanged. This example
makes the same promise one level up: a *player* who installs it but does
not select the ruleset gets vanilla battles.
```lua
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
return next(ctx)
end
```
`next(ctx)` with the arguments it was handed *is* the vanilla call. No
allocation, no rounding, no reordering — the same number the engine would
have produced. Everything above that line is a gate, and every gate that
fails defers.
## Preserving multiple returns
`Damage.compute` returns two values: the damage number and an info table
carrying the crit flag and the type multiplier. A wrapper that returns only
the first silently throws the second away, and the battle log stops saying
"A critical hit!".
```lua
local damage, info = next(ctx)
if type(damage) ~= "number" then return damage, info end
return math.max(1, math.floor(damage * scale)), info
```
Hook chains preserve every return value, so passing `info` back through is
all it takes.
## Deriving a ruleset from vanilla
A ruleset record is the *whole* rule table — `oneIn256Miss`,
`critUsesBaseSpeed`, `randMin`, `randMax` and the rest. Registering one
that only sets `name` would silently drop every Gen 1 quirk. So this mod
reads the vanilla record out of the merged registry and copies it:
```lua
local base = mod.content.rulesets:get("gen1_faithful")
local weather = {}
for key, value in pairs(base) do weather[key] = value end
weather.name = "WEATHER"
weather.exampleWeather = true
```
`:get` on a registry is the public path to engine content. It needs no
permission, and it composes: if another mod patched `gen1_faithful` first,
this ruleset inherits that patch too.
`exampleWeather` is not in the ruleset schema. Unknown fields on a record
registry are preserved rather than rejected — that is what makes rulesets
extensible, and it is how the damage hook recognizes its own ruleset
without a second lookup.
## Missing dependency, handled
If another mod removed `gen1_faithful`, `:get` returns nil. This example
logs a remediation line and returns — the rest of the game keeps working
and the manager shows one attributed message. No `assert`, no crash.
## Credits
- pret/pokered — the damage formula the hook scales.
+86
View File
@@ -0,0 +1,86 @@
-- Gallery #5 (Mechanic designer): a new battle mechanic with no engine
-- fork. A rain field effect scales WATER and FIRE damage, lives behind an
-- opt-in ruleset, and keeps its own counter in mod.save.
--
-- The parity lesson is the ruleset gate: install this mod, leave the
-- ruleset on gen1_faithful, and every battle is byte-for-byte vanilla
-- because the hook returns next(...) untouched.
local RULESET = "example_weather_battles"
local RAIN_TURNS = 5
local BOOST = { WATER = 1.5 }
local DAMPEN = { FIRE = 0.5 }
return function(mod)
-- ------- the field effect, declared as a status record
mod.content.statuses:register("EXAMPLE_RAIN", {
id = "EXAMPLE_RAIN",
label = "RAIN",
hudLabel = "RAIN",
-- a field effect is never inflicted on a battler; the record is the
-- declaration the HUD and other mods read, the hook is the behavior
canInflict = function() return false end,
})
-- ------- the ruleset that turns it on
-- Read the vanilla record out of the merged registry rather than
-- requiring the module: same table, no engine_internals permission.
local base = mod.content.rulesets:get("gen1_faithful")
if not base then
mod.log:error("gen1_faithful missing from the rulesets registry; "
.. "another mod removed it, so %s cannot be derived", RULESET)
return
end
local weather = {}
for key, value in pairs(base) do weather[key] = value end
weather.name = "WEATHER"
-- the marker the damage hook gates on; unknown fields ride through the
-- schema untouched, which is what makes rulesets extensible
weather.exampleWeather = true
mod.content.rulesets:register(RULESET, weather)
-- ------- rain lifecycle, in this mod's own save namespace
local function raining()
return (mod.save:get("turnsLeft", 0)) > 0
end
mod.events:on("battle.started", function(ev)
local ruleset = ev.battle and ev.battle.ruleset
if ruleset and ruleset.exampleWeather then
mod.save:set("turnsLeft", RAIN_TURNS)
else
mod.save:set("turnsLeft", 0)
end
end)
mod.events:on("battle.turn_started", function()
local left = mod.save:get("turnsLeft", 0)
if left > 0 then mod.save:set("turnsLeft", left - 1) end
end)
mod.events:on("battle.ended", function()
mod.save:set("turnsLeft", 0)
end)
-- ------- the one behavior change
mod.hooks:wrap("battle.damage", function(next, ctx)
-- the two gates, cheapest first: the player has to have picked the
-- ruleset, and it has to still be raining
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
return next(ctx)
end
local moveType = ctx.move and ctx.move.type
local scale = BOOST[moveType] or DAMPEN[moveType]
if not scale then return next(ctx) end
-- Damage.compute returns (damage, info); pass the second value through
-- untouched or the crit and type-effectiveness flags vanish
local damage, info = next(ctx)
if type(damage) ~= "number" then return damage, info end
return math.max(1, math.floor(damage * scale)), info
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_weather",
"name": "Weather Battles Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "overhaul",
"category": "MECHANIC",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Mechanic-designer gallery entry: a rain field effect behind an opt-in ruleset, driven by the battle.damage hook."
}
+24
View File
@@ -0,0 +1,24 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "battle", "mechanic", "ruleset", "hardcore" },
differences = {
changed = {
"under the WEATHER ruleset only: WATER move damage x1.5 and FIRE x0.5 while it rains",
},
added = {
"example_weather_battles ruleset, selectable in OPTIONS",
"EXAMPLE_RAIN field-effect status record",
},
known = {
"rain is unconditional at battle start and has no on-screen indicator yet",
},
},
credits = {
{ who = "pret/pokered", for_ = "the damage formula the hook scales" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,60 @@
-- Standalone: luajit mods/examples/example_weather/tests/example_weather_test.lua
-- Drives the battle.damage hook through the runtime bus, both with the
-- ruleset on and with it off.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_weather", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local weather = Data.rulesets.example_weather_battles
T.check(weather ~= nil, "the ruleset merged and is selectable")
T.eq(weather.name, "WEATHER", "the ruleset carries a display name")
T.eq(weather.oneIn256Miss, Data.rulesets.gen1_faithful.oneIn256Miss,
"the derived ruleset keeps every gen1_faithful rule")
T.check(Data.statuses.EXAMPLE_RAIN ~= nil, "the rain status record merged")
-- the vanilla stand-in the hook wraps; 100 damage and an info table
local function vanilla() return 100, { crit = false, typeMult = 10 } end
local function hit(ruleset, moveType)
return Runtime.call("battle.damage", vanilla,
{ ruleset = ruleset, move = { type = moveType } })
end
-- ------- ruleset off: the mod is installed and changes nothing
Runtime.emit("battle.started", { battle = { ruleset = Data.rulesets.gen1_faithful } })
T.eq(hit(Data.rulesets.gen1_faithful, "WATER"), 100,
"gen1_faithful is untouched with the mod installed")
T.eq(hit(Data.rulesets.gen1_faithful, "FIRE"), 100,
"FIRE is untouched under gen1_faithful too")
-- ------- ruleset on: rain scales WATER up and FIRE down
Runtime.emit("battle.started", { battle = { ruleset = weather } })
T.eq(hit(weather, "WATER"), 150, "rain boosts WATER damage")
T.eq(hit(weather, "FIRE"), 50, "rain dampens FIRE damage")
T.eq(hit(weather, "NORMAL"), 100, "every other type is untouched")
local damage, info = hit(weather, "WATER")
T.eq(damage, 150, "the scaled damage is the first return")
T.check(info ~= nil and info.typeMult == 10,
"the info table survives the wrap")
-- ------- the counter runs out
for _ = 1, 5 do Runtime.emit("battle.turn_started", {}) end
T.eq(hit(weather, "WATER"), 100, "rain stops after its turn count")
Runtime.emit("battle.started", { battle = { ruleset = weather } })
T.eq(hit(weather, "WATER"), 150, "a new battle starts the rain again")
Runtime.emit("battle.ended", {})
T.eq(hit(weather, "WATER"), 100, "battle.ended clears the rain")
run.release()
T.finish("example_weather")
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# Unified test entry point (21-testing-and-ci §CI).
#
# Runs every tier that this checkout can run and exits non-zero if any of
# them fails. The tier split is what makes that possible: T1/T2/T4 need
# nothing but the committed fixture dataset, so they run anywhere --
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
# data/generated/, so it is skipped automatically when the ROM has never
# been imported rather than failing the run.
#
# scripts/test.sh every tier this checkout can run
# scripts/test.sh --quick skip the slow content tier
# scripts/test.sh --bless re-pin the fingerprint goldens
# WITH_SHOTS=1 scripts/test.sh also capture and diff golden shots
# (fails today -- see the T5 block below)
#
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
set -uo pipefail
cd "$(dirname "$0")/.."
LUA=${LUA:-luajit}
BLESS=0
QUICK=0
SHOTS=${WITH_SHOTS:-0}
for arg in "$@"; do
case "$arg" in
--bless) BLESS=1 ;;
--bless-shots) SHOTS=1; BLESS=1 ;;
--quick) QUICK=1 ;;
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
if ! command -v "$LUA" >/dev/null 2>&1; then
echo "no lua interpreter '$LUA' on PATH (set LUA=...)" >&2
exit 2
fi
# The save-directory sandbox (conf.lua reads POKEPORT_IDENTITY) is scoped
# to the shot tier, which is the only one that starts a real LOVE process
# and could write into a developer's save folder. Exporting it for the
# whole run instead would change what SaveIO.defaultPath() returns, and the
# save-editor suite pins that to the default identity.
SANDBOX_IDENTITY="ci-$$"
FAILED=()
run_tier() {
local label="$1"; shift
echo ""
echo "=============================================================="
echo " $label"
echo "=============================================================="
if "$@"; then
echo "-- $label: PASS"
else
echo "-- $label: FAIL"
FAILED+=("$label")
fi
}
# ------- ROM-free tiers: these are what CI runs
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
# The modded-link desync suite (symmetric mod, handshake fail-closed,
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
# tests/modkit/cases/link_desync.lua.
#
# tests/run_link_tests.lua is a different matter: it calls Data:load() at
# :27 and so needs data/generated/. It is grouped with the content tier
# until that bootstrap can take an injected dataset.
# ------- content tier: only meaningful with an imported ROM
# tests/run_tests.lua is expected to be clean. It used to carry two stale
# chip-audio assertions on the allowlist below (Pikachu cry WAV exists /
# low-health alarm sfx extracted); both have since been fixed, so the
# baseline is zero and any failure fails the tier. Keep the allowlist
# mechanism rather than ignoring the exit code -- that would hide every
# future content regression.
KNOWN_CONTENT_FAILURES=0
KNOWN_CONTENT_LINES=""
run_content_behavior() {
local out
out=$("$LUA" tests/run_tests.lua 2>&1)
local count
count=$(printf '%s\n' "$out" | grep -c '^FAIL ' || true)
local lines
lines=$(printf '%s\n' "$out" | grep '^FAIL ' | sort)
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
printf '%s\n' "$out" | tail -3
if [ "$KNOWN_CONTENT_FAILURES" -gt 0 ]; then
echo "(the $KNOWN_CONTENT_FAILURES known stale assertions, unchanged)"
fi
return 0
fi
printf '%s\n' "$out" | grep '^FAIL ' || true
printf '%s\n' "$out" | tail -2
echo "expected exactly $KNOWN_CONTENT_FAILURES known failures; got $count"
return 1
}
if [ -f data/generated/maps.lua ]; then
if [ "$QUICK" = "1" ]; then
echo ""
echo "-- T3 content: skipped (--quick)"
else
run_tier "T3 content behavior (Red)" run_content_behavior
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
echo ""
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
echo " import a ROM to run them; the modded-link cases ran in T4)"
fi
# ------- golden screenshots: needs love + a display
if [ "$SHOTS" = "1" ]; then
SHOT_DIR=${SHOT_DIR:-/tmp/pokeport-shots}
export SHOT_DIR
mkdir -p "$SHOT_DIR"
SHOT_DRIVER=tests/drivers/shots_fixture.lua
# The fixture goldens are not capturable yet. A driver only ever runs
# after main.lua's bootGame(), so it cannot redirect Data:load(), and
# src/core/Data.lua has no POKEPORT_DATA_DIR branch -- 21-testing-and-ci
# §"Engine changes" specifies one, but it is not implemented, so a LOVE
# process has no way to boot tests/fixture_data. On a ROM-less checkout
# main.lua does not even reach the game: RomImporter.isReady() is false
# and it opens the importer instead.
#
# WITH_SHOTS is opt-in, so asking for a tier that cannot run is an error,
# not a skip. Reporting "pass" here is what made the whole pipeline look
# delivered while never diffing a single pixel.
if [ ! -f "$SHOT_DRIVER" ]; then
echo ""
echo "-- T5 shots: NOT WIRED ($SHOT_DRIVER does not exist)."
echo " Fixture capture needs the POKEPORT_DATA_DIR override in"
echo " src/core/Data.lua so LOVE can boot tests/fixture_data."
FAILED+=("T5 shots (requested but not wired)")
elif ! command -v love >/dev/null 2>&1; then
echo ""
echo "-- T5 shots: love is not on PATH but WITH_SHOTS was requested"
FAILED+=("T5 shots (love missing)")
else
RUNNER="love ."
command -v xvfb-run >/dev/null 2>&1 && RUNNER="xvfb-run -a love ."
run_tier "T5 shot capture" \
env POKEPORT_IDENTITY="$SANDBOX_IDENTITY" POKEPORT_DRIVER="$SHOT_DRIVER" $RUNNER
if [ "$BLESS" = "1" ]; then
run_tier "T5 shot bless" \
python3 tools/compare_shots.py tests/goldens/shots "$SHOT_DIR" --bless
else
run_tier "T5 shot diff" \
python3 tools/compare_shots.py tests/goldens/shots "$SHOT_DIR"
fi
fi
fi
# ------- fingerprint blessing
if [ "$BLESS" = "1" ] && [ "$SHOTS" != "1" ]; then
echo ""
echo "re-pinning fingerprint goldens (deliberate parity change -- record it"
echo "in docs/known-differences.md or docs/new-features.md)"
"$LUA" tests/bless_fingerprints.lua || FAILED+=("fingerprint bless")
fi
# ------- verdict
echo ""
echo "=============================================================="
if [ ${#FAILED[@]} -eq 0 ]; then
echo " ALL TIERS PASSED"
echo "=============================================================="
exit 0
fi
echo " ${#FAILED[@]} TIER(S) FAILED"
for tier in "${FAILED[@]}"; do echo " - $tier"; done
echo "=============================================================="
exit 1
+408
View File
@@ -0,0 +1,408 @@
-- Authoring DSL for the channel bytecode ChipAudio's Channel:nextEvent
-- decodes: note-event Lua tables in, a self-contained program blob out.
-- The blob is mounted as pseudo-bank 0 by ChipAudio, so addresses are based
-- at 0x4000 exactly like the ROM's own 0x4000-window programs.
--
-- Two passes: the first sizes every event and records where each label and
-- each event index lands, the second emits bytes with call/loop targets
-- resolved. Nothing here touches love.*, so mods assemble at load time and
-- headless tools assemble without a graphics context.
local ChipAsm = {}
local BASE_ADDRESS = 0x4000
local FRAME_TICKS = 256
local NOTES = {
C = 0, ["C#"] = 1, Db = 1, D = 2, ["D#"] = 3, Eb = 3, E = 4,
F = 5, ["F#"] = 6, Gb = 6, G = 7, ["G#"] = 8, Ab = 8, A = 9,
["A#"] = 10, Bb = 10, B = 11,
}
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
-- grid as the ROM's own drum tables
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
end
-- ------- validation
local Cursor = {}
Cursor.__index = Cursor
local function cursor(channel, scope)
return setmetatable({ channel = channel, scope = scope, index = 0 }, Cursor)
end
function Cursor:fail(message)
error(("ChipAsm: channel %d %sevent %d: %s")
:format(self.channel, self.scope, self.index, message), 0)
end
function Cursor:int(value, low, high, what)
if type(value) ~= "number" or value ~= math.floor(value) then
self:fail(("%s must be an integer, got %s"):format(what, tostring(value)))
end
if value < low or value > high then
self:fail(("%s out of range %d-%d: %d"):format(what, low, high, value))
end
return value
end
function Cursor:length(value)
return self:int(value or 1, 1, 16, "len")
end
-- the fade nibble is signed: bit 3 set means a decay of the low three bits
function Cursor:fade(value)
value = self:int(value or 0, -7, 7, "fade")
if value < 0 then return 8 - value end
return value
end
function Cursor:pitch(event)
if event.pitch ~= nil then return self:int(event.pitch, 0, 11, "pitch") end
local name = event.note
if type(name) ~= "string" then
self:fail("note must be a name or an explicit pitch")
end
local pitch = NOTES[name]
if not pitch then self:fail(("unknown note %q"):format(name)) end
return pitch
end
-- ------- pass 1: events to sized chunks
-- a chunk is a byte, or a two-byte reference to a label / event index that
-- pass 2 resolves once every channel's size is known
local function reference(cur, target)
return {
label = target, offsets = cur.offsets, ref = cur, index = cur.index,
}
end
local function emitters(cur, hw)
local E = {}
function E.label() end
function E.note(event, out)
if hw == 4 then cur:fail("channel 4 plays drums, not notes") end
out[#out + 1] = cur:pitch(event) * 16 + cur:length(event.len) - 1
end
function E.rest(event, out)
local len = event.rest == true and event.len or event.rest
out[#out + 1] = 0xC0 + cur:length(len) - 1
end
function E.drum(event, out)
if hw ~= 4 then cur:fail("drums only play on channel 4") end
local id = cur:int(event.drum, 0, 255, "drum")
local len = cur:length(event.len) - 1
-- ids from 11 up do not fit the note nibble and carry an extra byte
if id >= 11 then
out[#out + 1] = 0xB0 + len
out[#out + 1] = id
else
out[#out + 1] = id * 16 + len
end
end
-- the interpreter reads the packed byte per hardware channel: none on
-- noise, wave level plus instrument on channel 3, volume plus fade on the
-- two square channels
function E.notetype(event, out)
local spec = event.notetype
out[#out + 1] = 0xD0 + cur:int(spec.speed or 12, 0, 15, "speed")
if hw == 4 then return end
if hw == 3 then
out[#out + 1] = cur:int(spec.waveLevel or 0, 0, 3, "waveLevel") * 16
+ cur:int(spec.waveInstrument or 0, 0, 15, "waveInstrument")
else
out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16
+ cur:fade(spec.fade)
end
end
function E.octave(event, out)
out[#out + 1] = 0xE0 + 8 - cur:int(event.octave, 1, 8, "octave")
end
function E.perfectPitch(_, out)
out[#out + 1] = 0xE8
end
function E.vibrato(event, out)
local spec = event.vibrato
out[#out + 1] = 0xEA
out[#out + 1] = cur:int(spec.delay or 0, 0, 255, "vibrato delay")
out[#out + 1] = cur:int(spec.depth or 0, 0, 15, "vibrato depth") * 16
+ cur:int(spec.rate or 0, 0, 15, "vibrato rate")
end
function E.slide(event, out)
local spec = event.slide
out[#out + 1] = 0xEB
out[#out + 1] = cur:int(spec.len or 0, 0, 255, "slide len")
out[#out + 1] = (8 - cur:int(spec.octave or 4, 1, 8, "slide octave")) * 16
+ cur:pitch(spec)
end
function E.duty(event, out)
out[#out + 1] = 0xEC
out[#out + 1] = cur:int(event.duty, 0, 3, "duty")
end
function E.dutyPattern(event, out)
local packed = 0
for slot = 1, 4 do
packed = packed * 4 + cur:int(event.dutyPattern[slot], 0, 3, "dutyPattern")
end
out[#out + 1] = 0xFC
out[#out + 1] = packed
end
function E.tempo(event, out)
local tempo = cur:int(event.tempo, 0, 0xFFFF, "tempo")
out[#out + 1] = 0xED
out[#out + 1] = math.floor(tempo / 0x100)
out[#out + 1] = tempo % 0x100
end
function E.pan(event, out)
out[#out + 1] = 0xEE
out[#out + 1] = cur:int(event.pan, 0, 255, "pan")
end
function E.executeMusic(_, out)
out[#out + 1] = 0xF8
end
function E.call(event, out)
out[#out + 1] = 0xFD
out[#out + 1] = reference(cur, event.call)
end
function E.ret(_, out)
out[#out + 1] = 0xFF
end
function E.loop(event, out)
out[#out + 1] = 0xFE
out[#out + 1] = cur:int(event.loop.count or 0, 0, 255, "loop count")
out[#out + 1] = reference(cur, event.loop.to)
end
-- sfx-only: the 0x20-0x2F note form carries its own volume and fade plus
-- either a raw frequency register or a noise parameter
function E.squareNote(event, out)
local spec = event.squareNote
local register = cur:int(spec.frequency or 0, 0, 0x7FF, "frequency")
out[#out + 1] = 0x20 + cur:length(spec.len) - 1
out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16
+ cur:fade(spec.fade)
out[#out + 1] = register % 0x100
out[#out + 1] = math.floor(register / 0x100)
end
function E.noiseNote(event, out)
local spec = event.noiseNote
out[#out + 1] = 0x20 + cur:length(spec.len) - 1
out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16
+ cur:fade(spec.fade)
out[#out + 1] = cur:int(spec.parameter or 0, 0, 255, "parameter")
end
function E.pitchSweep(event, out)
local spec = event.pitchSweep
out[#out + 1] = 0x10
out[#out + 1] = cur:int(spec.pace or 0, 0, 7, "sweep pace") * 16
+ (spec.subtract and 8 or 0)
+ cur:int(spec.shift or 0, 0, 7, "sweep shift")
end
return E
end
-- the event key that names the command, checked in a fixed order so an
-- event carrying `len` alongside `note` is still a note
local KEYS = {
"label", "note", "pitch", "rest", "drum", "notetype", "octave",
"perfectPitch", "vibrato", "slide", "duty", "dutyPattern", "tempo", "pan",
"executeMusic", "call", "ret", "loop", "squareNote", "noiseNote",
"pitchSweep",
}
-- an unresolved reference is one chunk but two bytes, so offsets are counted
-- rather than read off the chunk list's length
local function byteSize(chunks)
local size = 0
for _, chunk in ipairs(chunks) do
size = size + (type(chunk) == "table" and 2 or 1)
end
return size
end
local function assembleStream(program, cur, E, out, offsets, labels)
cur.offsets = offsets
for index, event in ipairs(program) do
cur.index = index
if type(event) ~= "table" then cur:fail("event must be a table") end
local offset = byteSize(out)
offsets[index] = offset
local kind
for _, key in ipairs(KEYS) do
if event[key] ~= nil then kind = key break end
end
if not kind then cur:fail("no command in event") end
if kind == "pitch" then kind = "note" end
if kind == "label" then labels[event.label] = offset end
E[kind](event, out)
end
end
-- a stream that cannot fall off its end needs no terminator; anything else
-- gets the endchannel byte the interpreter stops on
local function endsItself(program)
local last = program[#program]
if type(last) ~= "table" then return false end
if last.ret then return true end
return last.loop ~= nil and (last.loop.count or 0) == 0
end
local function assembleChannel(spec, hw, number, prelude)
local cur = cursor(number, "")
local out, offsets, labels = {}, {}, {}
for _, byte in ipairs(prelude or {}) do out[#out + 1] = byte end
local program = spec.program or {}
assembleStream(program, cur, emitters(cur, hw), out, offsets, labels)
if not endsItself(program) then out[#out + 1] = 0xFF end
-- subroutines follow the body so every call target is inside the blob
local names = {}
for name in pairs(spec.subroutines or {}) do names[#names + 1] = name end
table.sort(names)
for _, name in ipairs(names) do
labels[name] = byteSize(out)
local sub = spec.subroutines[name]
local subCursor = cursor(number, ("subroutine %q "):format(name))
assembleStream(sub, subCursor, emitters(subCursor, hw), out, {}, labels)
if not endsItself(sub) then out[#out + 1] = 0xFF end
end
return { bytes = out, labels = labels }
end
-- ------- pass 2: resolve the references and pack the blob
local function targetAddress(chunk, labels, base)
local target = chunk.label
local offset
if type(target) == "number" then
offset = chunk.offsets[target]
if not offset then
chunk.ref.index = chunk.index
chunk.ref:fail(("loop target event %d does not exist"):format(target))
end
else
offset = labels[target]
if not offset then
chunk.ref.index = chunk.index
chunk.ref:fail(("unknown label %q"):format(tostring(target)))
end
end
return offset + base
end
local function pack(channels, blobBase)
local pieces = {}
for _, channel in ipairs(channels) do
local base = channel.base + blobBase
for _, byte in ipairs(channel.bytes) do
if type(byte) == "table" then
local address = targetAddress(byte, channel.labels, base)
pieces[#pieces + 1] = string.char(address % 0x100)
pieces[#pieces + 1] = string.char(math.floor(address / 0x100) % 0x100)
else
pieces[#pieces + 1] = string.char(byte % 0x100)
end
end
end
return table.concat(pieces)
end
-- friendly drum rows to the segment lists Engine:noiseInstrument caches
local function drumSegments(rows)
local drums = {}
for id, program in pairs(rows) do
local segments, ticks = {}, 0
for index, row in ipairs(program) do
local length = row.len or 1
if type(length) ~= "number" or length < 1 or length > 16 then
error(("ChipAsm: drum %s row %d: len out of range 1-16")
:format(tostring(id), index), 0)
end
local duration = length * FRAME_TICKS
segments[#segments + 1] = {
startSample = snapTicks(ticks),
endSample = snapTicks(ticks + duration),
volume = row.volume or 0,
fade = row.fade or 0,
parameter = row.parameter or 0,
}
ticks = ticks + duration
end
drums[id] = segments
end
return drums
end
local function assemble(spec, sfx)
local channels, size = {}, 0
for index, channelSpec in ipairs(spec.channels or {}) do
local hw = channelSpec.hw or index
if type(hw) ~= "number" or hw < 1 or hw > 4 then
error(("ChipAsm: channel %d: hw must be 1-4"):format(index), 0)
end
-- the global tempo rides on the first channel, the way the ROM's own
-- songs write it
local prelude
if index == 1 and spec.tempo and not sfx then
local tempo = cursor(index, ""):int(spec.tempo, 0, 0xFFFF, "tempo")
prelude = { 0xED, math.floor(tempo / 0x100), tempo % 0x100 }
end
local built = assembleChannel(channelSpec, hw, index, prelude)
built.base = size
-- effect programs live on channels 5-8, which is how the interpreter
-- tells an effect's command set from a song's
built.number = sfx and hw + 4 or hw
size = size + byteSize(built.bytes)
channels[#channels + 1] = built
end
local blob = pack(channels, BASE_ADDRESS)
local layout = {}
for _, channel in ipairs(channels) do
layout[#layout + 1] = {
number = channel.number,
address = BASE_ADDRESS + channel.base,
}
end
return {
chip = {
blob = blob,
channels = layout,
waves = spec.waves,
drums = spec.drums and drumSegments(spec.drums) or nil,
engine = spec.engine or 1,
},
}
end
function ChipAsm.song(spec)
return assemble(spec, false)
end
function ChipAsm.sfx(spec)
return assemble(spec, true)
end
return ChipAsm
+10 -2
View File
@@ -422,8 +422,16 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
-- effect runs AFTER each block displays, so block 1 shows normal.
-- PlayAnimation pushes rOBP0 around every subanimation row (:246-251
-- / :259-262), so the ambient palette returns when the toss ends.
local ballFlicker = opts
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
-- opts.ballFlicker carries the ball record's flicker flag; the id
-- check covers callers that only pass the ball item.
local wantsFlicker
if opts and opts.ballFlicker ~= nil then
wantsFlicker = opts.ballFlicker
else
wantsFlicker = opts
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
end
local ballFlicker = wantsFlicker
and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM"
or moveId == "ULTRATOSS_ANIM")
local obp0Flip = false
+537 -442
View File
File diff suppressed because it is too large Load Diff
+71 -29
View File
@@ -1,45 +1,64 @@
-- Gen 1 catch algorithm (engine/items/item_effects.asm, ItemUseBall).
local Status = require("src.battle.Status")
local Catching = {}
local BALL_RAND_MAX = { MASTER_BALL = 0, POKE_BALL = 255, GREAT_BALL = 200,
ULTRA_BALL = 150, SAFARI_BALL = 150 }
local BALL_HP_FACTOR = { POKE_BALL = 12, GREAT_BALL = 8, ULTRA_BALL = 12,
SAFARI_BALL = 12 }
-- randMax is the ceiling of the catch roll, hpFactor the X of the HP term,
-- wobbleFactor the ballFactor2 divisor of the wobble math. MASTER_BALL
-- never rolls (autoCatch), so its factors are unused. tossAnim picks the
-- TossBallAnimation arc and flicker the Master/Ultra OBJ-palette strobe
-- (DoBallTossSpecialEffects).
local BALLS = {
MASTER_BALL = { randMax = 0, autoCatch = true,
tossAnim = "ULTRATOSS_ANIM", flicker = true },
POKE_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 255,
tossAnim = "TOSS_ANIM" },
GREAT_BALL = { randMax = 200, hpFactor = 8, wobbleFactor = 200,
tossAnim = "GREATTOSS_ANIM" },
ULTRA_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150,
tossAnim = "ULTRATOSS_ANIM", flicker = true },
SAFARI_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150,
tossAnim = "ULTRATOSS_ANIM" },
}
Catching.BALLS = BALLS
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
--
-- On failure the ball wobbles per the original's shake calculation:
-- Y = rate*100/ballFactor2 (255/200/150), Z = X*Y/255 + status2 (5/10)
-- where X is the HP factor; Z<10: 0 shakes, <30: 1, <70: 2, else 3.
-- (We use the HP factor for X on both failure paths; the original reads
-- a stale quotient when the first roll fails.)
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
rng = rng or love.math.random
if ball == "MASTER_BALL" then return true, 3 end
local randMax = BALL_RAND_MAX[ball] or 255
-- an unknown ball falls back to POKE_BALL's roll and the 150 wobble
-- divisor, which is what the old per-field `or` defaults resolved to
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
function Catching.registerInto(registry, _, owner)
for id, record in pairs(BALLS) do
registry:register(id, record, owner)
end
end
-- The stock ItemUseBall math. On failure the ball wobbles per the
-- original's shake calculation: Y = rate*100/ballFactor2 (255/200/150),
-- Z = X*Y/255 + status2 (5/10) where X is the HP factor; Z<10: 0 shakes,
-- <30: 1, <70: 2, else 3. (We use the HP factor for X on both failure
-- paths; the original reads a stale quotient when the first roll fails.)
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
if def.autoCatch then return true, 3 end
local randMax = def.randMax
local rate = rateOverride or targetDef.catchRate
local statusBonus = 0
-- the status subtraction and the wobble bonus come off the merged
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
local s = targetMon.status
if s == "SLP" or s == "FRZ" then
statusBonus = 25
elseif s == "PSN" or s == "BRN" or s == "PAR" then
statusBonus = 12
end
local record = Status.recordFor(statuses, s)
local statusBonus = record and record.catchBonus or 0
-- HP factor (X)
local maxhp = targetMon.stats.hp
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
local factor = BALL_HP_FACTOR[ball] or 12
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
-- the intermediate in 16 bits); capping early collapses the value
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
local function shakes()
local ballFactor2 = ball == "POKE_BALL" and 255
or ball == "GREAT_BALL" and 200 or 150
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
local y = math.floor(rate * 100 / ballFactor2)
local z
if y > 255 then
@@ -47,10 +66,8 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
else
z = math.floor(f * y / 255)
end
if s == "SLP" or s == "FRZ" then
z = z + 10
elseif s then
z = z + 5
if s then
z = z + ((record and record.shakeBonus) or 5)
end
if z < 10 then return 0 elseif z < 30 then return 1
elseif z < 70 then return 2 else return 3 end
@@ -63,4 +80,29 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
return false, shakes()
end
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
-- opts (all optional): ballDef = the merged ball record, statuses = the
-- merged statuses table, battle = the running battle. A ball record's
-- attempt fn supersedes the whole formula; its ctx.vanillaAttempt() runs
-- the stock math with the ctx's (possibly rewritten) rateOverride.
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride, opts)
rng = rng or love.math.random
opts = opts or {}
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
local statuses = opts.statuses
if def.attempt then
local ctx = {
ballDef = def, targetMon = targetMon, targetDef = targetDef,
rng = rng, rateOverride = rateOverride, battle = opts.battle,
}
ctx.vanillaAttempt = function()
return stockAttempt(def, targetMon, targetDef, rng, ctx.rateOverride,
statuses)
end
return def.attempt(ctx)
end
return stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
end
return Catching
+89 -31
View File
@@ -3,27 +3,68 @@
--
-- Battlers carry curStats/curTypes (Transform/Conversion can override the
-- species values) plus reflect/lightScreen/focusEnergy volatile flags.
-- Battlers built by makeBattler also carry the merged badgeBoosts rows and
-- statuses records; hand-built battlers fall back to the vanilla tables.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local TypeChart = require("src.battle.TypeChart")
local Damage = {}
-- Moves with a boosted critical-hit rate (engine/battle/core.asm
-- CriticalHitTest checks these move ids explicitly).
-- CriticalHitTest checks these move ids explicitly). The move-record
-- highCrit field wins; this list covers pre-existing imported caches.
local HIGH_CRIT = {
KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true,
}
-- ApplyBadgeStatBoosts (engine/battle/core.asm): x9/8 per badge on the
-- named battle stat. Data.constants.badgeBoosts replaces this via the
-- battler's badgeBoosts field; these rows are the vanilla values.
Damage.BADGE_BOOSTS = {
{ badge = "BOULDERBADGE", stat = "attack", num = 9, den = 8 },
{ badge = "THUNDERBADGE", stat = "defense", num = 9, den = 8 },
{ badge = "SOULBADGE", stat = "speed", num = 9, den = 8 },
{ badge = "VOLCANOBADGE", stat = "special", num = 9, den = 8 },
}
-- the boost a battler's badge set applies to one battle stat, or nil
local function badgeBoost(battler, stat)
local badges = battler.badges
if not badges then return nil end
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == stat and badges[row.badge] then return row end
end
return nil
end
-- the merged status record for a battler's persistent condition, or nil
local function statusRecord(battler)
return Status.recordFor(battler.statuses, battler.mon.status)
end
-- Critical chance test, following CriticalHitTest's shift chain exactly
-- (each left shift caps at 255): b = baseSpeed/2, then x2 (or /2 with
-- (each left shift caps at 255): b = speed/2, then x2 (or /2 with
-- Focus Energy's famous right-shift bug), then x4 for high-crit moves
-- or /2 for normal ones. Net rates: normal = speed/512, high-crit =
-- speed*4/256 (capped), Focus Energy bug = 1/4 the usual.
function Damage.critRoll(ruleset, attacker, moveId, rng)
-- critUsesBaseSpeed (default true, the Gen 1 rule) reads the species
-- base speed; a ruleset that sets it false uses the current in-battle
-- speed with stages applied.
function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
rng = rng or love.math.random
local function shl(x) return math.min(255, x * 2) end
local b = math.floor(attacker.def.baseStats.speed / 2)
local speed
if ruleset.critUsesBaseSpeed == false then
speed = Stats.applyStage(attacker.curStats.speed,
attacker.stages and attacker.stages.speed or 0)
else
speed = attacker.def.baseStats.speed
end
local b = math.floor(speed / 2)
if attacker.focusEnergy then
if ruleset.focusEnergyBug then
b = math.floor(b / 2) -- srl instead of sla
@@ -33,7 +74,8 @@ function Damage.critRoll(ruleset, attacker, moveId, rng)
else
b = shl(b)
end
if HIGH_CRIT[moveId] then
if highCrit == nil then highCrit = HIGH_CRIT[moveId] end
if highCrit then
b = shl(shl(b))
else
b = math.floor(b / 2)
@@ -63,13 +105,27 @@ function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
return rng(0, 255) < acc
end
local function isSpecial(moveType)
-- Gen 1: WATER/GRASS/FIRE/ICE/ELECTRIC/PSYCHIC/DRAGON are special
return moveType == "WATER" or moveType == "GRASS" or moveType == "FIRE"
or moveType == "ICE" or moveType == "ELECTRIC" or moveType == "PSYCHIC_TYPE"
or moveType == "DRAGON"
local warnedTypes = {}
-- Gen 1 splits physical from special by TYPE: the move's own category
-- field wins, then the merged type record's, then physical (with one
-- warning per unknown type).
local function categoryOf(move)
local category = move.category or TypeChart.category(move.type)
if category == nil then
if move.type ~= nil and not warnedTypes[move.type] then
warnedTypes[move.type] = true
Logger.warn("move type %s has no category; treated as physical",
tostring(move.type))
end
category = "physical"
end
return category
end
function Damage.isSpecial(moveType)
return TypeChart.category(moveType) == "special"
end
Damage.isSpecial = isSpecial
-- Compute damage. attacker/defender are battler tables.
-- opts: rng, forceCrit, explode (halves defense), typeless (confusion
@@ -80,16 +136,23 @@ Damage.isSpecial = isSpecial
function Damage.compute(ruleset, attacker, defender, move, opts)
opts = opts or {}
local rng = opts.rng or love.math.random
if move.power == 0 then
if move.power == 0 or move.category == "status" then
return 0, { crit = false, typeMult = 10 }
end
local crit = opts.forceCrit
if crit == nil then
crit = Damage.critRoll(ruleset, attacker, move.id, rng)
if Runtime.wantsHook("battle.crit") then
crit = Runtime.call("battle.crit", function(c)
return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit)
end, { ruleset = ruleset, attacker = attacker, moveId = move.id,
rng = rng, highCrit = move.highCrit })
else
crit = Damage.critRoll(ruleset, attacker, move.id, rng, move.highCrit)
end
end
local special = isSpecial(move.type)
local special = categoryOf(move) == "special"
local atkStat = special and "special" or "attack"
local defStat = special and "special" or "defense"
@@ -105,28 +168,23 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
-- Volcano -> special
local badges = attacker.badges
if badges then
if not special and badges.BOULDERBADGE then
atk = math.floor(atk * 9 / 8)
elseif special and badges.VOLCANOBADGE then
atk = math.floor(atk * 9 / 8)
end
local atkBoost = badgeBoost(attacker, atkStat)
if atkBoost then
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
end
local dbadges = defender.badges
if dbadges then
if not special and dbadges.THUNDERBADGE then
dfn = math.floor(dfn * 9 / 8)
elseif special and dbadges.VOLCANOBADGE then
dfn = math.floor(dfn * 9 / 8)
end
local defBoost = badgeBoost(defender, defStat)
if defBoost then
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
end
-- burn halves physical attack (applied as part of the stat in Gen 1).
-- burn halves physical attack (applied as part of the stat in Gen 1;
-- the status record's statPenalty names the stat it cuts).
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
-- unmodified attack over the burn-halved battle stat, lifting the
-- penalty until the next stat recompute.
if not special and attacker.mon.status == "BRN" and not attacker.hazeStatReset then
atk = math.max(1, math.floor(atk / 2))
local record = statusRecord(attacker)
local penalty = record and record.statPenalty
if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then
atk = math.max(1, math.floor(atk / penalty.div))
end
-- screens double the effective defense (crits bypass them). The
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
+268
View File
@@ -0,0 +1,268 @@
-- The move-effect execution surface: the ctx facade handed to every
-- move_effects record callback, and the staged damaging pipeline that
-- performMove drives through the record's stage fields
-- (gate/neverMiss/hitCount/beforeAccuracy/chooseDamage/onMiss/afterDamage
-- plus the post-damage secondary run). The ctx is the only supported
-- surface handlers receive; everything else is engine-internal.
local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry")
local EffectRegistry = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
end
EffectRegistry.displayName = displayName
-- built once per performMove call; closes over the battle
function EffectRegistry.makeCtx(battle, user, target, move, moveInst, isCalled)
local ctx
ctx = {
battle = battle, data = battle.data, rng = battle.rng,
ruleset = battle.ruleset,
user = user, target = target, move = move, moveInst = moveInst,
isCalled = isCalled or false,
field = battle.field,
displayName = displayName,
say = function(text) battle:sayNext(text) end,
sayNext = function(text) battle:sayNext(text) end,
anim = function(animName, isPlayer)
battle:animNext(animName, isPlayer == nil and user.isPlayer or isPlayer)
end,
drain = function() battle:drainNext() end,
-- applyDamage plus the faint queue, like the crash/self-hit paths
damage = function(who, amount)
local dealt = battle:applyDamage(who, amount)
if who.mon.hp <= 0 then battle:onFaint(who) end
return dealt
end,
inflict = function(who, statusId, opts)
return StatusRegistry.inflict(battle, who, statusId, opts)
end,
cure = function(who)
who.mon.status = nil
who.toxicCounter = nil
end,
changeStage = function(who, stat, delta, fromEnemy)
return MoveEffects.changeStage(battle, who, stat, delta, fromEnemy)
end,
computeDamage = function(opts)
return battle:computeDamage(user, target, move, opts)
end,
accuracyRoll = function()
return battle:accuracyRoll(move, user, target)
end,
callMove = function(moveId)
return battle:performMove(user, target, { id = moveId, pp = 1 }, true)
end,
side = function(who) return battle:sideOf(who) end,
}
return ctx
end
-- multi-hit count: the record's hitCount wins, then the move's multiHit
-- field, then a single hit
local function hitCount(ctx, record)
if record and record.hitCount then
return record.hitCount(ctx) or 1
end
local dist = ctx.move.multiHit
if dist == nil then return 1 end
if type(dist) == "number" then return dist end
local r = ctx.rng(0, #dist - 1)
return dist[r + 1]
end
-- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
-- damage choice -> hits -> messages -> after-damage -> secondary run).
function EffectRegistry.runDamaging(battle, ctx, record)
local user, target = ctx.user, ctx.target
local move, moveInst = ctx.move, ctx.moveInst
local neverMiss = record and record.neverMiss
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
-- SWIFT_EFFECT before the INVULNERABLE check)
if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
-- OHKO speed gate, Dream Eater sleep gate
if record and record.gate then
local ok, failMsg = record.gate(ctx)
if not ok then
battle:cancelMoveAnim()
if failMsg then battle:sayNext(failMsg) end
return
end
end
local hits = hitCount(ctx, record)
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
if not neverMiss then
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil
return
end
end
-- damage per hit
local dmg, info
if move.id == "COUNTER" then
-- HandleCounterMove: 2x the last damage dealt in battle, only if
-- the opponent's last move was counterable with >0 power (and not
-- Counter itself); wDamage is shared, so any last damage counts.
-- counterable defaults to the Normal/Fighting whitelist.
local lastId = target.lastMove
local lm = lastId and lastId ~= "COUNTER" and battle.data.moves[lastId]
local counterable = false
if lm and (lm.power or 0) > 0 then
if lm.counterable ~= nil then
counterable = lm.counterable
else
counterable = lm.type == "NORMAL" or lm.type == "FIGHTING"
end
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
dmg = math.min(65535, battle.lastDamage * 2)
info = { crit = false, typeMult = 10 }
elseif record and record.chooseDamage then
-- Counter/Super Fang/OHKO/fixed damage; (nil, msg) means the move
-- failed with that text already chosen
local chosen, extra = record.chooseDamage(ctx)
if not chosen then
battle:cancelMoveAnim()
if extra then battle:sayNext(extra) end
return
end
dmg, info = chosen, extra or { crit = false, typeMult = 10 }
else
dmg, info = battle:computeDamage(user, target, move,
{ rng = battle.rng, explode = (record and record.explode) or nil })
end
if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation:
-- on the move's anim row when one was announced, else on a bare hit
-- row (thrash/rage continuations), placed BEFORE the drain rows the
-- hits loop inserts so the blink precedes the bar drain
local hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt
landed = h
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move,
damage = dealt, crit = info.crit, typeMult = info.typeMult,
})
end
if hadSub and not target.substituteHP then
-- AttackSubstitute: breaking the substitute ends a multi-hit move
brokeSub = true
break
end
end
hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
else
battle:sayNext(("Hit %d times!"):format(hits))
end
end
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
ctx.brokeSub, ctx.hits = brokeSub, hits
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
battle:applyDamage(user, recoil)
end
-- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary"
and target.mon.hp > 0 and totalDealt > 0 then
for _, m in ipairs(record.run(ctx)) do
battle:sayNext(m)
end
end
if record == nil then
MoveEffects.warnUnknown(move.effect)
end
if target.mon.hp <= 0 then
battle:onFaint(target)
end
if user.mon.hp <= 0 then
battle:onFaint(user)
end
end
return EffectRegistry
+39 -8
View File
@@ -5,6 +5,7 @@
-- participant's stat exp.
local Growth = require("src.pokemon.Growth")
local Runtime = require("src.mods.Runtime")
local Stats = require("src.pokemon.Stats")
local Experience = {}
@@ -21,14 +22,26 @@ local Experience = {}
-- Sequential floor divisions equal one floor division by the product,
-- so callers pass numParticipants = 2*participants for the first pass
-- and 2*participants*partyCount for the whole-party pass.
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, traded)
--
-- consts is Data.constants; a constants.exp record can retune the
-- divisor and the traded/trainer multipliers, with the values above as
-- the defaults.
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants,
traded, consts)
local divisor, tradedMult, trainerMult = 7, nil, nil
local tuning = consts and consts.exp
if tuning then
divisor = tuning.divisor or divisor
tradedMult = tuning.tradedMult
trainerMult = tuning.trainerMult
end
local base = math.floor(defeatedDef.baseExp / math.max(1, numParticipants or 1))
local exp = math.floor(base * level / 7)
local exp = math.floor(base * level / divisor)
if traded then
exp = math.floor(exp * 3 / 2)
exp = math.floor(exp * (tradedMult or 1.5))
end
if isTrainer then
exp = math.floor(exp * 3 / 2)
exp = math.floor(exp * (trainerMult or 1.5))
end
return math.max(1, exp)
end
@@ -46,18 +59,36 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer,
local gain = math.floor(defeatedDef.baseStats[key] / statShare)
mon.statExp[key] = math.min(65535, (mon.statExp[key] or 0) + gain)
end
local gained = Experience.gainFor(defeatedDef, level, isTrainer,
numParticipants, traded)
local consts = data.constants
local gained
if Runtime.wantsHook("exp.gain") then
gained = Runtime.call("exp.gain", function(c)
return Experience.gainFor(c.defeatedDef, c.level, c.isTrainer,
c.participants, c.traded, consts)
end, { defeatedDef = defeatedDef, level = level, isTrainer = isTrainer,
participants = numParticipants, traded = traded, mon = mon })
else
gained = Experience.gainFor(defeatedDef, level, isTrainer,
numParticipants, traded, consts)
end
mon.exp = mon.exp + gained
local cap = consts and consts.levelCap or 100
local levels = {}
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp)
while mon.level < math.min(newLevel, 100) do
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp, cap,
data.growth_rates)
while mon.level < math.min(newLevel, cap) do
mon.level = mon.level + 1
local old = mon.stats
mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp)
mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp))
table.insert(levels, mon.level)
if Runtime.wants("pokemon.level_up") then
Runtime.emit("pokemon.level_up", {
mon = mon, level = mon.level, prevLevel = mon.level - 1,
learnable = Experience.movesLearnedAt(speciesDef, mon.level),
})
end
end
return levels, gained
end
+370 -49
View File
@@ -5,8 +5,16 @@
--
-- Substitutes block status/stat effects and side effects aimed at their
-- owner, like Gen 1.
--
-- The primary/secondary tables keep their v1 signatures; MoveEffects.full
-- carries the stage callbacks the damaging pipeline consults, and RECORDS
-- is the registry view of all three -- the merged Data.move_effects a
-- battle dispatches on serves these same objects.
local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local MoveEffects = {}
@@ -53,6 +61,7 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
end
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
end
MoveEffects.changeStage = changeStage
local function statUp(stat, delta)
return function(battle, user, target)
@@ -70,54 +79,10 @@ end
-- status
-- ---------------------------------------------------------------------
local STATUS_LABEL = {
SLP = "fell asleep", PSN = "was poisoned", BRN = "was burned",
FRZ = "was frozen solid",
}
-- opts: toxic (start the Toxic counter), moveType (for the type
-- gates), secondary (side-effect of a damaging move).
-- kept as the module's inflict entry: the registry-backed rules live in
-- StatusRegistry (per-status canInflict/onInflict on the merged records)
local function inflictStatus(battle, target, status, opts)
opts = opts or {}
if target.mon.status then return {} end
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
-- their handlers never check the substitute in Gen 1.
if target.substituteHP and (opts.secondary or status == "PSN") then
return {}
end
for _, t in ipairs(target.curTypes) do
-- can't poison Poison-types (primary or secondary)
if status == "PSN" and t == "POISON" then return {} end
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
if status == "PAR" and opts.moveType == "ELECTRIC" and t == "GROUND" then
return {}
end
-- FreezeBurnParalyzeEffect: a secondary status never lands when
-- the move's type matches either of the target's types (Body Slam
-- can't paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
if opts.secondary and status ~= "PSN" and opts.moveType == t then
return {}
end
-- keep the canonical immunities for any non-secondary path
if (status == "BRN" and t == "FIRE") or (status == "FRZ" and t == "ICE") then
return {}
end
end
target.mon.status = status
if status == "SLP" then
target.sleepTurns = battle.rng(1, 7)
end
if opts.toxic then
target.toxicCounter = 1
-- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(displayName(target)) }
end
if status == "PAR" then
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(displayName(target)) }
end
return { ("%s\n%s!"):format(displayName(target), STATUS_LABEL[status]) }
return StatusRegistry.inflict(battle, target, status, opts)
end
local function statusMove(status)
@@ -131,6 +96,7 @@ local function statusMove(status)
local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC",
moveType = move and move.type,
source = move and move.id,
})
if #msgs == 0 then
return { "But, it failed!" }
@@ -151,6 +117,7 @@ local function statusSide(status, chance)
return inflictStatus(battle, target, status, {
moveType = move and move.type,
secondary = true,
source = move and move.id,
})
end
end
@@ -397,11 +364,331 @@ MoveEffects.secondary = {
-- the second hit reroutes to PoisonEffect with POISON_SIDE_EFFECT1:
-- 20 percent + 1 (52/256)
if battle.rng(0, 255) >= 52 then return {} end
return inflictStatus(battle, target, "PSN", { secondary = true })
return inflictStatus(battle, target, "PSN",
{ secondary = true, source = "TWINEEDLE" })
end,
}
-- effects fully handled inside BattleState's damage pipeline
-- ---------------------------------------------------------------------
-- full records: the damaging pipeline's stage callbacks
-- ---------------------------------------------------------------------
-- Status-move effects whose pokered handlers call MoveHitTest (sleep/
-- poison/paralyze/confusion/leech seed/disable and the primary
-- stat-down moves). Everything else in MoveEffects.primary is
-- self-targeting and never rolls accuracy. Mimic also hit-tests but
-- runs its own mid-move flow (resolveMimic).
local ACC_CHECKED = {
SLEEP_EFFECT = true, POISON_EFFECT = true, PARALYZE_EFFECT = true,
CONFUSION_EFFECT = true, LEECH_SEED_EFFECT = true, DISABLE_EFFECT = true,
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
-- fixed-damage moves (engine/battle/core.asm SpecialDamage); the move
-- field wins, previously imported caches fall back to the id table
local FIXED_DAMAGE = {
SONICBOOM = 20, DRAGON_RAGE = 40,
SEISMIC_TOSS = "level", NIGHT_SHADE = "level", PSYWAVE = "half_level_rand",
}
MoveEffects.FIXED_DAMAGE = FIXED_DAMAGE
local function fixedDamageFor(ctx)
local spec = ctx.move.fixedDamage
if spec == nil then spec = FIXED_DAMAGE[ctx.move.id] end
if type(spec) == "function" then return spec(ctx) end
if spec == "level" then return ctx.user.mon.level end
if spec == "half_level_rand" then
-- PSYWAVE: rand(1, floor(level*3/2) - 1)
local max = math.max(1, math.floor(ctx.user.mon.level * 3 / 2) - 1)
return ctx.rng(1, max)
end
return spec
end
local function plainInfo()
return { crit = false, typeMult = 10 }
end
-- multi-hit count: the move's multiHit field (a count or a distribution)
-- with the effect's classic distribution as the fallback
local function hitsFrom(dist, ctx)
if type(dist) == "number" then return dist end
local r = ctx.rng(0, #dist - 1)
return dist[r + 1]
end
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
-- that amount, so Counter would see the halved value
local function drainHalf(text)
return function(ctx)
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
ctx.battle.lastDamage = heal
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain()
ctx.say(text:format(displayName(ctx.target)))
end
end
-- fixed damage still respects type immunity (AdjustDamageForMoveType
-- flags the miss before the special-damage override)
local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
end
return nil
end
MoveEffects.full = {
NO_ADDITIONAL_EFFECT = {},
TWO_TO_FIVE_ATTACKS_EFFECT = {
hitCount = function(ctx)
return hitsFrom(ctx.move.multiHit or { 2, 2, 2, 3, 3, 3, 4, 5 }, ctx)
end,
},
ATTACK_TWICE_EFFECT = {
hitCount = function(ctx)
return hitsFrom(ctx.move.multiHit or 2, ctx)
end,
},
-- hits twice AND keeps its secondary poison run (registered below)
TWINEEDLE_EFFECT = {
hitCount = function(ctx)
return hitsFrom(ctx.move.multiHit or 2, ctx)
end,
},
SPECIAL_DAMAGE_EFFECT = {
chooseDamage = function(ctx)
local blocked = immuneMsg(ctx)
if blocked then return nil, blocked end
local dmg = fixedDamageFor(ctx)
if not dmg then return nil, "But, it failed!" end
return dmg, plainInfo()
end,
},
SUPER_FANG_EFFECT = {
chooseDamage = function(ctx)
local blocked = immuneMsg(ctx)
if blocked then return nil, blocked end
return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo()
end,
},
OHKO_EFFECT = {
-- fails against faster opponents (Gen 1 rule) and immune types
gate = function(ctx)
local blocked = immuneMsg(ctx)
if blocked then return false, blocked end
if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then
return false, "But, it failed!"
end
return true
end,
chooseDamage = function()
return 65535, { crit = false, typeMult = 10, ohko = true }
end,
},
RECOIL_EFFECT = {
afterDamage = function(ctx)
-- recoil.asm reads the RAW computed wDamage (not the HP actually
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
end,
},
DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"),
},
DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage)
gate = function(ctx)
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true
end,
afterDamage = drainHalf("%s's\ndream was eaten!"),
},
-- charge moves: first turn just charges; Fly AND Dig go
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
CHARGE_EFFECT = { charge = {} },
FLY_EFFECT = { charge = { invulnerable = true } },
TRAPPING_EFFECT = {
-- TrappingEffect runs BEFORE the hit test and clears the target's
-- Hyper Beam recharge, even if the trapping move then misses
-- (effects.asm:1091-1092 ClearHyperBeam)
beforeAccuracy = function(ctx)
if not ctx.user.trappingTurns then
ctx.target.mustRecharge = nil
end
end,
afterDamage = function(ctx)
local user = ctx.user
if not user.trappingTurns then
-- TrappingEffect (effects.asm:1080-1103) rolls wNumAttacksLeft
-- as 1-4 (weights 3/8 3/8 1/8 1/8): that many CONTINUATION
-- attacks follow this first hit, 2-5 attacks total. The victim
-- is held while the counter runs (live mirror in lockedAction).
local r = ctx.rng(0, 7)
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
user.trapDamage = ctx.rawDamage
-- remember the move so its animation can replay on each locked
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
user.trapMove = ctx.move.id
end
end,
},
THRASH_PETAL_DANCE_EFFECT = {
afterDamage = function(ctx)
local user = ctx.user
if not user.thrashTurns then
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
user.thrashMove = ctx.moveInst
user.thrashAnnounced = true
else
user.thrashTurns = user.thrashTurns - 1
if user.thrashTurns <= 0 then
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5)
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
end
end
end
end,
},
JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
ctx.damage(ctx.user, 1)
end,
},
EXPLODE_EFFECT = {
explode = true, -- Damage.compute halves the defense
onMiss = function(ctx)
ctx.battle:selfDestruct(ctx.user)
end,
afterDamage = function(ctx)
ctx.battle:selfDestruct(ctx.user)
end,
},
HYPER_BEAM_EFFECT = {
afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
ctx.user.mustRecharge = true
end
end,
},
PAY_DAY_EFFECT = {
afterDamage = function(ctx)
local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!")
end,
},
SWIFT_EFFECT = { neverMiss = true },
RAGE_EFFECT = {
afterDamage = function(ctx)
ctx.user.rageMove = ctx.moveInst
end,
},
BIDE_EFFECT = {
perform = function(ctx)
local user = ctx.user
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
end,
},
SWITCH_AND_TELEPORT_EFFECT = {
-- SwitchAndTeleportEffect (effects.asm:810-909): in a wild battle
-- it auto-succeeds when the user's level >= the opponent's;
-- otherwise roll rand[0, userLevel+enemyLevel] and FAIL when the
-- roll is below opponentLevel/4. Teleport's failure text is "But
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
-- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation.
perform = function(ctx)
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
if battle.kind == "wild" then
local uLvl, tLvl = user.mon.level, target.mon.level
local ok = uLvl >= tLvl
if not ok then
ok = ctx.rng(0, uLvl + tLvl) >= math.floor(tLvl / 4)
end
if ok then
if move.id == "ROAR" then
ctx.say(("%s\nran away scared!"):format(displayName(target)))
elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
else
ctx.say(("%s\nran from battle!"):format(displayName(user)))
end
battle.result = "run"
battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
else
battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
end
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
else
battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
end
end,
},
METRONOME_EFFECT = {
callsMove = function(ctx)
local order = ctx.data.constants.moveOrder
local pick
repeat
pick = order[ctx.rng(1, #order)]
until pick ~= "METRONOME" and pick ~= "STRUGGLE" and ctx.data.moves[pick]
return pick
end,
},
MIRROR_MOVE_EFFECT = {
callsMove = function(ctx)
local last = ctx.target.lastMove
if not last then
ctx.say("The MIRROR MOVE\nfailed!")
return nil
end
return last
end,
},
-- Mimic runs its own mid-move flow: hit test, then the copy menu
-- (player) or a random roll (enemy / link), all on the queue.
-- PlayCurrentMoveAnimation runs only after a successful copy
-- (effects.asm:1268), never on a miss -- so no announcement anim row.
MIMIC_EFFECT = {
announceAnim = false,
perform = function(ctx)
ctx.battle:resolveMimic(ctx.user, ctx.target, ctx.move, ctx.moveInst)
end,
},
}
-- ---------------------------------------------------------------------
-- the registry view
-- ---------------------------------------------------------------------
-- effects fully handled inside the damaging pipeline; kept as the v1
-- compat set (BattleState dispatched on it before the records existed)
MoveEffects.special = {
NO_ADDITIONAL_EFFECT = true, TWO_TO_FIVE_ATTACKS_EFFECT = true,
ATTACK_TWICE_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true,
@@ -415,6 +702,40 @@ MoveEffects.special = {
TWINEEDLE_EFFECT = true, MIMIC_EFFECT = true,
}
-- the (battle, user, target, move, moveInst) handlers adapted to the ctx
-- facade the registry records expose
local function shim(fn)
return function(ctx)
return fn(ctx.battle, ctx.user, ctx.target, ctx.move, ctx.moveInst)
end
end
local RECORDS = {}
MoveEffects.RECORDS = RECORDS
for id, fn in pairs(MoveEffects.primary) do
RECORDS[id] = { kind = "primary", run = shim(fn),
accuracyChecked = ACC_CHECKED[id] or nil }
end
for id, fn in pairs(MoveEffects.secondary) do
RECORDS[id] = { kind = "secondary", run = shim(fn) }
end
for id, spec in pairs(MoveEffects.full) do
local record = { kind = "full" }
for key, value in pairs(spec) do record[key] = value end
-- TWINEEDLE: full record with its secondary run honored post-damage
local secondary = MoveEffects.secondary[id]
if secondary then record.run = shim(secondary) end
RECORDS[id] = record
end
-- One record per effect, the same objects performMove dispatches on: the
-- merged Data.move_effects and this table agree by construction.
function MoveEffects.registerInto(registry, _, owner)
for id, record in pairs(RECORDS) do
registry:register(id, record, owner)
end
end
local warned = {}
function MoveEffects.warnUnknown(effect)
+171 -32
View File
@@ -1,9 +1,151 @@
-- Per-turn status/volatile condition handling (Gen 1 semantics).
--
-- The persistent conditions live in Status.RECORDS; a battle passes its
-- merged Data.statuses so mod statuses join the same beforeMove gauntlet
-- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior.
local Status = {}
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion)
function Status.beforeMove(battler, rng)
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname; these records only know the raw name -- BattleState
-- splices the prefix in (prefixEnemy), same as always
local function name(battler)
return battler.name
end
-- statuses with beforeMovePriority above this run before the engine's
-- held/disable/confusion volatiles; at or below, after (sleep 40 and
-- freeze 30 come first, paralysis 10 comes last, like the original
-- CheckPlayerStatusConditions order)
local VOLATILE_PRIORITY = 20
local function hasType(battler, wanted)
for _, t in ipairs(battler.curTypes or {}) do
if t == wanted then return true end
end
return false
end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed)
local function damageOverTime(what)
return function(battler)
local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16))
local dmg = base
if battler.toxicCounter then
dmg = base * battler.toxicCounter
battler.toxicCounter = battler.toxicCounter + 1
end
mon.hp = math.max(0, mon.hp - dmg)
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
end
end
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
-- burn/paralysis stat cut (Damage.compute, TurnOrder.effectiveSpeed) all
-- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = {
SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40,
beforeMove = function(battler)
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
battler.mon.status = nil
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
end
return false, { name(battler) .. "\nis fast asleep!" }
end,
onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) }
end,
},
FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" }
end,
canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) }
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("poison"),
canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display)
if opts.toxic then
target.toxicCounter = 1
-- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) }
end
return { ("%s\nwas poisoned!"):format(display) }
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("the burn"),
canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) }
end,
},
PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10,
beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" }
end
return true, {}
end,
canInflict = function(target, opts)
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
end,
onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
end,
},
}
function Status.registerInto(registry, _, owner)
for id, record in pairs(Status.RECORDS) do
registry:register(id, record, owner)
end
end
-- the merged view when a battle is on hand, the vanilla records otherwise
function Status.recordFor(statuses, id)
if id == nil then return nil end
return (statuses or Status.RECORDS)[id]
end
local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses
end
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion).
-- The active status record's beforeMove runs at its priority slot: above
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
-- freeze), at or below after it (paralysis) -- the original's order.
function Status.beforeMove(battler, rng, battle)
local mon = battler.mon
-- Haze curing this mon's sleep/freeze forfeits its pending move for
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
@@ -14,71 +156,68 @@ function Status.beforeMove(battler, rng)
end
if battler.flinched then
battler.flinched = false
return false, { battler.name .. "\nflinched!" }
return false, { name(battler) .. "\nflinched!" }
end
if mon.status == "SLP" then
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
mon.status = nil
return false, { battler.name .. "\nwoke up!" } -- wakes but loses the turn
end
return false, { battler.name .. "\nis fast asleep!" }
local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove
local priority = handler and (record.beforeMovePriority or 0)
local msgs = {}
local function runStatus()
local canMove, statusMsgs, selfHit = handler(battler, rng, battle)
for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end
return canMove, selfHit
end
if mon.status == "FRZ" then
return false, { battler.name .. "\nis frozen solid!" }
if handler and priority > VOLATILE_PRIORITY then
local canMove, selfHit = runStatus()
if not canMove or selfHit then return canMove, msgs, selfHit end
handler = nil
end
if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1
return false, { battler.name .. "\ncan't move!" }
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
return false, msgs
end
local msgs = {}
if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, battler.name .. "'s\ndisabled no more!")
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
end
end
if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then
battler.confusedTurns = nil
table.insert(msgs, battler.name .. "\nsnapped out of\nconfusion!")
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
else
table.insert(msgs, battler.name .. "\nis confused!")
table.insert(msgs, name(battler) .. "\nis confused!")
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself
end
end
end
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if mon.status == "PAR" and rng(0, 255) < 63 then
table.insert(msgs, battler.name .. "'s\nfully paralyzed!")
return false, msgs
if handler then
local canMove, selfHit = runStatus()
if not canMove or selfHit then return canMove, msgs, selfHit end
end
return true, msgs
end
-- End-of-turn residual damage; opponent is needed for Leech Seed.
-- Returns messages.
function Status.residual(battler, opponent)
function Status.residual(battler, opponent, battle)
local msgs = {}
local mon = battler.mon
-- the Haze move-forfeit only covers the turn Haze was used; if this
-- mon had already moved, drop the flag before it leaks into next turn
battler.skipMove = nil
if mon.hp <= 0 then return msgs end
if mon.status == "PSN" or mon.status == "BRN" then
local base = math.max(1, math.floor(mon.stats.hp / 16))
local dmg = base
if battler.toxicCounter then
dmg = base * battler.toxicCounter
battler.toxicCounter = battler.toxicCounter + 1
local record = Status.recordFor(battleStatuses(battle), mon.status)
if record and record.residual then
for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do
msgs[#msgs + 1] = m
end
mon.hp = math.max(0, mon.hp - dmg)
local what = mon.status == "PSN" and "poison" or "the burn"
table.insert(msgs, ("%s's\nhurt by %s!"):format(battler.name, what))
end
if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then
-- the shared Toxic counter multiplies (and advances on) the seed
@@ -92,7 +231,7 @@ function Status.residual(battler, opponent)
dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(battler.name))
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
end
return msgs
end
+57
View File
@@ -0,0 +1,57 @@
-- Status infliction against the merged statuses registry: the shared
-- immunity rules stay here, the per-status ones live on the records
-- (canInflict) and so does the landing text (onInflict), so a mod status
-- inflicts through the same path as the vanilla five.
local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status")
local StatusRegistry = {}
-- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
end
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
-- secondary (side-effect of a damaging move), source (inflicting move id).
-- Returns messages; empty means the status did not land.
function StatusRegistry.inflict(battle, target, status, opts)
opts = opts or {}
if target.mon.status then return {} end
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
-- their handlers never check the substitute in Gen 1.
if target.substituteHP and (opts.secondary or status == "PSN") then
return {}
end
-- FreezeBurnParalyzeEffect: a secondary status never lands when the
-- move's type matches either of the target's types (Body Slam can't
-- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
if opts.secondary and status ~= "PSN" then
for _, t in ipairs(target.curTypes or {}) do
if opts.moveType == t then return {} end
end
end
local statuses = battle and battle.data and battle.data.statuses
local record = Status.recordFor(statuses, status)
if record and record.canInflict and not record.canInflict(target, opts) then
return {}
end
target.mon.status = status
local msgs
local display = displayName(target)
if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display)
else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
record and record.label or tostring(status)) }
end
Runtime.emit("battle.status_inflicted", {
battle = battle, target = target, status = status, source = opts.source,
})
return msgs
end
return StatusRegistry
+78 -26
View File
@@ -24,13 +24,25 @@ local TrainerAI = {}
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
-- The trainer's ai_classes record from the merged registry; the direct
-- require covers battles built without a loader. A trainer record's
-- aiClass field picks a record other than its own id.
function TrainerAI.classFor(battle)
local trainer = battle and battle.trainer
if not trainer then return nil end
local id = trainer.aiClass or trainer.id
local classes = battle.data and battle.data.ai_classes
if classes then return classes[id] end
return require("data.scripts.ai_classes")[id]
end
-- Item use / switching per trainer class (engine/battle/trainer_ai.asm
-- via data/scripts/ai_classes.lua). Runs before move choice each enemy
-- via the ai_classes registry). Runs before move choice each enemy
-- turn; returns an action { special = "aiItem"/"aiSwitch", ... } or nil.
-- battle.aiUses is initialized per enemy Pokémon (wAICount).
function TrainerAI.classAction(battle)
if battle.kind ~= "trainer" or not battle.trainer then return nil end
local class = require("data.scripts.ai_classes")[battle.trainer.id]
local class = TrainerAI.classFor(battle)
if not class then return nil end
if (battle.aiUses or 0) <= 0 then return nil end
local rng = battle.rng
@@ -154,6 +166,50 @@ local function hasBetterMove(battler, judged, battle)
return false
end
-- The three vanilla passes as ai_classes layer records: vanilla is just the
-- first registrant, so a mod patches one instead of reimplementing trainer
-- AI. src/mods/Builtins.lua registers them; chooseMove dispatches through
-- whatever the registry holds and falls back here when a battle was built
-- without a loader. view.encourageTurn is wAILayer2Encouragement == 1.
TrainerAI.LAYERS = {
LAYER_1 = { kind = "layer", score = function(view, def, score)
-- `add $5`: heavily discourage a zero-power status move that would
-- fail because the player is already statused
if def and view.target.mon.status and def.power == 0
and STATUS_EFFECTS[def.effect] then
return score + 5
end
return score
end },
LAYER_2 = { kind = "layer", score = function(view, def, score)
if def and view.encourageTurn and ENCOURAGE_EFFECTS[def.effect] then
return score - 1 -- `dec [hl]`: slightly encourage
end
return score
end },
-- AIGetTypeEffectiveness only reads the FIRST matching TypeEffects row for
-- (move type vs either defender type) -- no dual-type product -- and runs
-- for non-damaging moves too. The table holds no value-10 rows, so
-- >10 / <10 reproduces the oracle's compare against $10.
LAYER_3 = { kind = "layer", score = function(view, def, score)
if not def then return score end
local row = TypeChart.rows(def.type, view.target.curTypes)[1]
if row and row > 10 then
return score - 1 -- `dec [hl]`: encourage a super-effective move
elseif row and row < 10 and hasBetterMove(view.user, def, view.battle) then
return score + 1 -- `inc [hl]`: discourage when a better move is known
end
return score
end },
}
-- vanilla registrations, kept beside the other Builtins delegations
function TrainerAI.registerInto(registry, _, owner)
for id, record in pairs(TrainerAI.LAYERS) do
registry:register(id, record, owner)
end
end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
local usable = {}
@@ -179,40 +235,36 @@ function TrainerAI.chooseMove(battler, rng, battle)
return usable[rng(1, #usable)]
end
-- aiMods entries may name registered ai_classes layer records; a number n
-- resolves through "LAYER_<n>", which is how the vanilla three are keyed.
-- A battle built without a loader has no merged registry, so the built-in
-- records answer directly.
local classes = battle.data and battle.data.ai_classes
local layers, view = {}, nil
for _, mod in ipairs(mods) do
local id = type(mod) == "string" and mod or ("LAYER_" .. tostring(mod))
local record = classes and classes[id]
if not (record and record.score) then record = TrainerAI.LAYERS[id] end
if record and record.score then
layers[#layers + 1] = record.score
view = view or { battle = battle, user = battler, target = battle.player,
data = battle.data, rng = rng,
encourageTurn = encourageTurn }
end
end
-- AIEnemyTrainerChooseMoves (engine/battle/trainer_ai.asm:3-257): every
-- usable move starts at a base score of 10; the class's modification
-- functions adjust it additively, then the MINIMUM-scored move is chosen
-- with ties broken uniformly among the minima (core.asm:2971-3002 rolls a
-- fresh byte among the value-1 slots). A non-minimal move is never
-- selectable.
local target = battle.player
local scores = {}
for i, mv in ipairs(usable) do
local def = battle.data.moves[mv.id]
local s = 10
for _, mod in ipairs(mods) do
if mod == 1 and def and target.mon.status
and def.power == 0 and STATUS_EFFECTS[def.effect] then
-- AIMoveChoiceModification1: `add $5` -- heavily discourage a
-- zero-power status move that would fail (player already statused)
s = s + 5
elseif mod == 2 and def and encourageTurn
and ENCOURAGE_EFFECTS[def.effect] then
-- AIMoveChoiceModification2: `dec [hl]` -- slightly encourage
s = s - 1
elseif mod == 3 and def then
-- AIMoveChoiceModification3 via AIGetTypeEffectiveness only reads
-- the FIRST matching TypeEffects row for (move type vs either
-- defender type) -- no dual-type product -- and runs for
-- non-damaging moves too. The table holds no value-10 rows, so
-- >10 / <10 reproduces the oracle's compare against $10.
local row = TypeChart.rows(def.type, target.curTypes)[1]
if row and row > 10 then
s = s - 1 -- `dec [hl]`: encourage a super-effective move
elseif row and row < 10 and hasBetterMove(battler, def, battle) then
s = s + 1 -- `inc [hl]`: discourage when a better move is known
end
end
for _, score in ipairs(layers) do
s = score(view, def, s) or s
end
scores[i] = s
end
+30 -15
View File
@@ -1,31 +1,46 @@
-- Turn order, from engine/battle/core.asm MainInBattleLoop: compare
-- effective speed; ties are a coin flip. QUICK_ATTACK moves first and
-- COUNTER last (Gen 1 has only these two priority moves, checked by id).
-- effective speed; ties are a coin flip. Move priority reads the move
-- record's priority field; the id table below covers pre-existing
-- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last).
local Damage = require("src.battle.Damage")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local TurnOrder = {}
local function effectiveSpeed(battler)
local spd = Stats.applyStage(battler.curStats.speed,
battler.stages and battler.stages.speed or 0)
-- ApplyBadgeStatBoosts: the SOULBADGE (bit 4) boosts speed
if battler.badges and battler.badges.SOULBADGE then
spd = math.floor(spd * 9 / 8)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback
local badges = battler.badges
if badges then
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == "speed" and badges[row.badge] then
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
break
end
end
end
-- paralysis quarters speed; hazeStatReset suppresses it because Haze
-- (haze.asm ResetStats) copied the unmodified speed over the quartered
-- battle stat, lifting the penalty until the next stat recompute.
if battler.mon.status == "PAR" and not battler.hazeStatReset then
spd = math.max(1, math.floor(spd / 4))
-- paralysis quarters speed (the status record's statPenalty);
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
-- copied the unmodified speed over the quartered battle stat, lifting
-- the penalty until the next stat recompute.
local record = Status.recordFor(battler.statuses, battler.mon.status)
local penalty = record and record.statPenalty
if penalty and penalty.stat == "speed" and not battler.hazeStatReset then
spd = math.max(1, math.floor(spd / penalty.div))
end
return spd
end
local function priority(moveId)
if moveId == "QUICK_ATTACK" then return 1 end
if moveId == "COUNTER" then return -1 end
return 0
local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 }
local function priority(move)
if not move then return 0 end
if move.priority then return move.priority end
return PRIORITY[move.id] or 0
end
-- Returns true when battler a moves before battler b. invertTie flips
@@ -34,7 +49,7 @@ end
-- who moves first.
function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie)
rng = rng or love.math.random
local pa, pb = priority(aMove and aMove.id), priority(bMove and bMove.id)
local pa, pb = priority(aMove), priority(bMove)
if pa ~= pb then return pa > pb end
local sa, sb = effectiveSpeed(a), effectiveSpeed(b)
if sa ~= sb then return sa > sb end
+51
View File
@@ -6,6 +6,7 @@ local TypeChart = {}
local index -- [atk][def] -> x10 multiplier
local matchups -- ROM-ordered TypeEffects rows
local types -- merged type records (physical/special category, display name)
function TypeChart.load(data)
index = {}
@@ -14,6 +15,21 @@ function TypeChart.load(data)
index[m.attacker] = index[m.attacker] or {}
index[m.attacker][m.defender] = m.multiplier
end
types = data.type_chart.types
end
-- the merged type record's category; falls back to the vanilla records
-- so pure-module callers need no load
function TypeChart.category(typeId)
local record = types and types[typeId] or TypeChart.TYPES[typeId]
return record and record.category or nil
end
-- display name for the move-select TYPE/ box (mod types render their
-- name instead of their raw id)
function TypeChart.displayName(typeId)
local record = types and types[typeId] or TypeChart.TYPES[typeId]
return record and record.name or typeId
end
-- The x10 multipliers of every TypeEffects row that applies, in ROM
@@ -52,4 +68,39 @@ function TypeChart.effectiveness(moveType, defenderTypes)
return mult
end
-- Gen 1 splits physical from special by TYPE, not by move: the seven types
-- from FIRE up are special (engine/battle/effect_commands.asm compares the
-- type id against SPECIAL). The list Damage.isSpecial carries is the same
-- one, restated here as the type records the type_chart registry serves.
TypeChart.TYPES = {
NORMAL = { name = "NORMAL", category = "physical" },
FIGHTING = { name = "FIGHTING", category = "physical" },
FLYING = { name = "FLYING", category = "physical" },
POISON = { name = "POISON", category = "physical" },
GROUND = { name = "GROUND", category = "physical" },
ROCK = { name = "ROCK", category = "physical" },
BUG = { name = "BUG", category = "physical" },
GHOST = { name = "GHOST", category = "physical" },
FIRE = { name = "FIRE", category = "special" },
WATER = { name = "WATER", category = "special" },
GRASS = { name = "GRASS", category = "special" },
ELECTRIC = { name = "ELECTRIC", category = "special" },
PSYCHIC_TYPE = { name = "PSYCHIC", category = "special" },
ICE = { name = "ICE", category = "special" },
DRAGON = { name = "DRAGON", category = "special" },
}
-- The matchup rows come from the generated chart, so a dataset with a
-- different table registers a different world without touching this file.
function TypeChart.registerInto(registry, data, owner)
for id, record in pairs(TypeChart.TYPES) do
registry:register(id, record, owner)
end
local chart = data and data.type_chart
for _, row in ipairs(chart and chart.matchups or {}) do
registry:register(row.attacker .. ">" .. row.defender,
{ multiplier = row.multiplier }, owner)
end
end
return TypeChart
+85 -14
View File
@@ -1,4 +1,5 @@
local bit = require("bit")
local Assets = require("src.render.Assets")
local ChipAudio = {}
@@ -48,6 +49,22 @@ local function loadBanks(data)
return banks
end
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
-- target work unchanged. The ROM's own cached bank table is never touched
-- because bank 0 differs per def, and a blob that carries its own waves and
-- drums renders even where programs.bin is unreadable.
local function engineBanks(data, chip)
if not chip then return loadBanks(data) end
local banks = {}
local ok, romBanks = pcall(loadBanks, data)
if ok then
for bank, bytes in pairs(romBanks) do banks[bank] = bytes end
end
banks[0] = chip.blob
return banks
end
local function romByte(banks, bank, address)
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
local value = bytes:byte(address - 0x4000 + 1)
@@ -497,6 +514,8 @@ function Channel:sample()
if event.wave then
local wave = self.engine.waves[
math.min(event.waveInstrument + 1, #self.engine.waves)]
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55
end
@@ -511,6 +530,9 @@ local Engine = {}
Engine.__index = Engine
function Engine:noiseInstrument(number)
-- a def-local drum wins over the ROM engine's table for that id
local custom = self.customDrums and self.customDrums[number]
if custom then return custom end
local cached = self.noiseInstruments[number]
if cached then return cached end
@@ -571,20 +593,55 @@ local function readWaves(banks, audio, engineNumber)
return waves
end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter
local function normalizeWaves(source)
local waves = {}
for index, values in ipairs(source) do
local nibbles = false
for _, value in ipairs(values) do
if value > 1 or value < -1 then nibbles = true break end
end
local wave = {}
for position, value in ipairs(values) do
wave[position] = nibbles and (value - 7.5) / 7.5 or value
end
waves[index] = wave
end
return waves
end
function Engine.new(data, header, options)
options = options or {}
local banks = loadBanks(data)
local audio = data.audio or {}
-- shape dispatch: a def-local chip program supplies its own channels and
-- may supply its own waves/drums, falling back to a ROM engine's tables
local chip = header.chip
local banks = engineBanks(data, chip)
local engineNumber = chip and (chip.engine or 1) or header.engine
local waves
if chip and chip.waves then
waves = normalizeWaves(chip.waves)
elseif chip then
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
waves = ok and romWaves or {}
else
waves = readWaves(banks, audio, engineNumber)
end
local engine = setmetatable({
banks = banks,
tempo = 0x100,
pan = 0xFF,
waves = readWaves(banks, data.audio, header.engine),
noiseHeaders = data.audio.noiseHeaders
and data.audio.noiseHeaders[tostring(header.engine)] or {},
waves = waves,
noiseHeaders = audio.noiseHeaders
and audio.noiseHeaders[tostring(engineNumber)] or {},
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
}, Engine)
for _, spec in ipairs(headerChannels(banks, header)) do
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
@@ -593,7 +650,7 @@ function Engine.new(data, header, options)
frameTicks = 0x80 + options.cryLength
end
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
bank = header.bank,
bank = chip and 0 or header.bank,
sfx = options.sfx,
allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset,
@@ -663,14 +720,14 @@ local function fillMusic()
end
function ChipAudio.playMusic(data, header, allowLoops)
ChipAudio.stopMusic()
-- build before tearing down: a def that fails to compile (bad addresses,
-- unreadable blob) must leave the outgoing song sounding
local engine = Engine.new(data, header, { allowLoops = allowLoops })
local ok, source = pcall(
love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT)
if not ok then return nil, source end
currentMusic = {
source = source,
engine = Engine.new(data, header, { allowLoops = allowLoops }),
}
ChipAudio.stopMusic()
currentMusic = { source = source, engine = engine }
fillMusic()
source:play()
return source
@@ -700,6 +757,17 @@ function ChipAudio.stopMusic()
currentMusic = nil
end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped
-- the file out from under the single-slot bank cache)
function ChipAudio.invalidate()
ChipAudio.stopMusic()
cachedProgramFile, cachedBanks = nil, nil
end
-- a stale song must not keep sounding past the flush that replaced its
-- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate)
local function renderEffect(data, header, options)
if not header then return nil end
options = options or {}
@@ -796,10 +864,13 @@ function ChipAudio.newSfx(data, name, pitch, tempo, header)
})
end
function ChipAudio.newCry(data, species)
local cry = data.audio.cries[species]
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
-- out -- a derived cry borrowing another species' header with its own
-- modifiers, which no registry lookup under `species` could find
function ChipAudio.newCry(data, species, resolved)
local cry = resolved or (data.audio.cries and data.audio.cries[species])
if not cry then return nil end
return renderEffect(data, cry.header, {
return renderEffect(data, cry.chip and cry or cry.header, {
frequencyOffset = cry.pitch,
cryLength = cry.length,
})
+169 -2
View File
@@ -14,10 +14,137 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
levelCap = 100,
coinCap = 9999, -- MAX_COINS (src/ui/SlotMachine.lua)
-- move-slot repair when a scrub empties a mon (src/core/SaveData.lua);
-- a total conversion without TACKLE patches this to its own floor
fallbackMove = "TACKLE",
hmMoves = { "CUT", "FLY", "SURF", "STRENGTH", "FLASH" }, -- IsMoveHM
-- gym order (data/scripts/victories.lua); list position is the badge
-- number the trainer card draws
badges = {
{ id = "BOULDERBADGE" }, { id = "CASCADEBADGE" }, { id = "THUNDERBADGE" },
{ id = "RAINBOWBADGE" }, { id = "SOULBADGE" }, { id = "MARSHBADGE" },
{ id = "VOLCANOBADGE" }, { id = "EARTHBADGE" },
},
}
-- field.boot is the total-conversion override point for the new game; the
-- values match what SaveData.newGame and the Oak speech used to inline.
local BOOT_DEFAULTS = {
-- special_warps.asm NewGameWarp: REDS_HOUSE_2F, 3, 6 -- the bedroom, not
-- the tile outside the house. lastHeal is deliberately absent: SaveData
-- derives the vanilla blackout point, and seeding it here would leak into
-- total conversions that patch the spawn without naming a heal point.
startMap = "REDS_HOUSE_2F", startX = 3, startY = 6, startFacing = "down",
playerName = "RED", rivalName = "BLUE",
startMoney = 3000,
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
}
local function copy(value)
if type(value) ~= "table" then return value end
local out = {}
for k, v in pairs(value) do out[k] = copy(v) end
return out
end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
local constants = self.constants
for key, value in pairs(CONSTANT_DEFAULTS) do
if constants[key] == nil then constants[key] = copy(value) end
end
-- derived, not literal: a dataset with a different roster gets the right
-- upper bound without 151 being written down anywhere
if constants.dexSize == nil then
local highest = 0
for _, def in pairs(self.pokemon) do
if def.dex and def.dex > highest then highest = def.dex end
end
constants.dexSize = highest
end
if constants.dexDigits == nil then
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
end
local boot = self.field.boot
if boot == nil then
boot = {}
self.field.boot = boot
end
for key, value in pairs(BOOT_DEFAULTS) do
if boot[key] == nil then boot[key] = copy(value) end
end
-- the naming screen presets the importer already extracts but nothing
-- ever read (field.presetNames)
if boot.namePresets == nil then
local presets = self.field.presetNames or {}
boot.namePresets = {
player = copy(presets.player) or { "RED", "ASH", "JACK" },
rival = copy(presets.rival) or { "BLUE", "GARY", "JOHN" },
}
end
-- the overworld's Kanto literals, same fill-if-absent contract; required
-- here rather than at the top so core keeps out of src/world at load time
require("src.world.FieldDefaults").seed(self)
-- Cinnabar's quiz trainers are text_asm (no def_trainers), so the
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
local gym = {}
for i = 0, 6 do
local n = i + 1
-- object indices 2..8 are SUPER_NERD1..7; range 0 -- they only
-- engage via talk / wrong quiz answer, never sight lines
gym[i + 2] = {
event = "EVENT_BEAT_CINNABAR_GYM_TRAINER_" .. i,
range = 0,
battle = "_CinnabarGymSuperNerd" .. n .. "BattleText",
won = "_CinnabarGymSuperNerd" .. n .. "EndBattleText",
after = "_CinnabarGymSuperNerd" .. n .. "AfterBattleText",
}
end
headers.CinnabarGym = gym
end
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
-- ROM-free fixture set, tests/fixture_data); unset -- every shipped build
-- -- the generated modules load exactly as before. loadfile skips the
-- require cache, so each overridden load hands back fresh tables.
local function loadModule(dir, name)
if dir then
local chunk, err = loadfile(dir .. "/" .. name .. ".lua")
if not chunk then return false, err end
return pcall(chunk)
end
return pcall(require, "data.generated." .. name)
end
function Data:load()
local dir = os.getenv("POKEPORT_DATA_DIR")
for _, name in ipairs(MODULES) do
local ok, mod = pcall(require, "data.generated." .. name)
local ok, mod = loadModule(dir, name)
if not ok then
if dir then
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
:format(dir, name, mod))
end
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
@@ -25,18 +152,58 @@ function Data:load()
self[name] = mod
end
for _, name in ipairs(OPTIONAL) do
local ok, mod = pcall(require, "data.generated." .. name)
local ok, mod = loadModule(dir, name)
self[name] = ok and mod or nil
if not ok then
Logger.warn("optional data module '%s' missing (feature disabled)", name)
end
end
-- before the mod loader runs: the deep registries fold over these
self:seedDefaults()
-- the top-level keys a pristine load leaves behind, so reloadGenerated can
-- strip whatever a mod merge added since; kept on self (assigned before the
-- scan so it counts itself) because tests load other tables through this
-- method, and a shared upvalue would let them clobber the singleton's set
local pristine = {}
self._pristineKeys = pristine
for key in pairs(self) do pristine[key] = true end
Logger.info("generated data loaded (%d maps, %d species, %d moves)",
(function() local n = 0 for _ in pairs(self.maps) do n = n + 1 end return n end)(),
(function() local n = 0 for _ in pairs(self.pokemon) do n = n + 1 end return n end)(),
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
local pristine = self._pristineKeys
if pristine then
for key in pairs(self) do
if not pristine[key] then self[key] = nil end
end
end
for _, name in ipairs(MODULES) do
package.loaded["data.generated." .. name] = nil
end
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
end
self:load()
end
-- Resolve a dotted target path, creating empty tables on the way. Only the
-- mod merge calls this; a vanilla boot never does, so an unmodded Data
-- table is byte-identical to a pre-registry-v2 one.
function Data.ensure(data, path)
local node = data
for key in path:gmatch("[^%.]+") do
if node[key] == nil then node[key] = {} end
node = node[key]
end
return node
end
-- Resolve a TEXT_* constant on a map to a plain string (or nil if the text
-- needs a hand-ported script; see data/scripts/).
function Data:resolveText(mapLabel, textConst)
+658
View File
@@ -0,0 +1,658 @@
-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's
-- current map (and battle status) on their Discord profile. Desktop only;
-- no-ops on mobile, headless runs, or when Discord is not running.
--
-- Application ID is the only credential needed for presence. The Discord
-- Interactions public key is unused here (that is for verifying bot HTTP
-- payloads, not Rich Presence).
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local DiscordPresence = {
APP_ID = "1529183141267374262",
}
local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2
local MIN_UPDATE_INTERVAL = 2.0
local RECONNECT_INTERVAL = 30.0
local state = {
enabled = false,
connected = false,
socket = nil, -- Windows: file handle; Unix: ffi sockfd
isWindows = false,
ffi = nil,
ffiReady = false,
startedAt = 0,
dirty = false,
lastSentKey = nil,
lastSendAt = -1e9,
nextReconnectAt = 0,
location = nil, -- display name
mapId = nil,
activity = "menu", -- menu | exploring | battle
battleLabel = nil,
unsubs = {},
pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running
}
local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
or os.getenv("POKEPORT_DRIVER") ~= nil
end
local function isDesktop()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "OS X" or osName == "Windows" or osName == "Linux"
end
local function packU32(n)
n = math.floor(n) % 0x100000000
local b1 = n % 256; n = math.floor(n / 256)
local b2 = n % 256; n = math.floor(n / 256)
local b3 = n % 256; n = math.floor(n / 256)
local b4 = n % 256
return string.char(b1, b2, b3, b4)
end
local function unpackU32(s)
local b1, b2, b3, b4 = s:byte(1, 4)
return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
end
local function nonce()
return string.format("%08x-%04x-4%03x-%04x-%04x%08x",
math.random(0, 0xffffffff),
math.random(0, 0xffff),
math.random(0, 0xfff),
math.random(0, 0x3fff) + 0x8000,
math.random(0, 0xffff),
math.random(0, 0xffffffff))
end
local function resolvePid()
if state.pid then return state.pid end
local ffi = state.ffi
if ffi then
if state.isWindows then
pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);")
local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
else
pcall(ffi.cdef, "int getpid(void);")
local ok, p = pcall(function() return ffi.C.getpid() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
end
end
state.pid = 1000 + math.random(1, 8999)
return state.pid
end
local function ensureFfi()
if state.ffiReady then return state.ffi end
if state.ffi == false then return nil end
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then
state.ffi = false
return nil
end
state.ffi = ffi
if state.isWindows then
state.ffiReady = true
return ffi
end
-- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family.
local osName = love.system.getOS()
local cdef
if osName == "OS X" then
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
uint8_t sun_len;
uint8_t sun_family;
char sun_path[104];
};
struct timeval { long tv_sec; int tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
else
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
unsigned short sun_family;
char sun_path[108];
};
struct timeval { long tv_sec; long tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
end
-- cdef may already be registered from a prior boot; either way we need
-- sockaddr_un usable before claiming readiness
pcall(ffi.cdef, cdef)
local ready = pcall(function() return ffi.new("struct sockaddr_un") end)
if not ready then
state.ffi = false
state.ffiReady = false
return nil
end
state.ffiReady = true
return ffi
end
local function closeSocket()
local sock = state.socket
state.socket = nil
state.connected = false
if not sock then return end
if state.isWindows then
pcall(function() sock:close() end)
elseif state.ffi then
pcall(state.ffi.C.close, sock)
end
end
local function writeRaw(bytes)
if not state.socket then return false end
if state.isWindows then
local ok = pcall(function()
state.socket:seek("end")
local _, writeErr = state.socket:write(bytes)
state.socket:flush()
if writeErr then error(writeErr) end
end)
return ok
end
local ffi = state.ffi
if not ffi then return false end
local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0)
return ok and sent == #bytes
end
local function sendFrame(opcode, payload)
local body = payload or ""
local ok, frame = pcall(function()
return packU32(opcode) .. packU32(#body) .. body
end)
if not ok or not frame then return false end
return writeRaw(frame)
end
local function readExact(n)
if not state.socket or n <= 0 then return nil end
if state.isWindows then
local ok, data = pcall(function() return state.socket:read(n) end)
if not ok or not data or #data < n then return nil end
return data
end
local ffi = state.ffi
if not ffi then return nil end
local ok, data = pcall(function()
local buf = ffi.new("char[?]", n)
local got = 0
while got < n do
local nread = ffi.C.recv(state.socket, buf + got, n - got, 0)
if not nread or nread <= 0 then return nil end
got = got + tonumber(nread)
end
return ffi.string(buf, n)
end)
if not ok then return nil end
return data
end
local function receiveFrame()
local header = readExact(8)
if not header then return nil end
local ok, opcode, length = pcall(function()
return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8))
end)
if not ok or not opcode or not length or length < 0 or length > 65536 then
return nil
end
local data = length > 0 and readExact(length) or ""
if data == nil then return nil end
return opcode, data
end
local function unixSocketPaths()
local paths = {}
local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }
local bases = {}
for _, key in ipairs(envs) do
local v = os.getenv(key)
if v and v ~= "" then
if v:sub(-1) == "/" then v = v:sub(1, -2) end
bases[#bases + 1] = v
end
end
bases[#bases + 1] = "/tmp"
local suffixes = {
"",
"/app/com.discordapp.Discord",
"/app/com.discordapp.DiscordCanary",
"/app/com.discordapp.DiscordPTB",
"/snap.discord",
"/snap.discord-canary",
}
local seen = {}
for i = 0, 9 do
local name = "discord-ipc-" .. i
for _, base in ipairs(bases) do
for _, suffix in ipairs(suffixes) do
local path = base .. suffix .. "/" .. name
if not seen[path] then
seen[path] = true
paths[#paths + 1] = path
end
end
end
end
return paths
end
local function setSocketTimeout(fd, seconds)
local ffi = state.ffi
if not ffi or not fd then return end
pcall(function()
local tv = ffi.new("struct timeval")
tv.tv_sec = seconds
tv.tv_usec = 0
-- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS
local sol, rcv, snd
if love.system.getOS() == "OS X" then
sol, rcv, snd = 0xffff, 0x1006, 0x1005
else
sol, rcv, snd = 1, 20, 21
end
ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv))
ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv))
end)
end
local function connectUnix()
local ffi = ensureFfi()
if not ffi then return false end
local darwin = love.system.getOS() == "OS X"
local maxPath = darwin and 103 or 107
-- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS
local pathsOk, paths = pcall(unixSocketPaths)
if not pathsOk or not paths then return false end
for _, path in ipairs(paths) do
if #path <= maxPath then
local ok, connected = pcall(function()
local fd = ffi.C.socket(1, 1, 0)
if not fd or fd < 0 then return false end
local addr = ffi.new("struct sockaddr_un")
addr.sun_family = 1
if darwin then
-- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL
addr.sun_len = 2 + #path + 1
end
ffi.copy(addr.sun_path, path)
local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr))
if rc == 0 then
setSocketTimeout(fd, 1) -- handshake must not freeze the game
state.socket = fd
return true
end
ffi.C.close(fd)
return false
end)
if ok and connected then return true end
end
end
return false
end
local function connectWindows()
for i = 0, 9 do
local path = "\\\\.\\pipe\\discord-ipc-" .. i
local ok, file = pcall(io.open, path, "r+")
if ok and file then
state.socket = file
return true
end
end
return false
end
local function handshake()
local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID })
if not ok or not payload then return false end
if not sendFrame(OP_HANDSHAKE, payload) then return false end
-- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss.
local opcode = receiveFrame()
if opcode == OP_FRAME then return true end
-- Windows named pipes have no easy read timeout: if the write succeeded
-- and Discord is present, proceed optimistically so boot never blocks.
if state.isWindows then return true end
return false
end
local function noteAbsent()
if state.loggedAbsent then return end
state.loggedAbsent = true
-- info, not warn: missing Discord is normal for many players
Logger.info("discord: rich presence unavailable (Discord not running)")
end
local function connect()
local ok, result = pcall(function()
closeSocket()
local linked
if state.isWindows then
linked = connectWindows()
else
linked = connectUnix()
end
if not linked then return false end
if not handshake() then
closeSocket()
return false
end
state.connected = true
state.dirty = true
state.lastSentKey = nil
state.loggedAbsent = false
Logger.info("discord: rich presence connected")
return true
end)
if not ok then
closeSocket()
return false
end
return result and true or false
end
local function locationName(game, mapId)
if not mapId then return nil end
local field = game and game.data and game.data.field
local townMap = field and field.townMap
local locations = townMap and (townMap.locations or townMap)
local entry = type(locations) == "table" and locations[mapId]
if type(entry) == "table" then
local name = entry.name or entry.label
if type(name) == "string" and name ~= "" then return name end
end
local def = game and game.data and game.data.maps and game.data.maps[mapId]
if def and type(def.label) == "string" and def.label ~= "" then
-- PalletTown -> Pallet Town
return def.label:gsub("(%l)(%u)", "%1 %2")
end
return tostring(mapId):gsub("_", " ")
end
local function speciesName(game, species)
if not species or not game or not game.data or not game.data.pokemon then
return nil
end
local def = game.data.pokemon[species]
return def and def.name or tostring(species):gsub("_", " ")
end
local function buildActivity()
local details, activityState
if state.activity == "battle" then
details = state.battleLabel or "In battle"
activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then
details = state.location
activityState = "Exploring the overworld"
else
details = state.location or "Kanto"
activityState = "At the title screen"
end
return {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
assets = {
large_image = "logo",
large_text = "Pokemon Gen1Recomp",
},
}
end
local function activityKey(activity)
return (activity.details or "") .. "|" .. (activity.state or "")
end
local function flush(force)
if not state.enabled or not state.connected then return end
local ok = pcall(function()
local now = love.timer.getTime()
if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then
state.dirty = true
return
end
local activity = buildActivity()
local key = activityKey(activity)
if not force and key == state.lastSentKey then
state.dirty = false
return
end
local payload = Json.encode({
cmd = "SET_ACTIVITY",
args = {
pid = resolvePid(),
activity = activity,
},
nonce = nonce(),
})
if not sendFrame(OP_FRAME, payload) then
closeSocket()
state.nextReconnectAt = now + RECONNECT_INTERVAL
return
end
state.lastSentKey = key
state.lastSendAt = now
state.dirty = false
end)
if not ok then
closeSocket()
local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end
local function setPresence(fields)
-- event listeners must never throw into the overworld/battle path
pcall(function()
if fields.location ~= nil then state.location = fields.location end
if fields.mapId ~= nil then state.mapId = fields.mapId end
if fields.activity ~= nil then state.activity = fields.activity end
if fields.clearBattle then
state.battleLabel = nil
elseif fields.battleLabel ~= nil then
state.battleLabel = fields.battleLabel
end
state.dirty = true
flush(false)
end)
end
local function subscribe(game)
local events = Runtime.events
if not events or not events.on then return end
local function track(name, fn)
state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord")
end
track("map.entered", function(ev)
local mapId = ev and ev.mapId
setPresence({
mapId = mapId,
location = locationName(game, mapId),
activity = "exploring",
clearBattle = true,
})
end)
track("battle.started", function(ev)
local battle = ev and ev.battle
local label = "In battle"
if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species)
if name then
label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else
label = "Wild battle"
end
elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle"
elseif ev and ev.kind == "link" then
label = "Link battle"
end
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = "battle",
battleLabel = label,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("battle.ended", function()
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = mapId and "exploring" or "menu",
clearBattle = true,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("screen.pushed", function(ev)
local s = ev and ev.state
local id = s and s.screenId
if not id and s and s.onNewGame then id = "TitleState" end
if id == "TitleState" or id == "IntroMovie" then
state.mapId = nil
setPresence({
activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
clearBattle = true,
})
end
end)
end
function DiscordPresence.init(game)
local ok, err = pcall(function()
DiscordPresence.shutdown()
if disabledByEnv() or not isDesktop() then return end
state.enabled = true
state.isWindows = love.system.getOS() == "Windows"
state.startedAt = os.time()
state.activity = "menu"
state.location = "Title screen"
state.mapId = nil
state.battleLabel = nil
state.dirty = true
state.nextReconnectAt = 0
state.loggedAbsent = false
if not state.isWindows and not ensureFfi() then
-- no FFI / broken cdef: stay disabled, never throw
state.enabled = false
return
end
subscribe(game)
if connect() then
flush(true)
else
noteAbsent()
local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end)
if not ok then
closeSocket()
state.enabled = false
-- still soft: a broken presence stack must not abort boot
Logger.info("discord: rich presence disabled (%s)", tostring(err))
end
end
function DiscordPresence.update(_dt)
if not state.enabled then return end
pcall(function()
local now = love.timer.getTime()
if not state.connected then
if now >= state.nextReconnectAt then
state.nextReconnectAt = now + RECONNECT_INTERVAL
if connect() then
flush(true)
else
noteAbsent()
end
end
return
end
if state.dirty then flush(false) end
end)
end
function DiscordPresence.shutdown()
pcall(function()
for _, unsub in ipairs(state.unsubs) do
pcall(unsub)
end
state.unsubs = {}
if state.connected then
pcall(function()
-- omit activity to clear the presence
sendFrame(OP_FRAME, Json.encode({
cmd = "SET_ACTIVITY",
args = { pid = resolvePid() },
nonce = nonce(),
}))
sendFrame(OP_CLOSE, "{}")
end)
end
closeSocket()
end)
state.enabled = false
state.dirty = false
state.lastSentKey = nil
state.connected = false
state.socket = nil
end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
return DiscordPresence
+8 -1
View File
@@ -12,8 +12,15 @@ function FixedStep:init(callback)
self.callback = callback
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
-- steps), which silently throttled the high fast-forward levels: at 100X
-- a 60fps frame wants ~100 steps. Game:update raises this to fit the
-- current speed target; a stall still cannot snowball past one frame's
-- intended budget.
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt)
self.accum = math.min(self.accum + dt, MAX_ACCUM)
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
self.callback(self.STEP)
+174 -20
View File
@@ -10,9 +10,22 @@ local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchInput = require("src.core.TouchInput")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Game = {}
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
-- module unloaded, so a player boot never touches a byte of dev code
local devMode = os.getenv("POKEPORT_DEV") == "1"
-- the boot screen ids (field.boot.screens); a plain function so the
-- headless harness can borrow makeTitleState onto a stub game
local function bootScreens(game)
local boot = game.data and game.data.field and game.data.field.boot
return (boot and boot.screens) or {}
end
function Game:load()
self.data = Data
Data:load()
@@ -35,11 +48,17 @@ function Game:load()
Renderer:init()
require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data)
self.stack = StateStack
StateStack:init()
self.save = SaveData.newGame()
self.save = SaveData.newGame(self:bootConfig())
-- seed=true keeps what entry chunks wrote through mod.save before any
-- save existed; the skeleton fires save.created exactly once
self:adoptSave(self.save, true)
ModRuntime.emit("save.created", { save = self.save })
-- apply the persisted audio + display options before anything plays
self:applyOptions(self.save.options)
@@ -49,6 +68,14 @@ function Game:load()
local OverworldState = require("src.world.OverworldController")
self.overworld = OverworldState
-- Discord Rich Presence: map name / battle status on the player's profile.
-- Soft-fail: missing Discord / IPC errors must never block boot.
pcall(function() require("src.core.DiscordPresence").init(self) end)
-- every service is up but nothing is on the stack yet; this payload is
-- the sanctioned way for a mod to obtain the Game object
ModRuntime.emit("game.ready", { game = self })
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
-- the Oak speech + naming, CONTINUE restores the save. The headless
-- autopilot skips straight into the overworld.
@@ -58,40 +85,53 @@ function Game:load()
else
local titleState = self:makeTitleState()
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
-- before the title (engine/movie/splash.asm + intro.asm)
local IntroMovie = require("src.ui.IntroMovie")
StateStack:push(IntroMovie.new(self, function()
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
-- from field.boot.screens so a total conversion owns the whole boot
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
StateStack:push(titleState)
end))
end)
end
Logger.info("game loaded")
end
-- the merged field.boot: spawn, names, money and the naming presets a
-- total conversion overrides. Threaded into SaveData so persistence stays
-- free of a Data dependency.
function Game:bootConfig()
return self.data and self.data.field and self.data.field.boot
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
local TitleState = require("src.ui.TitleState")
local OverworldState = require("src.world.OverworldController")
return TitleState.new(self, {
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
local title = factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.save = SaveData.newGame()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing)
local OakSpeech = require("src.ui.OakSpeech")
self.stack:push(OakSpeech.new(self, function() end))
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end,
onContinue = function()
local loaded = SaveData.load()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded)
self:restoreSave(loaded, recovered)
end
end,
})
title.screenId = title.screenId or "TitleState"
return title
end
-- QUIT from the START menu: back to the title like a power-cycle,
@@ -114,19 +154,52 @@ function Game:step(dt)
self.stack:update(dt)
-- play time for the trainer card / save screen
self.save.playTime = (self.save.playTime or 0) + dt
require("src.core.Music").update(Data)
-- Music.update is NOT serviced here: it decrements fade counters and
-- drives ChipAudio once per call, so running it inside the logic step
-- would pitch music and sfx up under fast-forward. Game:update advances
-- it on its own real-time 60Hz accumulator instead.
end
-- The logic multiplier for this frame. Read live rather than cached so the
-- Options row takes effect immediately; speedOverride is the --speed /
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
FixedStep:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
local speed = self:logicSpeed()
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
FixedStep:update(dt * speed)
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
-- display refresh, so fades and chip synthesis keep their intended tempo
-- whether we are at 1X, 10X, or running with vsync disabled.
local step = FixedStep.STEP
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
while self.audioAccum >= step do
self.audioAccum = self.audioAccum - step
require("src.core.Music").update(Data)
end
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
end
-- render.zones' identity default: unhooked, the zone list reaches the blit
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
function Game:draw()
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
@@ -146,6 +219,11 @@ function Game:draw()
break
end
end
-- 14's render.zones: weather/lighting overlays and custom colorization
-- recolor or add zones before the blit
if ModRuntime.wantsHook("render.zones") then
zones = ModRuntime.call("render.zones", sameZones, self, zones)
end
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
@@ -172,17 +250,31 @@ function Game:keypressed(key)
self.stack:top():onKeyPressed(key)
return
end
if devMode and key == "f5" then
require("src.dev.HotReload").run(self)
return
end
if devMode and key == "`" then
self.stack:push(require("src.dev.Console").new(self))
return
end
if key == "f10" then
local ManagerState = require("src.mods.ManagerState")
self.stack:push(ManagerState.new(self))
-- toggle: the manager no longer swallows the keyboard, so a second
-- press reaches this branch and closes it instead of stacking another
local top = self.stack:top()
if top and top.screenId == "ManagerState" then
self.stack:pop()
else
Screens.push(self, "ManagerState")
end
return
end
if key == "f1" then
self:writeSave()
return
elseif key == "f2" then
local loaded = SaveData.load()
if loaded then self:restoreSave(loaded) end
local loaded, recovered = SaveData.load()
if loaded then self:restoreSave(loaded, recovered) end
return
elseif key == "-" then
self:zoomStep(-1)
@@ -228,6 +320,12 @@ function Game:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
Input:gamepadpressed(joystick, button)
end
@@ -251,12 +349,35 @@ function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
end
-- Point the loader's mod.save backing at this save's modData so per-mod
-- state persists with the slot. seedBuckets is boot-only: it keeps what
-- entry chunks wrote before any save existed, while NEW GAME and
-- CONTINUE replace the backing outright.
function Game:adoptSave(save, seedBuckets)
save.modData = save.modData or {}
local loader = self.mods
if not loader then return end
if seedBuckets then
for id, bucket in pairs(loader.modSave or {}) do
if save.modData[id] == nil then save.modData[id] = bucket end
end
end
loader.modSave = save.modData
end
-- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave()
if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save)
end
-- stamp here so the save.writing payload carries the exact meta the
-- file gets; mods snapshot runtime state into their namespace now
self.save.meta = SaveData.buildMeta(
self.modStatus and self.modStatus.loaded, self.save.meta)
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
SaveData.save(self.save)
end
@@ -277,13 +398,28 @@ function Game:applyOptions(opts)
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
end
function Game:restoreSave(loaded)
function Game:restoreSave(loaded, recovered)
if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded })
end
-- mod chains replay before validation so a mod repairs its own data
-- instead of watching it get quarantined; core steps already ran in
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
report.modsDiff = modsDiff
self.save = loaded
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
@@ -293,6 +429,24 @@ function Game:restoreSave(loaded)
while self.stack:top() do self.stack:pop() end
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing)
self.saveReport = report
if not SaveData.emptyReport(report) then
-- the report screen is a Screens id so mods (or the ui milestone) own
-- its looks; until one exists the log keeps a quarantine from being
-- silent
local ok = pcall(Screens.push, self, "QuarantineReport", report)
if not ok then
Logger.warn("load report: %d mons quarantined, %d items removed, %d maps remapped%s",
#report.lostMons, #report.lostItems, #report.remappedMaps,
recovered and (", recovered from " .. recovered) or "")
local notice = SaveData.modsDiffNotice(modsDiff, loaded.meta)
if notice then Logger.warn("%s", notice) end
end
end
if ModRuntime.wants("save.loaded") then
ModRuntime.emit("save.loaded",
{ save = loaded, meta = loaded.meta, modsDiff = modsDiff })
end
end
return Game
+54
View File
@@ -0,0 +1,54 @@
-- Fast-forward multiplier for game logic.
--
-- Speeding up means running the 1/60 fixed step N times per real frame
-- (Game:update), so everything driven by the step -- movement, text,
-- battle timing, scripts -- advances N times faster while staying
-- deterministic. Audio deliberately does NOT scale: Music.update drives
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
-- accumulator in Game:update, so music and sfx play at normal pitch and
-- tempo at every speed.
--
-- Vsync still caps how much work a real frame can do, so 10X is a target
-- rather than a promise on a slow machine -- the logic simply runs as many
-- steps as the frame budget allows.
local GameSpeed = {}
-- 20X exists for the bot runs (tests/drivers/route.lua): a full-route
-- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate.
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v)
v = tonumber(v) or GameSpeed.DEFAULT
if v == 1 then return "NORMAL" end
return tostring(v) .. "X"
end
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
-- a --speed argument), so a bad number degrades to something sane
function GameSpeed.clamp(v)
v = tonumber(v)
if not v then return GameSpeed.DEFAULT end
local best, bestDiff = GameSpeed.DEFAULT, math.huge
for _, level in ipairs(GameSpeed.LEVELS) do
local diff = math.abs(level - v)
if diff < bestDiff then best, bestDiff = level, diff end
end
return best
end
-- cycle to the next/previous level, wrapping (the options row idiom)
function GameSpeed.cycle(v, dir)
local levels = GameSpeed.LEVELS
local cur = 1
for i, level in ipairs(levels) do
if level == GameSpeed.clamp(v) then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #levels + 1
return levels[nextIdx]
end
return GameSpeed
+155 -68
View File
@@ -1,11 +1,14 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio and legacy pre-rendered WAV definitions. Songs with split WAVs
-- chain def.file into def.loopFile in Music.update().
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
-- branch is chosen per song definition, never by a global import flag, so a
-- file-backed song and a chip song coexist in one dataset. Songs with split
-- files chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with Music_BikeRiding until dismount.
-- themes with the bike song until dismount.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Music = {}
@@ -37,8 +40,8 @@ local function applyFilter(src)
end
local state = {
enabled = true,
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
loopSource = nil, -- pre-loaded loop body waiting for the intro to end
mapSong = nil, -- song to restore after a battle
@@ -48,6 +51,7 @@ local state = {
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
failed = {}, -- labels whose def could not be started; logged once
}
-- Is a fanfare SFX (Sound.lua's FANFARES) still sounding?
@@ -64,7 +68,7 @@ end
-- channels on the Game Boy, so the current song halts and resumes when
-- the jingle ends (see update()).
function Music.duckForFanfare(src)
if not state.enabled or not src then return end
if not src then return end
state.fanfare = src
if state.source then
local ok, playing = pcall(state.source.isPlaying, state.source)
@@ -78,6 +82,8 @@ end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
-- for caches built before the importer wrote the table.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
@@ -97,10 +103,52 @@ local OUTDOOR = {
Music_Dungeon3 = true,
}
-- Scene themes the engine asks for by role rather than by label, so a total
-- conversion can rename every song. data.audio.special supersedes this.
local SPECIAL = {
heal = "Music_PkmnHealed",
title = "Music_TitleScreen",
credits = "Music_Credits",
hallOfFame = "Music_HallOfFame",
introBattle = "Music_IntroBattle",
oakRoute = "Music_Routes2",
bike = "Music_BikeRiding",
surf = "Music_Surfing",
}
-- the label a scene role resolves to; call sites keep their own presence
-- guard on the resolved label
function Music.special(data, key)
local special = data and data.audio and data.audio.special
local label = special and special[key]
if label ~= nil then return label end
return SPECIAL[key]
end
local function outdoorSongs(data)
return data and data.audio and data.audio.outdoorSongs or OUTDOOR
end
local function songDef(data, song)
return data and data.audio and data.audio.songs and data.audio.songs[song]
end
-- which mod put this label in the registry, for attributed failure logs
local function songOwner(data, song)
local owners = data and data.audio and data.audio._owners
local songs = owners and owners.songs
return songs and songs[song] or "base"
end
-- one log line, plus an entry in the loader's error feed when a mod owns the
-- def, so the manager's errors screen can flag that mod
local function reportBadDef(data, song, err)
local who = songOwner(data, song)
Logger.warn("audio: bad song def %q (mod %s): %s", song, who, tostring(err))
Runtime.reportError(who,
("audio: bad song def %q: %s"):format(song, tostring(err)))
end
local function stopSource(src)
if src then pcall(src.stop, src) end
end
@@ -108,50 +156,73 @@ end
local function newSource(file)
local ok, src = pcall(love.audio.newSource, file, "stream")
if ok and src then return src end
Logger.warn("music: cannot load %s", tostring(file))
return nil
return nil, ok and "no source" or tostring(src)
end
function Music.play(data, song, loop)
if not state.enabled or not song or song == state.current then return end
if not love.audio then -- headless test stub
state.enabled = false
-- Build the new song's sources; the caller only tears the old song down
-- once this succeeded, so a broken def costs nothing but a log line.
-- Returns src, loopSrc, isChip -- or nil plus the reason.
local function startSong(data, def, wantLoop)
if def.chip or (def.address and def.bank) then
local ok, src = pcall(
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
if ok and src then return src, nil, true end
return nil, nil, nil, ok and "no source" or tostring(src)
elseif def.file then
local src, err = newSource(def.file)
if not src then return nil, nil, nil, err end
-- a missing loop body degrades to the intro file alone
local loopSrc = def.loopFile and newSource(def.loopFile) or nil
return src, loopSrc, false
end
return nil, nil, nil, "no chip program and no file"
end
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
if not Runtime.wantsHook("music.select") then return song end
return Runtime.call("music.select", function(chosen) return chosen end, song, {
reason = ctx and ctx.reason or "direct",
mapId = ctx and ctx.mapId,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
kind = ctx and ctx.kind,
battleKind = ctx and ctx.kind,
trainerId = ctx and ctx.trainerId,
})
end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
song = selectSong(song, ctx)
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or song == state.current then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
state.failed[song] = true
reportBadDef(data, song, err)
return
end
local def = songDef(data, song)
local runtime = data and data.audio and data.audio.runtime
if not def or (not runtime and not def.file) then return end
stopSource(state.source)
stopSource(state.loopSource)
if runtime then require("src.core.ChipAudio").stopMusic() end
state.source, state.loopSource, state.fade = nil, nil, nil
local wantLoop = loop ~= false
local src
if runtime then
local ok, generated = pcall(
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
if ok then src = generated end
else
src = newSource(def.file)
end
if not src then
state.enabled = false
state.current = nil
return
end
if not runtime and def.loopFile then
-- intro file plays once, then update() chains to the loop body
-- a chip song holds the streaming source; ChipAudio.playMusic already
-- swapped it when the new song is chip-backed too
if state.chip and not isChip then require("src.core.ChipAudio").stopMusic() end
state.fade = nil
if loopSrc then
-- intro plays once, then update() chains to the loop body
-- (for one-shot jingles the body plays once and doesn't repeat)
pcall(src.setLooping, src, false)
local loopSrc = newSource(def.loopFile)
if loopSrc then
pcall(loopSrc.setLooping, loopSrc, wantLoop)
applyVolume(loopSrc)
applyFilter(loopSrc)
state.loopSource = loopSrc
else
pcall(src.setLooping, src, wantLoop) -- degrade: intro file only
end
pcall(loopSrc.setLooping, loopSrc, wantLoop)
applyVolume(loopSrc)
applyFilter(loopSrc)
else
pcall(src.setLooping, src, wantLoop)
end
@@ -164,15 +235,34 @@ function Music.play(data, song, loop)
else
pcall(src.play, src)
end
state.source = src
local previous = state.current
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
reason = ctx and ctx.reason or "direct",
})
end
end
function Music.stop()
local previous = state.current
stopSource(state.source)
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false
if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous })
end
end
-- hot reload: forget the failed defs and the playing label so the next cue
-- re-resolves against the freshly merged registries
function Music.reload()
state.failed = {}
Music.stop()
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
@@ -183,7 +273,6 @@ end
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.enabled then return end
if not state.source then Music.stop() return end
control = math.max(1, control or 10)
state.fade = {
@@ -196,13 +285,14 @@ end
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if state.onBike and song and OUTDOOR[song]
and songDef(data, "Music_BikeRiding") then
return "Music_BikeRiding"
if not song or not outdoorSongs(data)[song] then return song end
if state.onBike then
local bike = Music.special(data, "bike")
if bike and songDef(data, bike) then return bike end
end
if state.surfing and song and OUTDOOR[song]
and songDef(data, "Music_Surfing") then
return "Music_Surfing"
if state.surfing then
local surf = Music.special(data, "surf")
if surf and songDef(data, surf) then return surf end
end
return song
end
@@ -216,20 +306,23 @@ function Music.playMap(data, mapId, onBike, surfing)
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play) end
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
end
-- toggle the surf override mid-map (starting/ending a surf)
function Music.setSurfing(data, surfing)
state.surfing = not not surfing
local play = effectiveMapSong(data, state.mapSong)
if play then Music.play(data, play) end
if play then Music.play(data, play, nil, { reason = "map" }) end
end
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
function Music.playBattle(data, kind)
function Music.playBattle(data, kind, trainerId)
local b = data.audio and data.audio.battle
if b then Music.play(data, b[kind] or b.wild) end
if b then
Music.play(data, b[kind] or b.wild, nil,
{ reason = "battle", kind = kind, trainerId = trainerId })
end
end
-- victory theme (Music_DefeatedWildMon/Trainer/GymLeader): starts the
@@ -237,12 +330,12 @@ end
-- (each Defeated* song ends in `sound_loop 0, .mainloop`); the battle's
-- finish() restores the map theme, like the overworld reload's
-- PlayDefaultMusicFadeOutCurrent. Returns true if the theme started.
function Music.playVictory(data, kind)
function Music.playVictory(data, kind, trainerId)
local b = data.audio and data.audio.battle
local jingle = b and b[kind .. "Win"]
local def = jingle and songDef(data, jingle)
if def and (def.file or (data.audio and data.audio.runtime)) then
Music.play(data, jingle)
if jingle and songDef(data, jingle) then
Music.play(data, jingle, nil,
{ reason = "victory", kind = kind, trainerId = trainerId })
return true
end
return false
@@ -251,11 +344,8 @@ end
-- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme
-- resumes when it ends, via update()
function Music.playOnce(data, song)
local def = songDef(data, song)
if not (def and (def.file or (data.audio and data.audio.runtime))) then
return false
end
Music.play(data, song, false)
if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" })
state.pendingRestore = true
return true
end
@@ -274,7 +364,7 @@ function Music.restoreMap(data)
state.current = nil
state.pendingRestore = nil
local play = effectiveMapSong(data, state.mapSong)
if play then Music.play(data, play) end
if play then Music.play(data, play, nil, { reason = "map" }) end
end
-- 0-7 music volume (0 mutes), applied to the playing song and the
@@ -308,10 +398,7 @@ end
-- call once per frame: chains a finished intro into its loop body and
-- restores the map theme after a one-shot jingle
function Music.update(data)
if data and data.audio and data.audio.runtime then
require("src.core.ChipAudio").update()
end
if not state.enabled then return end
if state.chip then require("src.core.ChipAudio").update() end
-- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops.
@@ -344,7 +431,7 @@ function Music.update(data)
end
state.fanfareResume = false
end
if data and data.audio and data.audio.runtime and not state.fanfare then
if state.chip and not state.fanfare then
require("src.core.ChipAudio").ensureMusicPlaying()
end
if state.loopSource and sourceStopped(state.source) then
+595 -112
View File
@@ -2,14 +2,30 @@
-- Options (audio, display, battle preferences) live in a separate
-- options.lua so they survive New Game and aren't tied to a save slot.
-- Both are plain Lua tables serialized as Lua source (deterministic
-- key order).
-- key order) and read back through SaveSerializer's data-only parser,
-- so a save can never execute code.
--
-- The load pipeline is read -> parse -> migrate -> validate/quarantine
-- -> restore; Game:restoreSave drives the last two phases with the
-- merged Data threaded in, because this module must not reach into
-- Data itself.
local Logger = require("src.core.Logger")
local Version = require("src.core.Version")
local SaveSerializer = require("src.core.SaveSerializer")
local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local SaveData = {}
local FILENAME = "save.lua"
local OPTIONS_FILENAME = "options.lua"
-- one rolling backup plus the staged-write witness; load promotes either
-- when the main file is missing or fails to parse
local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp"
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
@@ -25,10 +41,14 @@ function SaveData.defaultOptions()
musicVol = 7,
sfxVol = 7,
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5)
colors = "gbc",
tilt = 0,
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -47,153 +67,604 @@ function SaveData.mergeOptions(loaded)
return opts
end
local function serialize(v, indent)
indent = indent or 0
local pad = string.rep(" ", indent)
local t = type(v)
if t == "number" or t == "boolean" then
return tostring(v)
elseif t == "string" then
return string.format("%q", v)
elseif t == "table" then
local keys = {}
for k in pairs(v) do table.insert(keys, k) end
table.sort(keys, function(a, b)
local ta, tb = type(a), type(b)
if ta ~= tb then return ta < tb end
return a < b
end)
if next(v) == nil then return "{}" end
local parts = {}
for _, k in ipairs(keys) do
local key
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
key = k
else
key = "[" .. serialize(k) .. "]"
end
table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1))
end
return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}"
end
error("cannot serialize " .. t)
end
function SaveData.encode(data)
return "return " .. serialize(data) .. "\n"
return SaveSerializer.encode(data)
end
function SaveData.decode(str)
local loader = loadstring or load
local chunk, err = loader(str, "@save.lua")
if not chunk then return nil, err end
local ok, data = pcall(chunk)
if not ok then return nil, data end
if type(data) ~= "table" then return nil, "save root must be a table" end
return data
return SaveSerializer.decode(str)
end
function SaveData.saveOptions(opts)
local function readTable(fs, name)
if not fs.getInfo(name) then return nil, "no file: " .. name end
local body = fs.read(name)
if type(body) ~= "string" then return nil, "unreadable: " .. name end
return SaveSerializer.decode(body)
end
-- the stub filesystem some headless harnesses inject has no remove; a
-- lingering tmp/bak there is harmless
local function remove(fs, name)
if fs.remove then fs.remove(name) end
end
-- ------- options
-- Both take an optional fs (write/getInfo/read) defaulting to
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs)
fs = fs or love.filesystem
opts = SaveData.mergeOptions(opts)
local ok, err = love.filesystem.write(OPTIONS_FILENAME, SaveData.encode(opts))
-- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial
-- write cannot clobber another mod's persisted keys. Every other
-- option stays on the shallow path.
local onDisk = readTable(fs, OPTIONS_FILENAME)
if onDisk and type(onDisk.modOptions) == "table" then
local merged = {}
for modId, bucket in pairs(onDisk.modOptions) do
merged[modId] = bucket
end
for modId, bucket in pairs(opts.modOptions or {}) do
if type(bucket) == "table" and type(merged[modId]) == "table" then
for k, v in pairs(bucket) do merged[modId][k] = v end
else
merged[modId] = bucket
end
end
opts.modOptions = merged
end
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
if not ok then
Logger.error("options save failed: %s", tostring(err))
end
return ok and opts or nil
end
function SaveData.loadOptions()
if not love.filesystem.getInfo(OPTIONS_FILENAME) then
return SaveData.defaultOptions()
end
local chunk, err = love.filesystem.load(OPTIONS_FILENAME)
if not chunk then
Logger.error("options load failed: %s", tostring(err))
return SaveData.defaultOptions()
end
local ok, data = pcall(chunk)
if not ok or type(data) ~= "table" then
Logger.error("options load failed: %s", tostring(data))
function SaveData.loadOptions(fs)
fs = fs or love.filesystem
local data, err = readTable(fs, OPTIONS_FILENAME)
if not data then
if fs.getInfo(OPTIONS_FILENAME) then
Logger.error("options load failed: %s", tostring(err))
end
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
end
-- Game progress only; options are written separately via saveOptions.
-- If `data.options` is present it is also flushed to options.lua so an
-- F1 / in-game save keeps the live settings in sync, then stripped from
-- the game file.
function SaveData.save(data)
if data.options then
SaveData.saveOptions(data.options)
end
local gameOnly = {}
for k, v in pairs(data) do
if k ~= "options" then gameOnly[k] = v end
end
local ok, err = love.filesystem.write(FILENAME, SaveData.encode(gameOnly))
if ok then
Logger.info("saved game")
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
-- loaded list sorted by id and is the ground truth for the load-time
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
-- headless writer (the save editor) never wipes it.
function SaveData.buildMeta(mods, previous)
local list
if mods ~= nil then
list = {}
for _, mod in ipairs(mods) do
list[#list + 1] = { id = mod.id, version = mod.version, api = mod.api }
end
table.sort(list, function(a, b) return a.id < b.id end)
else
Logger.error("save failed: %s", tostring(err))
list = (type(previous) == "table" and previous.mods) or {}
end
return ok
return {
format = Version.saveFormat,
engine = Version.engine,
savedAt = os.time(),
mods = list,
}
end
function SaveData.load()
if not love.filesystem.getInfo(FILENAME) then
return nil
-- {added, removed, changed} between the set that wrote the save
-- (meta.mods) and the active loaded set; all three empty on a vanilla
-- load under vanilla
function SaveData.modsDiff(save, activeMods)
local stored = {}
for _, entry in ipairs((save.meta and save.meta.mods) or {}) do
if type(entry) == "table" and entry.id then
stored[entry.id] = entry.version or ""
end
end
local chunk, err = love.filesystem.load(FILENAME)
if not chunk then
Logger.error("load failed: %s", tostring(err))
return nil
local diff = { added = {}, removed = {}, changed = {} }
for _, mod in ipairs(activeMods or {}) do
local was = stored[mod.id]
if was == nil then
diff.added[#diff.added + 1] = mod.id
elseif was ~= mod.version then
diff.changed[#diff.changed + 1] = { id = mod.id, from = was, to = mod.version }
end
stored[mod.id] = nil
end
local ok, data = pcall(chunk)
if not ok then
Logger.error("load failed: %s", tostring(data))
return nil
for id in pairs(stored) do diff.removed[#diff.removed + 1] = id end
table.sort(diff.added)
table.sort(diff.removed)
table.sort(diff.changed, function(a, b) return a.id < b.id end)
return diff
end
-- one-line load notice for a non-empty diff ("This save was made with
-- 2 mods; 1 is no longer active"); nil when empty so a vanilla load
-- stays silent
function SaveData.modsDiffNotice(diff, meta)
if type(diff) ~= "table" then return nil end
local removed = #(diff.removed or {})
local changed = #(diff.changed or {})
local added = #(diff.added or {})
if removed == 0 and changed == 0 and added == 0 then return nil end
local wrote = #((type(meta) == "table" and meta.mods) or {})
local parts = {}
if removed > 0 then
parts[#parts + 1] = removed .. (removed == 1 and " is" or " are") .. " no longer active"
end
-- saves from before the trainer ID existed: backfill once on load
-- (like the OT backfill for old saves)
if data.player and not data.player.id then
data.player.id = math.random(0, 65535)
if changed > 0 then
parts[#parts + 1] = changed .. " changed version"
end
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
-- was already hidden (Snorlax beaten) but the flag was never added,
-- and it can never be set again since the hidden object is
-- unreachable -- backfill it from the toggle so it isn't stuck forever
if data.objectToggles and data.flags then
if added > 0 then
parts[#parts + 1] = added .. " newly active"
end
return ("This save was made with %d mod%s; %s"):format(
wrote, wrote == 1 and "" or "s", table.concat(parts, ", "))
end
-- ------- migrations
-- Ordered engine steps keyed on meta.format, each reproducing the inline
-- migration it replaced; a save already at the current format skips them
-- all. Mod chains (recorded by Loader from mod.migrations:add) replay
-- against the version stored in meta.mods, in semver order, before the
-- validation pass -- so a mod repairs its own data instead of watching
-- it get quarantined.
local coreMigrations = {}
function SaveData.addCoreMigration(fromFormat, fn)
coreMigrations[#coreMigrations + 1] =
{ from = fromFormat, seq = #coreMigrations + 1, fn = fn }
end
local function storedVersion(save, modId)
for _, entry in ipairs((save.meta and save.meta.mods) or {}) do
if type(entry) == "table" and entry.id == modId then
return entry.version
end
end
return nil
end
local function semverLt(a, b)
local order = Semver.compare(a, b)
return order ~= nil and order < 0
end
function SaveData.runMigrations(save, modChains, activeMods)
table.sort(coreMigrations, function(a, b)
if a.from ~= b.from then return a.from < b.from end
return a.seq < b.seq
end)
-- every step whose from-format the save has not passed yet runs, in
-- (from, registration) order; a save at the current format runs none
local fmt = (save.meta and save.meta.format) or 1
for _, m in ipairs(coreMigrations) do
if m.from >= fmt then m.fn(save) end
end
-- a save that predates meta records an empty mod set: an old vanilla
-- save becomes a v2 vanilla save
save.meta = save.meta or { mods = {} }
save.meta.format = Version.saveFormat
for _, active in ipairs(activeMods or {}) do
local modSave = save.modData and save.modData[active.id]
local recorded = modChains and modChains[active.id]
if modSave and recorded then
local chain = {}
for _, m in ipairs(recorded) do chain[#chain + 1] = m end
table.sort(chain, function(a, b) return semverLt(a.since, b.since) end)
local stored = storedVersion(save, active.id) or "0.0.0"
for _, m in ipairs(chain) do
if semverLt(stored, m.since) and not semverLt(active.version, m.since) then
-- a throwing migration is skipped, not fatal: it would otherwise
-- re-raise on every load and lock the player out of the save
local ok, err = pcall(m.apply, modSave, save)
if not ok then
Logger.error("[%s] migration %s: %s -- skipped",
active.id, tostring(m.since), tostring(err))
break
end
end
end
end
end
return save
end
-- saves from before the trainer ID existed: backfill once on load
-- (like the OT backfill for old saves)
SaveData.addCoreMigration(1, function(save)
if save.player and not save.player.id then
save.player.id = math.random(0, 65535)
end
end)
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
-- was already hidden (Snorlax beaten) but the flag was never added,
-- and it can never be set again since the hidden object is
-- unreachable -- backfill it from the toggle so it isn't stuck forever
SaveData.addCoreMigration(1, function(save)
if save.objectToggles and save.flags then
local snorlaxRoutes = {
{ map = "ROUTE_12", obj = "ROUTE12_SNORLAX", flag = "EVENT_BEAT_ROUTE12_SNORLAX" },
{ map = "ROUTE_16", obj = "ROUTE16_SNORLAX", flag = "EVENT_BEAT_ROUTE16_SNORLAX" },
}
for _, r in ipairs(snorlaxRoutes) do
local toggles = data.objectToggles[r.map]
if toggles and toggles[r.obj] == false and not data.flags[r.flag] then
data.flags[r.flag] = true
local toggles = save.objectToggles[r.map]
if toggles and toggles[r.obj] == false and not save.flags[r.flag] then
save.flags[r.flag] = true
end
end
end
-- Migrate options that still live inside an old save.lua into the
-- standalone options file (once), then always prefer options.lua.
if type(data.options) == "table" and not love.filesystem.getInfo(OPTIONS_FILENAME) then
end)
-- Migrate options that still live inside an old save.lua into the
-- standalone options file (once); load always re-attaches options.lua
-- afterwards either way
SaveData.addCoreMigration(1, function(save)
if type(save.options) == "table"
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
SaveData.saveOptions(save.options)
end
end)
-- settle the box shape (single `box` list -> 12 boxes) before the
-- validation pass walks it; Boxes keeps the lazy ensure for play paths
SaveData.addCoreMigration(1, function(save)
Boxes.ensure(save)
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
-- If `data.options` is present it is also flushed to options.lua so an
-- F1 / in-game save keeps the live settings in sync, then stripped from
-- the game file. mods (when given) refreshes the meta stamp; the write
-- itself rolls the last good save into .bak and stages the new bytes as
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
if data.options then
SaveData.saveOptions(data.options)
end
data.options = SaveData.loadOptions()
Logger.info("loaded save")
return data
if mods ~= nil or data.meta == nil then
data.meta = SaveData.buildMeta(mods, data.meta)
end
local gameOnly = {}
for k, v in pairs(data) do
if k ~= "options" then gameOnly[k] = v end
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = love.filesystem
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
end
local ok, err = fs.write(TMP_FILENAME, encoded)
if not ok then
Logger.error("save failed: %s", tostring(err))
return false
end
-- love.filesystem has no atomic rename: remove + rewrite, with the
-- .tmp copy as the recovery witness in between
remove(fs, FILENAME)
ok, err = fs.write(FILENAME, encoded)
if not ok then
Logger.error("save failed: %s", tostring(err))
return false
end
remove(fs, TMP_FILENAME)
Logger.info("saved game")
return true
end
function SaveData.newGame()
return {
-- ------- read
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report
function SaveData.load()
local fs = love.filesystem
local data, err = readTable(fs, FILENAME)
local recovered
if not data then
local tmp = readTable(fs, TMP_FILENAME)
if tmp then
data, recovered = tmp, "tmp"
else
local bak = readTable(fs, BACKUP_FILENAME)
if bak then data, recovered = bak, "bak" end
end
if data then
Logger.warn("save.lua %s; recovered from %s copy",
fs.getInfo(FILENAME) and "corrupt" or "missing", recovered)
fs.write(FILENAME, SaveSerializer.encode(data))
end
end
if not data then
if fs.getInfo(FILENAME) then
Logger.error("load failed: %s", tostring(err))
end
return nil
end
SaveData.runMigrations(data)
data.options = SaveData.loadOptions()
Logger.info("loaded save")
return data, recovered
end
-- ------- validation and quarantine
local function known(tbl, id)
return id ~= nil and type(tbl) == "table" and tbl[id] ~= nil
end
-- only out-of-range values move; a vanilla save passes through untouched
local function clamp(n, lo, hi, fallback)
if type(n) ~= "number" then return fallback end
if n < lo then return lo end
if n > hi then return hi end
return n
end
local function ensureOrphaned(save)
if not save.orphaned then
save.orphaned = { mons = {}, items = {} }
end
save.orphaned.mons = save.orphaned.mons or {}
save.orphaned.items = save.orphaned.items or {}
return save.orphaned
end
-- quarantined ids whose content reappeared (mod re-enabled) go home
-- again: mons through the PC deposit, items through the bag with the PC
-- as overflow
local function reclaim(save, data, report)
local orphaned = save.orphaned
if not orphaned then return end
for i = #(orphaned.mons or {}), 1, -1 do
local mon = orphaned.mons[i]
if type(mon) == "table" and known(data.pokemon, mon.species) then
table.remove(orphaned.mons, i)
local box = Boxes.deposit(save, mon)
if box then
report.restoredMons[#report.restoredMons + 1] =
{ species = mon.species, box = box }
else
-- every box full: stays quarantined rather than vanishing
table.insert(orphaned.mons, i, mon)
end
end
end
for i = #(orphaned.items or {}), 1, -1 do
local entry = orphaned.items[i]
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
report.restoredItems[#report.restoredItems + 1] =
{ id = entry.id, count = entry.count or 1 }
end
end
end
-- mirrors Protocol.unpackMon's clamp discipline for the fields play
-- indexes; the level floor widens to 1 because a freshly caught level-1
-- mon can legitimately sit in a save
local function scrubKnownMon(mon, data)
if type(mon.dvs) == "table" then
for stat, v in pairs(mon.dvs) do mon.dvs[stat] = clamp(v, 0, 15, 0) end
end
if type(mon.statExp) == "table" then
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end
mon.level = clamp(mon.level, 1, 100, 1)
local moves = mon.moves
if type(moves) ~= "table" then return end
local hadMoves = #moves > 0
for j = #moves, 1, -1 do
local slot = moves[j]
local id = type(slot) == "table" and slot.id or slot
if not known(data.moves, id) then table.remove(moves, j) end
end
while #moves > 4 do table.remove(moves) end
if hadMoves and #moves == 0 then
-- data-driven repair so a total conversion without TACKLE still heals
local fallback = (data.constants and data.constants.fallbackMove) or "TACKLE"
local def = data.moves and data.moves[fallback]
if def then
moves[1] = { id = fallback, pp = def.pp }
end
end
end
local function scrubMonList(list, where, save, data, report)
if type(list) ~= "table" then return end
for i = #list, 1, -1 do
local mon = list[i]
if type(mon) ~= "table" or not known(data.pokemon, mon.species) then
table.remove(list, i)
ensureOrphaned(save)
save.orphaned.mons[#save.orphaned.mons + 1] = mon
report.lostMons[#report.lostMons + 1] =
{ species = type(mon) == "table" and mon.species or nil, from = where }
else
scrubKnownMon(mon, data)
end
end
end
local function scrubItemMap(map, where, save, data, report)
if type(map) ~= "table" then return end
for id, count in pairs(map) do
if not known(data.items, id) then
map[id] = nil
ensureOrphaned(save)
save.orphaned.items[#save.orphaned.items + 1] =
{ id = id, count = count, from = where }
report.lostItems[#report.lostItems + 1] =
{ id = id, count = count, from = where }
end
end
end
local function scrubMaps(save, data, report)
local boot = (data.field and data.field.boot) or {}
local spawn = { map = boot.startMap or "REDS_HOUSE_2F",
x = boot.startX or 3, y = boot.startY or 6 }
-- heal point first, so the player fallback below always lands somewhere
-- valid; boot's heal cell (threaded from field.boot) is the last resort
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
local heal = boot.lastHeal or spawn
report.remappedMaps[#report.remappedMaps + 1] =
{ id = save.lastHeal.map, to = heal.map, field = "lastHeal" }
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
end
if save.player and not known(data.maps, save.player.map) then
local heal = save.lastHeal or spawn
report.remappedMaps[#report.remappedMaps + 1] =
{ id = save.player.map, to = heal.map, field = "player" }
save.player.map, save.player.x, save.player.y = heal.map, heal.x, heal.y
end
if save.lastOutdoor and not known(data.maps, save.lastOutdoor.id) then
report.remappedMaps[#report.remappedMaps + 1] =
{ id = save.lastOutdoor.id, field = "lastOutdoor" }
save.lastOutdoor = nil
end
if save.lastHeal and type(save.lastHeal.outdoor) == "table"
and not known(data.maps, save.lastHeal.outdoor.id) then
save.lastHeal.outdoor = nil
end
end
-- Walks every content id the save references against the merged data and
-- quarantines unknowns instead of letting them nil-index later: mons move
-- to save.orphaned (the LOST box), items are removed with a report row,
-- locations fall back to the heal point. Reclaims quarantined content
-- whose id reappeared first. On a mod-free save every membership test
-- passes and the save comes back untouched.
function SaveData.validate(save, data)
local report = { lostMons = {}, lostItems = {}, remappedMaps = {},
restoredMons = {}, restoredItems = {} }
reclaim(save, data, report)
scrubMonList(save.party, "party", save, data, report)
for b, box in ipairs(save.boxes or {}) do
scrubMonList(box, "box " .. b, save, data, report)
end
local daycare = save.daycare
if type(daycare) == "table" and type(daycare.mon) == "table" then
if not known(data.pokemon, daycare.mon.species) then
ensureOrphaned(save)
save.orphaned.mons[#save.orphaned.mons + 1] = daycare.mon
report.lostMons[#report.lostMons + 1] =
{ species = daycare.mon.species, from = "daycare" }
daycare.mon = nil
else
scrubKnownMon(daycare.mon, data)
end
end
scrubItemMap(save.inventory, "inventory", save, data, report)
scrubItemMap(save.pcItems, "pcItems", save, data, report)
if type(save.bagOrder) == "table" then
for i = #save.bagOrder, 1, -1 do
if not known(data.items, save.bagOrder[i]) then
table.remove(save.bagOrder, i)
end
end
end
scrubMaps(save, data, report)
local dex = save.pokedex
if type(dex) == "table" then
for _, key in ipairs({ "seen", "owned" }) do
if type(dex[key]) == "table" then
for id in pairs(dex[key]) do
if not known(data.pokemon, id) then dex[key][id] = nil end
end
end
end
end
-- hall of fame rosters keep their shape: an unknown species blanks in
-- place so the team stays the size it won at, with the rest of the mon
-- (level etc.) intact for display
for _, entry in ipairs(save.hallOfFame or {}) do
if type(entry) == "table" then
for i = 1, #entry do
local mon = entry[i]
if type(mon) == "table" and mon.species ~= nil
and not known(data.pokemon, mon.species) then
mon.species = nil
end
end
end
end
-- an empty quarantine leaves no residue, so a vanilla save re-encodes
-- byte-identically
local orphaned = save.orphaned
if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then
save.orphaned = nil
end
return report
end
function SaveData.emptyReport(report)
-- a bare validate report (the save editor's probe) carries no modsDiff;
-- restoreSave attaches one so a version bump alone still surfaces
local diff = report.modsDiff
return #report.lostMons == 0 and #report.lostItems == 0
and #report.remappedMaps == 0 and #report.restoredMons == 0
and #report.restoredItems == 0 and not report.recovered
and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0))
end
-- ------- new game
-- boot is Data.field.boot, threaded in by Game: this module must not reach
-- into Data itself. Every read falls back to the Red literal it replaced,
-- so an absent or partial config still produces the vanilla new game.
-- Where blackouts and ESCAPE ROPE return to for a given boot config.
--
-- In vanilla this is NOT the spawn. wLastBlackoutMap is zero-filled at new
-- game and PALLET_TOWN is map 0, so the player starts in the bedroom
-- (special_warps.asm NewGameWarp) but blacks out to Pallet Town's fly_warp
-- cell (5, 6). A world that moves the spawn without naming a heal point
-- keeps the two together -- it may have no Pallet Town at all.
--
-- Shared with the Hall of Fame reset, which pokered writes as a literal
-- (HallOfFameResetEventsAndSaveScript: wLastBlackoutMap := PALLET_TOWN)
-- rather than deriving from the spawn.
function SaveData.defaultHeal(boot)
boot = type(boot) == "table" and boot or {}
local h = boot.lastHeal
if h then return { map = h.map, x = h.x, y = h.y } end
local map = boot.startMap or "REDS_HOUSE_2F"
if map == "REDS_HOUSE_2F" then return { map = "PALLET_TOWN", x = 5, y = 6 } end
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
player = {
map = "PALLET_TOWN",
x = 5,
y = 6,
facing = "down",
name = "RED",
rival = "BLUE",
map = map,
x = x,
y = y,
facing = boot.startFacing or "down",
name = boot.playerName or "RED",
rival = boot.rivalName or "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
-- hRandomAdd in OakSpeech)
id = math.random(0, 65535),
@@ -202,16 +673,28 @@ function SaveData.newGame()
inventory = {},
party = {},
box = {},
money = 3000,
money = boot.startMoney or 3000,
defeatedTrainers = {},
pokedex = { seen = {}, owned = {} },
-- where blackouts and ESCAPE ROPE return to (updated by nurses)
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
-- copied, never aliased, so a save never writes back into Data
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
-- Interiors inherit the SGB palette of the last outdoor map. wLastMap
-- is zero-filled at new game and PALLET_TOWN is map 0, so before the
-- player has ever been outdoors that palette is Pallet Town's -- which
-- matters because the vanilla spawn (REDS_HOUSE_2F) is itself indoors.
-- Without this the palette falls through to the ROUTE default.
lastOutdoor = { id = heal.map or map, x = heal.x or x, y = heal.y or y },
repelSteps = 0,
-- per-mod persistence (mod.save) lives under here, keyed by mod id
modData = {},
-- Live options from options.lua (or defaults); New Game keeps the
-- player's audio/display/battle preferences.
options = SaveData.loadOptions(),
}
-- a total conversion reshapes the skeleton (spawn, party, money)
-- before anything reads it; unhooked this returns save unchanged
return Runtime.call("save.new_game", function(s) return s end, save)
end
return SaveData

Some files were not shown because too many files have changed in this diff Show More