new launcher and save converts and pipeline

This commit is contained in:
bryanthaboi
2026-07-25 08:34:49 -04:00
parent 6625391f76
commit 9307488dc1
58 changed files with 10971 additions and 348 deletions
+16 -1
View File
@@ -214,8 +214,21 @@ jobs:
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
cp "$love_file" "$outdir/gen1recomp-${v}.love"
ls -lh "$outdir"
# Checksums for every staged release asset (sums file itself is
# written after this and named outside the gen1recomp-* glob, so it
# never lists itself).
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
@@ -266,7 +279,9 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
echo "Published release $tag"
+149
View File
@@ -0,0 +1,149 @@
# Launcher
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
centered pill in a reserved band just above the footer, on every tab. That
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
## Save slot model
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
abstraction (`persistFs`) every other save/options call uses, so portable
mode (an `io.*` filesystem used when `portable.txt` marks the install)
keeps working unchanged.
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
`slot%d+`; `createSlot` allocates one past the highest existing number so a
reused id can never collide with a lingering file.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
Resolved once per version per process (`ensureVersionSlots`, cached in
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
legacy migration may create one; otherwise the flat legacy path is used
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
- **Legacy migration.** One-time per version, lazy on first
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
file exists and no `saves/<version>/` registry does, its main + `.bak` are
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
originals removed and `slot1` registered as active. A copy that fails to
verify leaves the originals in place; migration never loses data.
The launcher-facing API:
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
every registered slot. `name` is the save's player name, or `nil` for an
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
title screen's `ContinueInfo` shows) or `nil`. The pure part,
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
it as active, and updates the process cache so the very next save/load
lands there. The launcher calls this the moment a slot row is clicked
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
## Launcher mod manager
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
before `Game:load`, so **it never loads a mod's entry chunk**; only
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
`Loader:_discover` finds mods without running them. The real loader
(`src/mods/Loader.lua`) still owns the actual load at boot.
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
the zip root, or inside one top-level folder), validates its manifest, and
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
duplicate of an already-installed mod id, and accepts either an external
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
## Import / Export save
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
nothing to export, ROM not imported yet) surfaces as a red notice line on the
card. Nothing raises and nothing silently no-ops.
`SaveFileIO` is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
## Responsiveness
Every measurement derives from `love.graphics.getDimensions()` each frame
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
+105
View File
@@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout:
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
*drawing*: a pipeline is a display mode a mod owns, which may replace the
overworld's world pass with geometry of its own and/or post-process the
finished image. `mods/voxel_world` is the worked example — a 3D diorama
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
its renderer.
A record declares what the mode *is*; the engine
(`src/render/Pipelines.lua`) supplies everything about *being a display
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
persistence in `save.options.pipelines`, and the rule that a world pipeline
and the engine's own TILT are mutually exclusive.
```lua
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA", -- options row label
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
hotkey = "6", -- checked after the engine's keys
priority = 20, -- highest eligible wins the world
available = function() return Renderer3D.ok() end,
update = function(dt, level) Camera.ease(dt, level) end,
drawWorld = function(ctx) return renderScene(ctx) end,
})
```
Three draw stages, each optional; a record needs at least one:
| stage | signature | runs |
| --- | --- | --- |
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
`worldPresent` is the one to reach for when an effect must leave dialog
boxes and menus crisp — a depth-of-field or colour grade on the world only.
`present` is for effects that genuinely own the screen, like a CRT curve.
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
with your own projection and the engine draws every active field effect
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
is exactly one copy of each effect, so a new engine effect works in your
pipeline without you touching anything.
Three rules worth knowing:
- **`gate` governs input, never the draw.** It decides whether the player
may *change* the mode (default: free-roam overworld only). A mode that
stopped rendering during a warp would flash the flat 2D world every time
the player walked through a door.
- **`available` is re-read every frame** and is the only thing that decides
whether the mode can render at all. Answer `false` on a headless run or a
driver with no depth canvas and the engine silently keeps the vanilla 2D
path — which is why shipping a pipeline enabled is safe.
- **A callback that throws retires its pipeline**, attributed to your mod in
the manager's error feed, and the frame falls back to 2D. A broken
renderer costs the player a display mode, never the game.
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
the Game Boy did. A mod can override either, per species or per image.
Per species, on the `pokemon` record:
```lua
-- MEW's back pic renders 1.5x; its front pic is untouched
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
```
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
both take a number in `0.25 .. 4.0`.
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
exactly as the data references it:
```lua
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})
```
An image-level entry beats the species scale for that one pic, and it is
the only way to scale a pic that is not species-keyed — the player's
trainer back sprite, held on screen until "Go!", is a bare image path.
The resolution order at draw time is **image-level → species-level →
default** (1x front, 2x back).
- **The pic stays grounded at every scale.** The player pic keeps its feet
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
and outward from that anchor, never off the shelf.
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+126
View File
@@ -0,0 +1,126 @@
# Updater
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
baked into the executable, but that bundled copy is only ever the *fallback*.
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
looks in the save directory's `updates/` folder for a downloaded
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
engine version and runnable on this shell. If one qualifies, it is mounted
over `/` (so its files win over the fused source for every subsequent
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
run as if they had shipped in the executable. A dev/source checkout is never
fused, so `Boot.run` no-ops there and the working tree always runs itself.
The pieces are deliberately layered so the risky part is small. `Boot.select`
is a pure function (no `love.*` calls) that, given probed candidates and the
bundled `engine`/`shell`, decides what to run and what stale payloads to
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
orchestrates the crash guard, enumeration, selection, and the mount +
chainload, with full rollback on any failure. Checking for and fetching a
new payload is a separate, slower path: `src/update/Check.lua` is a thin
main-thread state machine the launcher screen polls, while the curl calls,
JSON parsing, and sha256 verification run on a background `love.thread`
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
reports itself up to date (it never chases a release, and it never counts
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
native shell cannot provide, for example a LOVE version bump, a new required
system binary, or a change to `love.run` itself (see Known limitations
below). An older shell refuses to chainload a payload whose `minShell`
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
rather than deleting it, in case a future shell upgrade can run it, and
`Check`'s worker reports `needs_full` so the player is pointed at a full
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-android.apk`) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
`Check.parseRelease`).
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
filenames) covering at least the `.love` payload. `Check.parseSums`
tolerates a leading `*` binary marker and a `./` prefix but expects the
filename otherwise to match the asset name exactly.
A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
## Save-directory layout
Under the save directory (identity `pokemon-love2d`):
```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker
```
`pending.txt` holds the filename of the payload currently being chainloaded.
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
on both a successful handoff and a clean rollback. If it is still present the
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
named payload is distrusted: it and the marker are deleted before candidates
are enumerated. Boot may still fall back to an older valid payload, or to the
bundled game, in that case.
## Update flow
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
and probe every `updates/*.love`, pick the highest engine that is
strictly newer than the bundled one and whose `minShell` this shell
satisfies, delete stale payloads, chainload the winner (or run the
bundled game if none qualifies).
2. **Check** (launcher screen): `Check.start()` kicks off an async check
against the GitHub releases API; safe to call every frame, it is a no-op
once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready |
needs_full | error` plus the latest version and download progress.
3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
the payload's sha256, and probes it with `Boot.probePayload` to gate its
`minShell` against this shell's `shell`. A verified, runnable payload is
renamed into place and reported as `ready`; anything else reports
`error` or `needs_full` and leaves `updates/` clean.
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo.
+6
View File
@@ -56,6 +56,12 @@ local function bootGame(version)
end
function love.load(args)
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
local Boot = require("src.update.Boot")
if Boot.run(args) then return end
local savePath
for i, a in ipairs(args or {}) do
if a == "--editor" then
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"assets_transforms": "transforms.lua",
"dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "overhaul",
"category": "MECHANIC",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+25
View File
@@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
# The working tree ships Version.lua with engine "0.0.0-dev"; the real release
# number only ever lives inside the packed archive. When --version is a strict
# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging
# dir and replace the entry inside game.love in place -- never the source tree.
# Short-hash / "dev" builds are left with the "-dev" default so they cannot be
# mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
+26
View File
@@ -145,6 +145,32 @@ pack_game_love() {
fail "game.love unexpectedly contains generated ROM data"
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
# stamps the release version the same way: patch a copy of Version.lua
# (engine set to $VERSION) under a throwaway staging dir and replace the
# entry inside the archive in place -- never the source tree. VERSION is
# already validated as X.Y.Z above; when it is empty the packaged game keeps
# the "0.0.0-dev" default. The stamp is read back out and the build fails if
# it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir
stamp_dir="$(mktemp -d)"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
rm -rf "$stamp_dir"
say "stamped engine version: $VERSION"
else
say "no X.Y.Z --version, shipping default engine (no stamp)"
fi
}
# --------------------------------------------------------------- SDK check
+105 -20
View File
@@ -173,6 +173,14 @@ local function grayImage(img)
return getImage(meta.path) or img
end
-- the asset path a loaded battle image came from (nil for the headless
-- stub images), so the battle_sprite_scales registry can be looked up by
-- the same path data references
local function imagePathOf(img)
local m = imageMeta[img]
return m and m.path
end
-- the image a battler pic actually draws with this frame
function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end
@@ -3449,12 +3457,15 @@ function BattleState:fxFaintActive(battler)
and fx.faint.frames > 0 or false
end
-- vertical slide offset for a fainting battler (the player's pic is
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
function BattleState:fxFaintOffset(battler)
-- vertical slide offset for a fainting battler. The offset is in screen
-- pixels, so it scales with the pic's draw scale (the player's default 2x
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
-- the same way. scale defaults to the vanilla side scale when unknown.
function BattleState:fxFaintOffset(battler, scale)
local fx = self.fx
if self:fxFaintActive(battler) then
return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
scale = scale or (battler.isPlayer and 2 or 1)
return (30 - fx.faint.frames) * 2 * scale
end
return 0
end
@@ -3550,7 +3561,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
return
end
if self:fxFaintActive(battler) then
local off = self:fxFaintOffset(battler)
local off = self:fxFaintOffset(battler, scale)
local visible = img:getHeight() - math.floor(off / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
@@ -3847,6 +3858,63 @@ function BattleState:drawAnimLayer(colorized)
end
end
-- ------------------------------------------------------------------
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
-- the player back pic at 2x on the GB; a mod can override either per
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
-- (the battle_sprite_scales registry, which is the only handle on the
-- non-species pics like the trainer back). These resolvers and the
-- placement math are pure (no love.*) so the grounding contract -- feet
-- pinned at any scale -- is unit-tested directly.
-- ------------------------------------------------------------------
-- the vanilla scale for a side: enemy front 1x, player back 2x
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
-- image-level override for an asset path, or nil. scales is the merged
-- data.battle_sprite_scales table (record id -> { path, scale }).
function BattleState.imageBattleScale(scales, path)
if not scales or not path then return nil end
for id, rec in pairs(scales) do
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
return rec.scale
end
end
return nil
end
-- effective battle scale for a pic: image-level override, else the
-- species-level override for the side, else the side default. side is
-- "front" (enemy) or "back" (player); species may be nil (a non-species
-- pic like the trainer back, which only image-level scaling reaches).
function BattleState.resolveBattleScale(data, side, path, species)
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
if img then return img end
local def = species and data and data.pokemon and data.pokemon[species]
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
local override = def and def[field]
if override then return override end
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
end
-- Player (back) placement: feet flush on the text-box top (y=96) at any
-- scale, with the left transparent columns pulled back so opaque pixels
-- land where hardware's white-on-white columns left them. Returns the
-- top-left x, y and the scale (slide/shake offsets are added by the
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
function BattleState.backPlacement(w, h, pad, padL, scale)
return 8 - padL * scale, 96 - (h - pad) * scale, scale
end
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
-- for every scale: y + h * scale == ey + h.
function BattleState.frontPlacement(ex, ey, w, h, scale)
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
@@ -3890,16 +3958,23 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
local s = BattleState.resolveBattleScale(self.data, "front",
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
-- and horizontal center pinned to the mon's slot while it grows
if gs > 0 then
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
ey + img:getHeight() * (1 - gs), 0, gs, gs)
-- and horizontal center pinned to the mon's slot while it grows --
-- the mod scale composes multiplicatively with the grow stage
local eff = s * gs
if eff > 0 then
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), eff)
love.graphics.draw(img, dx, dy, 0, eff, eff)
end
else
self:drawBattlerPic(self.enemy, ex, ey, 1)
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), s)
self:drawBattlerPic(self.enemy, dx, dy, s)
end
end
@@ -3913,9 +3988,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
-- the trainer back is a bare pic, not species-keyed, so only an
-- image-level battle_sprite_scales entry can rescale it
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.playerBackPic), nil)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
pad, padL, s)
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
@@ -3923,19 +4003,24 @@ function BattleState:drawPicsLayer(slide, sx, sy)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.player.sprite),
self.player.mon and self.player.mon.species)
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs)
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
-- pinned, mod scale composed with the grow stage
local eff = s * gs
if eff > 0 then
love.graphics.draw(img,
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
end
else
self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2)
local dx, dy = BattleState.backPlacement(img:getWidth(),
img:getHeight(), pad, padL, s)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
end
end
if clipped then
+20
View File
@@ -37,6 +37,9 @@ function Game:load()
self.mods = ModLoader.new()
self.mods:load(Data)
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data)
self.input = Input
Input:init()
@@ -198,6 +201,9 @@ function Game:update(dt)
-- 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)
-- mod render pipelines tween on the same real-frame clock, for the same
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
@@ -341,6 +347,17 @@ function Game:keypressed(key)
self:writeOptions()
return
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
Input:keypressed(key)
end
@@ -457,6 +474,9 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts)
require("src.render.Zoom").applyOptions(opts)
require("src.render.TileRenderer").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
+284 -4
View File
@@ -17,6 +17,7 @@ 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 Badges = require("src.inventory.Badges")
local GameVersion = require("src.core.GameVersion")
@@ -31,10 +32,11 @@ local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Forward-declared here so saveFilename (just below) and every save/load
-- caller share the one upvalue; the body is filled in under "save slots"
-- once the options IO it depends on exists, because it now resolves the
-- ACTIVE slot for a version rather than the fixed flat name.
local saveNames
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
@@ -91,6 +93,18 @@ local function makePortableFs(dir)
os.remove(full(name))
return true
end,
createDirectory = function(name)
-- portable mode writes real files through io.*, which will not
-- create missing parent directories; mkdir the tree so a slot path
-- like "saves/red" exists before a write lands inside it
local osPath = full(name):gsub("/", SEP)
if SEP == "\\" then
os.execute('mkdir "' .. osPath .. '" 2>nul')
else
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
end
return true
end,
}
end
@@ -198,6 +212,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
-- the player left it in.
pipelines = {},
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -283,6 +302,264 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data)
end
-- ------- save slots
-- A version's playthroughs live in numbered slots under saves/<version>/;
-- the active slot is where in-game SAVE and CONTINUE land. The registry
-- (the ordered slot list plus which one is active) persists in options.lua
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
local function slotDir(version) return "saves/" .. version end
local function slotNames(version, id)
local main = slotDir(version) .. "/" .. id .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
-- Create the parent directory of a slot path when the fs supports it.
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
-- stub keys files by full path and exposes no such method, so this is a
-- no-op there.
local function ensureParentDir(fs, name)
local dir = name:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
-- Decode a slot's save using the same recovery order load() uses -- main,
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
-- summarizes. nil when nothing readable is present.
local function decodeSlot(fs, version, id)
local main, bak, tmp = slotNames(version, id)
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
if data then return data end
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
if data then return data end
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
return data or nil
end
-- One-time legacy consolidation: a pre-slots install has a flat save file
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
-- the copy reads back, then remove the originals and register slot1 as the
-- active slot. Returns the new slot id, or nil when there is nothing to
-- migrate or the copy could not be verified (originals left in place so no
-- data is ever lost to a failed move).
local function tryMigrateLegacy(version, fs)
local lmain, lbak, ltmp = legacyNames(version)
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
if not (mainBody or bakBody) then return nil end
local id = "slot1"
local dmain, dbak = slotNames(version, id)
ensureParentDir(fs, dmain)
if mainBody then fs.write(dmain, mainBody) end
if bakBody then fs.write(dbak, bakBody) end
-- refuse to delete the originals unless the new slot is loadable (from
-- the main copy or, failing that, the backup)
if not decodeSlot(fs, version, id) then return nil end
remove(fs, lmain)
remove(fs, lbak)
remove(fs, ltmp)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id }
SaveData.saveOptions(opts, fs)
return id
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
if not knownVersion(version) then
activeSlotCache[version] = false
return
end
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
if reg and type(reg.list) == "table" and #reg.list > 0 then
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-- the version, falling back to the flat legacy names when no slot is in use.
function saveNames(version)
version = version or GameVersion.get()
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end
return legacyNames(version)
end
-- Pure extraction of the launcher's per-slot summary from a decoded save,
-- factored out so it is unit-testable with no filesystem: the player name
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
-- fields the title screen's ContinueInfo derives. Badges resolve against
-- the vanilla gym list (launcher has no loaded Data), which is what the
-- flat launcher meta line needs.
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
local t = math.floor(save.playTime or 0)
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
return name, {
badges = Badges.count(nil, save),
timeText = timeText,
dexCount = dexCount,
}
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
-- is migrated to slot1 first.
function SaveData.listSlots(version)
version = version or GameVersion.get()
if not knownVersion(version) then return {} end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local list = (reg and reg.list) or {}
local out = {}
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
end
return out
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then reg.list[#reg.list + 1] = slotId end
reg.active = slotId
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = slotId
return slotId
end
-- Register a new empty slot for the version and return its id. Does NOT
-- write a save file and does NOT change the active slot: an empty slot
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
-- allocated one past the highest existing number so a reused id can never
-- collide with a lingering file.
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local maxN = 0
for _, id in ipairs(reg.list) do
local n = tonumber(tostring(id):match("^slot(%d+)$"))
if n and n > maxN then maxN = n end
end
local id = "slot" .. (maxN + 1)
reg.list[#reg.list + 1] = id
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return id
end
-- The active slot id in use for a version (resolved once per process like
-- saveNames does), or nil when none is registered and the flat legacy path is
-- in use. Public so the launcher's save Import/Export glue can name an export
-- after the slot it came from without reaching into the private cache.
function SaveData.activeSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or nil
end
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
-- the same fs seam every other save/load call uses (so portable mode keeps
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
-- uses for the flat path. Used by the launcher's save-import glue, which has
-- already registered the slot via createSlot but written no bytes yet; unlike
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
-- options. Returns true, or false + an error string on a failed write.
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" then return false, "missing slot id" end
if type(saveTable) ~= "table" then return false, "missing save table" end
local main, bak, tmp = slotNames(version, slotId)
local encoded = SaveSerializer.encode(saveTable)
local fs = persistFs(nil)
ensureParentDir(fs, main)
if fs.getInfo(main) then
local prev = fs.read(main)
if prev then fs.write(bak, prev) end
end
local ok, err = fs.write(tmp, encoded)
if not ok then return false, err end
remove(fs, main)
ok, err = fs.write(main, encoded)
if not ok then return false, err end
remove(fs, tmp)
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
end
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
@@ -525,6 +802,9 @@ function SaveData.save(data, mods)
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = persistFs(nil)
-- the active slot may live in saves/<version>/, which must exist before
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
ensureParentDir(fs, FILENAME)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
+10 -2
View File
@@ -4,14 +4,22 @@
-- tests.
local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
-- refuses to chainload a payload whose minShell
-- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "gen1recomp v1.0.0"
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
function Version.title(base)
return (base or "gen1recomp")
.. " v" .. Version.engine
+1483 -277
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
--
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path
-- so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = {}
local SAVE_SIZE = SaveConvert.SAVE_SIZE
-- Resolve raw save bytes from whatever the launcher hands us:
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
-- way RomImporter reads a dropped ROM;
-- * a raw 32768-byte string (the tests and the in-memory path) used as-is;
-- * any other string treated as an absolute picker path opened with io.open.
-- A picker path is never 32768 bytes long, so the length test disambiguates it
-- from a raw image cleanly. Returns bytes, or nil + an error string.
local function readSource(source)
local t = type(source)
if t == "table" or t == "userdata" then
if type(source.read) ~= "function" then
return nil, "that file could not be read"
end
local ok, openErr = source:open("r")
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
local data, readErr = source:read(source:getSize())
source:close()
if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end
return data
end
if t ~= "string" then
return nil, "no save file was provided"
end
if #source == SAVE_SIZE then
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- importToSlot(source, version) -> ok, slotIdOrErr
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
-- registers a new slot for the version, writes the imported save into it, makes
-- it the active slot, and returns true + the new slot id. On any failure
-- returns false + a friendly message.
function SaveFileIO.importToSlot(source, version)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
if #bytes ~= SAVE_SIZE then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
-- the format numerically, so re-stamp it to the current format (the imported
-- table is already current-shaped, so no migration is skipped by doing so).
save.version = version
save.meta = SaveData.buildMeta(nil, save.meta)
local slotId = SaveData.createSlot(version)
if not slotId then return false, "this game has no save slots to import into" end
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
if not ok then
return false, "could not write the imported save: " .. tostring(writeErr)
end
SaveData.setActiveSlot(version, slotId)
return true, slotId
end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
return SaveFileIO
+335
View File
@@ -0,0 +1,335 @@
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local LauncherMods = {}
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
-- conflictSpecs, version and game_version are exactly the fields the loader's
-- Manifest.validate produced); enabledSet is the current desired enable-set.
local function statusFor(mods, id, enabledSet, enabled)
local m = mods[id]
-- conflict only bites an enabled mod: resolveToggle's conflict list is
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
-- enabled other's spec vs this mod), which is exactly the launcher chip.
if enabled then
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
if #r.conflicts > 0 then
local otherId = r.conflicts[1]
local other = mods[otherId]
return "conflict",
"Conflicts with " .. ((other and other.name) or otherId)
end
end
-- warn: the engine is outside the mod's game_version range
if m.game_version
and not Semver.satisfies(Version.engine, m.game_version) then
return "warn", "Needs engine " .. m.game_version
.. " (have " .. Version.engine .. ")"
end
-- warn: a hard dependency is absent, switched off, or the wrong version.
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
return "ok", "Ready"
end
-- deriveList(manifests, options) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (only options.mods is read). Rows come back
-- sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options)
local mods = options and options.mods or {}
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
}
end
return out
end
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
-- paths is a shallow listing: top-level file names as-is, and for a top-level
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
-- the manifest sits at the archive root, "<dir>" when a single top-level
-- folder holds it, or nil + a user-presentable reason.
function LauncherMods.locateRoot(paths)
for _, p in ipairs(paths) do
if p == "manifest.json" then return "" end
end
local topDirs, seen, hasManifest = {}, {}, {}
for _, p in ipairs(paths) do
local top, rest = p:match("^([^/]+)/(.+)$")
if top then
if not seen[top] then
seen[top] = true
topDirs[#topDirs + 1] = top
end
if rest == "manifest.json" then hasManifest[top] = true end
end
end
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
if #topDirs > 1 then
return nil, "the .zip must contain a single mod folder"
end
return nil, "no manifest.json found in the .zip"
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
local Json = require("src.link.Json")
local data, decodeErr = Json.decode(raw)
if not data then return nil, decodeErr end
local ok, manifest = pcall(Manifest.validate, data, path)
if not ok then return nil, manifest end
return manifest
end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
if manifest and not seen[manifest.id] then
seen[manifest.id] = true
out[#out + 1] = manifest
end
end
end
end
return out
end
-- list() -> the mods-panel rows for the current install. Reads the same
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
function LauncherMods.setEnabled(id, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
options.mods[id] = enabled and true or false
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
local ok = source:open("r")
if not ok then return nil, "could not open the dropped file" end
local data = source:read(source:getSize())
source:close()
if not data then return nil, "the dropped file could not be read" end
return data
end
if t == "string" then
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
f:close()
if not data then return nil, "could not read " .. source end
return data
end
if love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
return nil, "could not open " .. source
end
return nil, "unsupported archive source"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
local function topLevelPaths(mount)
local fs = love.filesystem
local paths = {}
for _, name in ipairs(fs.getDirectoryItems(mount)) do
local info = fs.getInfo(mount .. "/" .. name)
if info and info.type == "directory" then
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
paths[#paths + 1] = name .. "/manifest.json"
end
else
paths[#paths + 1] = name
end
end
return paths
end
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
local info = fs.getInfo(s)
if info and info.type == "directory" then
local ok, err = copyTree(s, d)
if not ok then return nil, err end
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
end
fs.remove(path)
end
-- installZip(source) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
if not prefix then
cleanup()
return nil, rootErr
end
local root = prefix == "" and mount or (mount .. "/" .. prefix)
local raw = fs.read(root .. "/manifest.json")
if not raw then
cleanup()
return nil, "the .zip has no readable manifest.json"
end
local manifest, manifestErr = decodeManifest(raw, root)
if not manifest then
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
local copied, copyErr = copyTree(root, dest)
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
end
return LauncherMods
+131
View File
@@ -40,6 +40,19 @@ function f.int(min, max)
end }
end
-- a bounded float; like f.int but keeps the fractional part (scales,
-- gains). Rejects out-of-range with the "expected number a..b" message.
function f.numRange(min, max)
local desc = "number"
if min and max then desc = ("number %s..%s"):format(min, max)
elseif min then desc = ("number >= %s"):format(min) end
return { kind = "num", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number"
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
function f.enum(values)
local set = {}
for _, value in ipairs(values) do set[value] = true end
@@ -420,6 +433,12 @@ R.pokemon = {
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool),
-- battle-pic scale overrides for this species' own pics: front is the
-- enemy pic (default 1x), back is the player pic (default 2x). An
-- image-level battle_sprite_scales entry for the same path beats these.
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
}
@@ -791,6 +810,118 @@ R.transitions = {
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
}
-- ------- rendering pipelines
--
-- A pipeline is a display mode that owns part of the frame: it may replace
-- the overworld's world pass with geometry of its own (drawWorld) and/or
-- post-process the finished composite (present). Everything around that --
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
-- save.options.pipelines and the gating that keeps it out of battles and
-- menus -- is engine plumbing driven from this record, so a renderer mod
-- declares what it is and writes only the two draw functions.
--
-- Both callbacks are optional and independent: a present-only pipeline is a
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
-- rendered the frame alone, and a drawWorld-only pipeline is a world
-- renderer that composites straight. See src/render/Pipelines.lua for the
-- ctx each receives and docs/modding.md for the worked example.
R.render_pipelines = {
semantics = "record", target = "render_pipelines",
fields = {
-- shown in the options menu; the ladder labels default to OFF/ON
label = f.str,
levels = f.opt(f.list(f.str)),
-- keyboard key that cycles the ladder, checked after the engine's own
-- display hotkeys so a pipeline can never shadow one
hotkey = f.opt(f.str),
-- higher wins when two world pipelines are somehow active at once;
-- also the options-row order, so a mode and its post-process sort
-- together instead of by id
priority = f.opt(f.num),
-- hardware/driver gate, checked every frame: false keeps the vanilla
-- 2D path, which is what a headless run and a driver with no depth
-- canvas both get
available = f.opt(f.fn),
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
-- right now. Defaults to the survey-zoom gate (free-roam overworld
-- only), which keeps a hotkey press from switching modes mid-warp or
-- mid-cutscene. It has no say over whether an already-on mode draws:
-- a mode that stopped rendering during a warp would flash the flat 2D
-- world every time the player walked through a door.
gate = f.opt(f.fn),
-- (dt, level): presentational tweens, ticked on real frame time
update = f.opt(f.fn),
-- (ctx) -> canvas | nil: render the world. nil falls back to the
-- vanilla flat/tilt draw for this frame.
drawWorld = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
-- composites over it -- a depth-of-field or colour grade that must not
-- touch the dialog boxes and menus sitting on top. Only runs when some
-- pipeline rendered the world, since the vanilla world pass has no
-- single finished image to hand over.
worldPresent = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
-- canvas; the input unchanged is the correct answer when the effect is
-- off.
present = f.opt(f.fn),
-- drop GPU objects (window resize, hot reload, mode switch)
invalidate = f.opt(f.fn),
},
-- a pipeline that does neither half is dead weight and would silently
-- occupy an options row and a hotkey
extra = function(_, value)
if value.drawWorld == nil and value.present == nil
and value.worldPresent == nil then
return "a render pipeline needs drawWorld, worldPresent or present"
end
end,
-- A pipeline callback fails at play time, long after the load phase has
-- handed its report to the mod manager, so the merge leaves behind who
-- wrote each record for Pipelines to name in the failure -- the same
-- provenance trick the audio registries use (Loader.stampAudioOwners).
-- Placement is otherwise the default record merge.
write = function(target, registry)
local owners, tombstones = {}, {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
target[id] = value
local owner = registry.owners[id]
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
end
end
for _, id in ipairs(tombstones) do target[id] = nil end
target._owners = owners
end,
example = 'mod.content.render_pipelines:register("voxel", ' ..
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
}
-- ------- battle sprite scales
--
-- Per-image battle-pic scale overrides, keyed by record id and consulted
-- by asset path at draw time. Where a species' battleScaleFront /
-- battleScaleBack scales its own front/back pic, this scales ANY battle
-- pic by the path it is drawn from -- the only handle on the non-species
-- pics like the player's trainer back sprite. Image-level beats
-- species-level; both compose with the send-out grow and keep the sprite
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
R.battle_sprite_scales = {
semantics = "record", target = "battle_sprite_scales",
fields = {
-- the asset path exactly as data references it, e.g.
-- "assets/generated/battle/back/abrab.png"
path = f.path,
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
}
-- ------- progression
R.evolution_methods = {
+423
View File
@@ -0,0 +1,423 @@
-- Rendering pipelines: the engine side of the render_pipelines registry.
--
-- A pipeline is a display mode a mod owns. It may replace the overworld's
-- world pass with geometry of its own (drawWorld) and/or post-process the
-- finished composite (present). Everything else about being a display mode
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
-- free-roam gate, and never letting a mod's error take the frame down -- is
-- engine plumbing and lives here, so a renderer mod writes the two draw
-- functions and declares the rest.
--
-- The two halves compose independently and in priority order: the highest
-- priority eligible drawWorld renders the world, then every eligible
-- present folds over whatever came out (the world pipeline's canvas, or the
-- vanilla flat/tilt composite when none ran). A present that is switched
-- off returns its input, so a full ladder of them costs nothing at level 0.
--
-- Nothing here reaches collision, movement, triggers or scripts: like
-- survey zoom and tilt, a pipeline is purely presentational, which is why
-- its level rides in save.options rather than the save proper.
--
-- Spec: docs/modding.md (rendering pipelines)
local Data = require("src.core.Data")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Zoom = require("src.render.Zoom")
local Pipelines = {}
-- id -> level. Levels live here rather than on the records because the
-- records are merged content: frozen after load, and shared with whatever
-- else reads Data.
local levels = {}
-- ids whose callbacks have already thrown, so a pipeline that fails every
-- frame reports once instead of filling the log at 60Hz
local broken = {}
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
-- The merged dataset the records live in. The boot singleton is the
-- default -- Game.data is that very table -- and install() lets a headless
-- caller (the SDK harness, a tool) point this at a dataset of its own.
local source = Data
function Pipelines.install(data)
source = data or Data
Pipelines.reset()
end
-- ------- catalog
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
-- (descending, ties by id) so selection, the options rows and the present
-- fold all walk the same sequence.
--
-- Memoized on the namespace table's identity. Content freezes at the merge
-- boundary, so the answer cannot change for a given table -- and this is
-- read several times per frame by update(), worldPipeline() and the
-- endFrame present check, which is no place to allocate and sort. An empty
-- list is cached too, so a mod-free boot pays one table for the process.
local listCache, listSource = nil, nil
function Pipelines.list()
local defs = source and source.render_pipelines
if defs == listSource and listCache then return listCache end
local out = {}
if type(defs) == "table" then
for id, def in pairs(defs) do
-- the merge writes provenance under _owners; skip the bookkeeping
-- keys rather than treating them as pipelines
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
out[#out + 1] = { id = id, def = def }
end
end
table.sort(out, function(a, b)
local pa, pb = a.def.priority or 0, b.def.priority or 0
if pa ~= pb then return pa > pb end
return a.id < b.id
end)
end
listCache, listSource = out, defs
return out
end
function Pipelines.get(id)
local defs = source and source.render_pipelines
local def = type(defs) == "table" and defs[id] or nil
return type(def) == "table" and def or nil
end
-- the mod that registered a pipeline, so a runtime failure lands in the
-- feed the mod manager shows instead of only in the console
local function ownerOf(id)
local defs = source and source.render_pipelines
local owners = type(defs) == "table" and defs._owners or nil
return owners and owners[id] or nil
end
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
-- frame must not take the frame with it: the pipeline is marked broken,
-- attributed once, and treated as absent from then on -- which degrades to
-- the vanilla 2D path rather than a black screen.
local function guard(id, fn, ...)
if broken[id] then return nil end
local ok, result = pcall(fn, ...)
if ok then return result end
broken[id] = true
Logger.error("render pipeline %s failed: %s -- disabled for this session",
id, tostring(result))
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
return nil
end
-- Whether a callback's return is a real Canvas we can composite. A mod that
-- forgets a return, or hands back a shade string / flag / number, must be
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
-- before and pop() after, so a callback that returns cleanly but leaves a
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
-- the engine composite that follows. guard() catches a callback that throws;
-- this catches one that dirties state. A pipeline already retired skips the
-- push/pop entirely, so the stack stays balanced.
local function guardRender(id, fn, ...)
if broken[id] then return nil end
love.graphics.push("all")
local out = guard(id, fn, ...)
love.graphics.pop()
return out
end
-- ------- levels
function Pipelines.levelLabels(id)
local def = Pipelines.get(id)
local labels = def and def.levels
if type(labels) ~= "table" or labels[1] == nil then
return Pipelines.DEFAULT_LEVELS
end
return labels
end
-- highest selectable level: one less than the label count, so a two-label
-- ladder is a plain OFF/ON toggle
function Pipelines.maxLevel(id)
return #Pipelines.levelLabels(id) - 1
end
function Pipelines.level(id)
return levels[id] or 0
end
function Pipelines.levelLabel(id, level)
local labels = Pipelines.levelLabels(id)
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
end
-- A world pipeline and the engine's own tilt mode are two answers to the
-- same question, so switching one on switches the other off -- the rule
-- tilt and survey zoom already follow between themselves. Present-only
-- pipelines (post-processes) compose with tilt and are left alone.
local function excludeTilt(id, level)
local def = Pipelines.get(id)
if not (def and def.drawWorld) or level <= 0 then return end
local Tilt = require("src.render.Tilt")
if Tilt.level > 0 then Tilt.setLevel(0) end
-- one world pipeline at a time, for the same reason
for _, entry in ipairs(Pipelines.list()) do
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
levels[entry.id] = 0
end
end
end
function Pipelines.setLevel(id, level)
if not Pipelines.get(id) then return 0 end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(id)
if level > max then level = max end
levels[id] = level
excludeTilt(id, level)
return level
end
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
function Pipelines.cycle(id, dir)
local max = Pipelines.maxLevel(id)
if max < 1 then return 0 end
local span = max + 1
local target = (Pipelines.level(id) + (dir or 1)) % span
if target < 0 then target = target + span end
return Pipelines.setLevel(id, target)
end
-- Turning a world pipeline on must switch tilt off in the save too, not
-- just in the live module, or the next boot restores both. Call sites hand
-- over the options table so this stays the one place that rule lives.
function Pipelines.syncOptions(opts)
if type(opts) ~= "table" then return end
local bucket = opts.pipelines
if type(bucket) ~= "table" then
bucket = {}
opts.pipelines = bucket
end
for _, entry in ipairs(Pipelines.list()) do
bucket[entry.id] = Pipelines.level(entry.id)
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
opts.tilt = 0
end
end
end
-- Restore levels from a loaded options table. A pipeline whose mod is gone
-- keeps its stored level untouched in the bucket (so re-enabling the mod
-- restores the mode) but contributes nothing while absent.
function Pipelines.applyOptions(opts)
local bucket = type(opts) == "table" and opts.pipelines or nil
levels = {}
broken = {}
local world = nil
for _, entry in ipairs(Pipelines.list()) do
local stored = type(bucket) == "table" and bucket[entry.id] or 0
local level = math.floor(tonumber(stored) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(entry.id)
if level > max then level = max end
-- list() is priority order, so the first world pipeline with a stored
-- level is the one that wins; the rest restore to OFF rather than
-- sitting on a level that can never render
if entry.def.drawWorld and level > 0 then
if world then level = 0 else world = entry.id end
end
levels[entry.id] = level
end
-- a restored world pipeline and tilt are two answers to the same
-- question; the pipeline wins, as it does at every place that sets one
if world then require("src.render.Tilt").setLevel(0) end
end
function Pipelines.reset()
levels = {}
broken = {}
end
-- ------- per-frame
-- Presentational tweens run on real frame time, like Tilt's. Every
-- pipeline ticks, not just the active ones: a mode easing back OUT still
-- has an angle to retire.
function Pipelines.update(dt)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.update then
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
end
end
end
-- A pipeline may run this frame when it is switched on, has not thrown, and
-- its hardware gate says yes. `available` is consulted every frame rather
-- than cached: a driver that loses its depth canvas on a resize has to be
-- able to change its mind.
--
-- Deliberately NOT gated on the state stack. `gate` governs whether the
-- player may CHANGE the mode, never whether it draws -- a display mode that
-- stopped rendering during a warp, a scripted cutscene or an open menu
-- would flash the flat 2D world for those frames every time the player
-- walked through a door. Once a mode is on it renders until it is off.
function Pipelines.eligible(id)
local def = Pipelines.get(id)
if not def or broken[id] then return false end
if Pipelines.level(id) <= 0 then return false end
if def.available and guard(id, def.available) ~= true then return false end
return true
end
-- Whether the player may cycle this mode right now: the free-roam gate,
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
-- Input only -- see eligible() for why the draw path does not consult it.
function Pipelines.canToggle(id, top, overworld)
local def = Pipelines.get(id)
if not def then return false end
local gate = def.gate or Zoom.gateOK
return guard(id, gate, top, overworld) == true
end
-- The pipeline that owns the world pass right now, or nil for the vanilla
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
-- there is normally only one candidate anyway.
function Pipelines.worldPipeline()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
return entry.id, entry.def
end
end
return nil
end
-- Render the world through `id`. Returns the canvas to composite, or nil
-- when the pipeline declined this frame (nothing to draw, a transient
-- failure), which the caller treats as "fall back to the 2D path".
function Pipelines.drawWorld(id, ctx)
local def = Pipelines.get(id)
if not (def and def.drawWorld) then return nil end
return guardRender(id, def.drawWorld, ctx)
end
-- Fold every eligible world post-process over a pipeline's world image,
-- before the UI composites on top. This is where a depth-of-field or a
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
-- `present` below is the whole-frame counterpart. Only reachable once some
-- pipeline rendered the world, so it is gated on the overworld state the
-- same way drawWorld is.
function Pipelines.worldPresent(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas (a
-- forgotten return, a shade string) is ignored, not folded in
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- Fold every eligible post-process over the finished frame. Present
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
-- grade applies to menus and battles too -- so eligibility here is just
-- "switched on and available". A pass that returns a non-canvas is
-- ignored rather than trusted, so a mod cannot blank the screen by
-- forgetting a return.
function Pipelines.present(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas is
-- ignored (docstring above), so a mod cannot blank or crash the frame
-- by forgetting a return or handing back a truthy non-canvas
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- true when any present-only pass wants to run, so the composite path can
-- skip allocating a target it would not use
function Pipelines.wantsPresent()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then return true end
end
return false
end
-- ------- input and UI
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
-- the caller knows the key was consumed. Checked after the engine's own
-- display hotkeys, so a mod can never shadow one.
function Pipelines.hotkey(key, top, overworld)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.hotkey == key then
-- the gate belongs here and nowhere else: it stops the player
-- flipping modes mid-warp or mid-cutscene, and has no say over
-- whether an already-on mode draws
if Pipelines.canToggle(entry.id, top, overworld) then
Pipelines.cycle(entry.id)
return entry.id
end
return nil
end
end
return nil
end
-- Options rows for every registered pipeline, in the same priority order,
-- in the descriptor shape src/ui/OptionRows.lua renders.
function Pipelines.rows(game)
local rows = {}
for _, entry in ipairs(Pipelines.list()) do
local id = entry.id
rows[#rows + 1] = {
id = "pipeline:" .. id,
label = entry.def.label or id:upper(),
value = function() return Pipelines.levelLabel(id) end,
step = function(g, dir)
Pipelines.cycle(id, dir)
local opts = g and g.save and g.save.options
if opts then
Pipelines.syncOptions(opts)
-- the exclusion above may have switched tilt off; keep the live
-- module in step with the option it just wrote
require("src.render.Tilt").setLevel(opts.tilt or 0)
end
return true
end,
}
end
return rows
end
-- Drop every pipeline's GPU objects (window resize, hot reload).
function Pipelines.invalidate()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
end
end
return Pipelines
+76 -4
View File
@@ -9,12 +9,28 @@
local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Renderer = {}
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
-- tables carrying the Canvas method shape. A mod pipeline handing back a
-- non-canvas must be rejected before it reaches love.graphics.draw, which
-- would otherwise take the frame down with it.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Tilt mode: the upright billboard canvas is grown by this many world
-- pixels on every side beyond the ground world view, so a structure or
-- sprite standing near a view edge still draws in full instead of being
@@ -57,6 +73,22 @@ function Renderer:init()
-- the projected ground in endFrame; never touched while tilt is off.
self.uprightCanvas = nil
self.uprightActive = false
-- a render pipeline's finished world image, already at window resolution
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
-- frame", which is every vanilla frame.
self.worldOverride = nil
end
-- Hand endFrame a pipeline's world image to composite instead of the world
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
-- straight back to the 2D path rather than showing a stale image.
function Renderer:setWorldOverride(canvas)
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
-- love.graphics.draw on it would crash the frame. Reject it and fall back
-- to the 2D path rather than trust it.
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
self.worldOverride = canvas
end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
@@ -91,6 +123,7 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
@@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
local needPresent = GBCFX.active()
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
local present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader()
end
if self.worldActive then
if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain,
-- characters and its own FX overlay -- as one window-resolution image,
-- so it composites with a straight 1:1 blit and the world canvas is
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth()
@@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones)
if present then
love.graphics.setCanvas()
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
-- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
end
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
end
+27
View File
@@ -65,8 +65,12 @@ end
Assets.register(SpriteRenderer.invalidate)
-- exported: a render pipeline's own sprite geometry picks frames by the
-- same tables, so a 3D pose can never drift from the 2D one
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
@@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed)
return self
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
-- coordinates rather than baked colors, so sharing this one resolver is
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
-- alike.
--
-- Deliberately free of draw's bookkeeping: markTrueColor and
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
-- pipeline that renders into its own canvas never runs through it. For the
-- same reason the OG-RED bake is returned unconditionally here rather than
-- only during a redraw pass -- there is no later pass to restore it.
function SpriteRenderer:resolveImage()
if self.def.trueColor then return self.image end
if PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
end
return self.image
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
+5
View File
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
return col[1] / 255, col[2] / 255, col[3] / 255, a
end
-- exported: a render pipeline bakes a map's palette into its own texture
-- atlas the same way, and has to land on the identical colors as the 2D
-- tiles it is standing in for
TileRenderer.recolorSample = recolorSample
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {}
+813
View File
@@ -0,0 +1,813 @@
-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <->
-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame).
--
-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI
-- (tools/save_convert/convert.lua) and headless tests alike.
--
-- Every offset below was derived mechanically from the authoritative
-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and
-- cross-checked against three independently well-known Gen1 save
-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C --
-- all three fall out exactly right from the single sPlayerName anchor
-- below, strong triangulated confirmation the whole chain (SRAM bank 1
-- layout, wMainData field order, party_struct/box_struct sizes) is right.
--
-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers +
-- Hall of Fame (not modeled -- see "explicitly out of scope" in the
-- save-converter plan); bank 1 is "Save Data" (sPlayerName through
-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums.
--
-- Fields with no equivalent in save.lua (current sprite/animation state,
-- connection-header cache, Day Care, Safari Zone, HOF roster) are
-- intentionally not modeled: on export, encode() starts from the
-- ORIGINAL imported bytes as a template when available (GenSave.decode
-- stashes them) so that scratch state round-trips untouched instead of
-- being invented; with no template (a save that originated in this
-- project) those bytes stay zero-filled, which is safe because the real
-- game regenerates all of it from wCurMap on the next map load anyway.
local bit = require("bit")
local GenSave = {}
-- ------------------------------------------------------------------
-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file)
-- ------------------------------------------------------------------
local NAME_LENGTH = 11
local PARTY_LENGTH = 6
local MONS_PER_BOX = 20
local NUM_BADGES = 8
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct)
local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE
+ MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122
local O = {}
O.playerName = 9624 -- sPlayerName (11B) = 0x2598
O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror)
O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151)
O.pokedexSeen = O.mainData + 19 -- 19B
O.numBagItems = O.mainData + 38 -- 1B
O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term)
O.money = O.mainData + 80 -- 3B BCD = 0x25F3
O.rivalName = O.mainData + 83 -- 11B
O.options = O.mainData + 94 -- 1B
O.badges = O.mainData + 95 -- 1B = 0x2602
O.playerId = O.mainData + 98 -- 2B (big-endian)
O.curMap = O.mainData + 103 -- 1B
O.yCoord = O.mainData + 106 -- 1B
O.xCoord = O.mainData + 107 -- 1B
O.lastMap = O.mainData + 110 -- 1B
O.numPcItems = O.mainData + 579 -- 1B
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
O.coins = O.mainData + 685 -- 2B BCD
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party
-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the
-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2
-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1
-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five
-- bytes read 201h 30m 07s, a sane completed-save clock.
O.playTimeHours = O.mainData + 1866 -- 1B
O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h)
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
O.spriteData = O.mainData + O.mainDataSize
O.spriteDataSize = 512 -- 2 x 16 sprites x 16B
O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C
O.partyCount = O.partyData
O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1)
O.partyMons = O.partyData + 8 -- 6 x 44B
O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE
O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH
O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE
+ PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404
O.curBoxData = O.partyData + O.partyDataSize
O.boxCount = O.curBoxData
O.boxSpecies = O.curBoxData + 1 -- 21B
O.boxMons = O.curBoxData + 22 -- 20 x 33B
O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE
O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH
O.checksumStart = O.playerName
O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B)
O.mainChecksum = O.checksumEnd -- 1B
O.box1 = 16384 -- bank 2 start
O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE
O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B
O.box7 = 24576 -- bank 3 start
O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE
O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B
GenSave.OFFSETS = O
GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE
GenSave.SAVE_SIZE = 32768
-- ------------------------------------------------------------------
-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based
-- indexing, so byte offset N is string position N+1); `buf` for writing
-- is a 32768-entry array of 1-char strings, joined at the end.
-- ------------------------------------------------------------------
local function u8(bytes, off) return bytes:byte(off + 1) end
local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end
local function u24be(bytes, off)
return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2)
end
local function setByte(buf, off, v)
buf[off + 1] = string.char(bit.band(v, 0xFF))
end
local function setU16be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 1, bit.band(v, 0xFF))
end
local function setU24be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF))
setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 2, bit.band(v, 0xFF))
end
local function setBcd(buf, off, nbytes, v)
for i = nbytes - 1, 0, -1 do
local d = v % 100
v = math.floor(v / 100)
setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10))
end
end
local function readBcd(bytes, off, nbytes)
local n = 0
for i = 0, nbytes - 1 do
local b = u8(bytes, off + i)
n = n * 100 + math.floor(b / 16) * 10 + (b % 16)
end
return n
end
-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum
local function checksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
-- This is pokered's runtime FlagAction convention (home/predef macros): it
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB.
-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag:
-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save:
-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be
-- set; only the LSB reading returns it set (MSB-first spuriously drops
-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151
-- dex. The prior MSB-first code round-tripped self-consistently but decoded
-- every flag_array (pokedex AND event flags) to the wrong bit.
local function bitGet(bytes, base, index)
local byteOff = base + math.floor(index / 8)
local b = u8(bytes, byteOff)
return bit.band(bit.rshift(b, index % 8), 1) == 1
end
-- Set one bit directly in `buf` (0-based flag index into a flag_array
-- starting at `base`), preserving every other bit already in that byte --
-- template bytes (see encode()'s header note) survive for bits this pass
-- never explicitly touches, e.g. event-flag bits with no known name
-- sharing a byte with ones that do.
local function bitSet(buf, base, index, value)
local byteOff = base + math.floor(index / 8)
local bitIdx = index % 8
local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0
local mask = bit.lshift(1, bitIdx)
setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask)))
end
-- ------------------------------------------------------------------
-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated,
-- $50-padded after the terminator). setCharmap(cm) must be called once
-- before decode/encode (src/save_convert/data/charmap.lua's shape).
-- ------------------------------------------------------------------
local charmap
function GenSave.setCharmap(cm) charmap = cm end
local function decodeName(bytes, off, len)
local out = {}
for i = 0, len - 1 do
local b = u8(bytes, off + i)
if b == 0x50 then break end
out[#out + 1] = charmap.byByte[b] or "?"
end
return table.concat(out)
end
local function encodeName(buf, off, len, text)
local i, pos = 0, 1
while i < len - 1 and pos <= #text do
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
-- byte with no plain-glyph mapping) is ONE game character despite
-- being several text bytes here; match it as a whole unit first, or
-- it would fall through to per-byte matching and turn into "?" x5
local bracket = text:match("^(<[^<>]*>)", pos)
local ch, clen
if bracket and charmap.byToken[bracket] then
ch, clen = bracket, #bracket
else
local b0 = text:byte(pos)
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
ch = text:sub(pos, pos + clen - 1)
end
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
-- left untouched: when encoding over a template they stay as the original
-- save's post-terminator padding (so an unchanged name round-trips
-- byte-identical), and on a templateless export they stay zero-filled. The
-- game reads a name only up to the first $50, so whatever follows is inert.
if i < len then setByte(buf, off + i, 0x50) end
end
-- ------------------------------------------------------------------
-- DV / PP packing (box_struct DVs, PP)
-- ------------------------------------------------------------------
-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special;
-- HP DV is derived, not stored, from each stat DV's low bit.
local function decodeDVs(bytes, off)
local b0, b1 = u8(bytes, off), u8(bytes, off + 1)
local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF)
local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF)
local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2),
bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1))
return { hp = hp, attack = atk, defense = def, speed = spe, special = spc }
end
local function encodeDVs(buf, off, dvs)
setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF)))
setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF)))
end
-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP
local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end
local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end
-- ------------------------------------------------------------------
-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=})
-- ------------------------------------------------------------------
-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable,
-- not worth a dedicated extractor for 15 names.
local TYPE_BY_INDEX = {
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON",
[4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST",
[20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC",
[24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON",
}
local TYPE_INDEX = {}
for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end
-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 ..
-- BIT_EARTHBADGE=7); this project stores badges as truthy
-- save.inventory[id] entries, not a flag or a separate bitmask
-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly).
local BADGE_BY_BIT = {
[0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE",
[3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE",
[6] = "VOLCANOBADGE", [7] = "EARTHBADGE",
}
local BADGE_BY_BIT_SET = {}
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
local function decodeStatus(b)
if bit.band(b, 7) > 0 then return "SLP" end
for name, bitIdx in pairs(STATUS_BIT) do
if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end
end
return nil
end
local function encodeStatus(status)
if status == "SLP" then return 7 end
if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end
return 0
end
local function buildIndexCrosswalk(defs)
local byIndex, byId = {}, {}
for id, def in pairs(defs or {}) do
if def.index ~= nil then
byIndex[def.index] = id
byId[id] = def.index
end
end
return byIndex, byId
end
-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM
-- species byte (`def.index`, used for party/box mon structs) -- these are
-- two completely different Gen1 numbering schemes (the whole "MissingNo"
-- phenomenon is dex-number vs internal-index mismatches). This project's
-- generated data has no dedicated dex-number field, but every pokemon.lua
-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]",
-- and BaseStats is declared in dex order in the disassembly -- verified
-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4],
-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real
-- national dex numbers) before relying on it here.
local function buildDexCrosswalk(defs)
local byDex, dexOf = {}, {}
for id, def in pairs(defs or {}) do
local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]"))
if n then
byDex[n] = id
dexOf[id] = n
end
end
return byDex, dexOf
end
-- TM/HM item entries carry no `index` (data/generated/items.lua extracts
-- them by move/slot, not by their place in the raw item-constant table),
-- so buildIndexCrosswalk alone would silently drop every TM/HM from the
-- bag/PC on encode. Their real item ids ARE derivable: pokered's
-- constants/item_constants.asm declares "HM_\1: the item id, starting at
-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing
-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1).
local function addMachineIndices(defs, byIndex, byId)
for id, def in pairs(defs or {}) do
if byId[id] == nil and def.machine and def.machine.number then
local base = def.machine.kind == "HM" and 195 or 200
local idx = base + def.machine.number
byIndex[idx] = id
byId[id] = idx
end
end
end
function GenSave.crosswalks(data)
local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon)
local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves)
local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items)
addMachineIndices(data.items, itemsByIndex, itemsIndex)
local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps)
local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon)
return {
pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex,
pokemonByDex = pokemonByDex, pokemonDex = pokemonDex,
movesByIndex = movesByIndex, movesIndex = movesIndex,
itemsByIndex = itemsByIndex, itemsIndex = itemsIndex,
mapsByIndex = mapsByIndex, mapsIndex = mapsIndex,
speciesDefs = data.pokemon or {},
}
end
-- ------------------------------------------------------------------
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
-- Type1/Type2 are read for nothing (this project derives type from
-- species) but re-derived from data.pokemon[species].types on encode.
-- ------------------------------------------------------------------
local function decodeMon(bytes, off, isParty, cw)
local speciesIdx = u8(bytes, off)
if speciesIdx == 0 then return nil end -- empty slot
local species = cw.pokemonByIndex[speciesIdx]
local hp = u16be(bytes, off + 1)
local boxLevel = u8(bytes, off + 3)
local status = decodeStatus(u8(bytes, off + 4))
local catchRate = u8(bytes, off + 7)
local moves = {}
for i = 0, 3 do
local moveIdx = u8(bytes, off + 8 + i)
if moveIdx > 0 then
local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i))
moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps }
end
end
local otId = u16be(bytes, off + 12)
local exp = u24be(bytes, off + 14)
local statExp = {
hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19),
defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23),
special = u16be(bytes, off + 25),
}
local dvs = decodeDVs(bytes, off + 27)
local mon = {
species = species, exp = exp, dvs = dvs, statExp = statExp,
hp = hp, status = status, moves = moves, otId = otId,
catchRate = catchRate, level = boxLevel,
-- Type1/Type2 as physically stored. This project derives type from species
-- for gameplay, but the raw bytes are captured so encode() can reproduce
-- them verbatim: some real saves (traded/tampered mons) carry type values
-- that do not match the ROM base stats, and re-deriving would corrupt them.
typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) },
}
if isParty then
mon.level = u8(bytes, off + 33)
mon.stats = {
hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36),
defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40),
special = u16be(bytes, off + 42),
}
end
return mon
end
local function encodeMon(buf, off, mon, isParty, cw)
if not mon then
setByte(buf, off, 0)
return
end
setByte(buf, off, cw.pokemonIndex[mon.species] or 0)
setU16be(buf, off + 1, mon.hp or 0)
setByte(buf, off + 3, mon.level or 1)
setByte(buf, off + 4, encodeStatus(mon.status))
local def = cw.speciesDefs[mon.species]
if mon.typeBytes then
-- reproduce the exact stored type bytes captured on decode (faithful
-- byte round-trip); fresh, engine-built mons have none and derive below.
setByte(buf, off + 5, mon.typeBytes[1] or 0)
setByte(buf, off + 6, mon.typeBytes[2] or 0)
else
local t = (def and def.types) or {}
setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0)
setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0)
end
setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0)
for i = 0, 3 do
local mv = mon.moves and mon.moves[i + 1]
setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0)
setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0)
end
setU16be(buf, off + 12, mon.otId or 0)
setU24be(buf, off + 14, mon.exp or 0)
local se = mon.statExp or {}
setU16be(buf, off + 17, se.hp or 0)
setU16be(buf, off + 19, se.attack or 0)
setU16be(buf, off + 21, se.defense or 0)
setU16be(buf, off + 23, se.speed or 0)
setU16be(buf, off + 25, se.special or 0)
encodeDVs(buf, off + 27, mon.dvs or {})
if isParty then
setByte(buf, off + 33, mon.level or 1)
local st = mon.stats or {}
setU16be(buf, off + 34, st.hp or 0)
setU16be(buf, off + 36, st.attack or 0)
setU16be(buf, off + 38, st.defense or 0)
setU16be(buf, off + 40, st.speed or 0)
setU16be(buf, off + 42, st.special or 0)
end
end
-- ------------------------------------------------------------------
-- Bag / PC items: (id, qty) byte pairs, $FF-terminated
-- ------------------------------------------------------------------
local function decodeItemList(bytes, off, capacity, cw)
local inventory, order = {}, {}
for i = 0, capacity - 1 do
local idByte = u8(bytes, off + i * 2)
if idByte == 0xFF then break end
local qty = u8(bytes, off + i * 2 + 1)
local id = cw.itemsByIndex[idByte]
if id then
inventory[id] = qty
order[#order + 1] = id
end
end
return inventory, order
end
local function encodeItemList(buf, off, capacity, inventory, order, cw)
local i = 0
local seen = {}
local function put(id, qty)
if i >= capacity or not qty or qty <= 0 then return end
local idByte = cw.itemsIndex[id]
if not idByte then return end
setByte(buf, off + i * 2, idByte)
setByte(buf, off + i * 2 + 1, math.min(qty, 99))
i = i + 1
seen[id] = true
end
for _, id in ipairs(order or {}) do
if inventory[id] and not seen[id] then put(id, inventory[id]) end
end
for id, qty in pairs(inventory or {}) do
if not seen[id] then put(id, qty) end
end
setByte(buf, off + i * 2, 0xFF)
return i -- count actually written (badges etc. in `inventory` that
-- aren't real items are silently skipped by put(), so this
-- can be less than #inventory -- see the wNumBagItems caller)
end
-- ------------------------------------------------------------------
-- decode: raw 32768-byte SRAM string -> save.lua-shaped table
-- ------------------------------------------------------------------
function GenSave.decode(bytes, data, opts)
assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save")
local cw = GenSave.crosswalks(data)
local warnings = {}
local function warn(msg) warnings[#warnings + 1] = msg end
if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then
warn("main data checksum mismatch (importing anyway)")
end
local save = {
meta = { format = "gen1_import" },
player = {
name = decodeName(bytes, O.playerName, NAME_LENGTH),
rival = decodeName(bytes, O.rivalName, NAME_LENGTH),
id = u16be(bytes, O.playerId),
},
money = readBcd(bytes, O.money, 3),
coins = readBcd(bytes, O.coins, 2),
inventory = {},
pcItems = {},
pokedex = { seen = {}, owned = {} },
flags = {},
party = {},
boxes = {},
currentBox = 1,
}
-- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
end
save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw)
save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw)
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
-- set AFTER decodeItemList since that call replaces save.inventory
local badgesByte = u8(bytes, O.badges)
for i = 0, NUM_BADGES - 1 do
if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then
save.inventory[BADGE_BY_BIT[i]] = 1
end
end
-- party
local partyCount = u8(bytes, O.partyCount)
for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
if mon then
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
save.party[#save.party + 1] = mon
end
end
-- current box (bank 1) + the 11 stored boxes (banks 2/3)
for i = 1, 12 do save.boxes[i] = {} end
local function decodeBoxRegion(base, boxNum)
local count = u8(bytes, base)
for i = 0, math.min(count, MONS_PER_BOX) - 1 do
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
if mon then
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
table.insert(save.boxes[boxNum], mon)
end
end
end
local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index
curBoxNum = math.max(1, math.min(12, curBoxNum + 1))
decodeBoxRegion(O.curBoxData, curBoxNum)
for b = 1, 6 do
if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end
end
for b = 7, 12 do
if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end
end
save.currentBox = curBoxNum
-- event flags (only bits with a known name are decoded)
local events = data.eventFlags
if events then
for bitIdx, name in pairs(events.byBit) do
if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end
end
end
-- map + position
local mapIdx = u8(bytes, O.curMap)
local mapId = cw.mapsByIndex[mapIdx]
local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord)
if mapId then
save.player.map, save.player.x, save.player.y = mapId, x, y
else
warn(("unknown map index %d, defaulting spawn"):format(mapIdx))
end
local lastMapIdx = u8(bytes, O.lastMap)
local lastMapId = cw.mapsByIndex[lastMapIdx]
if lastMapId then save.lastOutdoor = { id = lastMapId } end
-- play time: this project stores save.playTime as a single float of
-- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu /
-- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60).
-- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s
-- sub-second ticks, kept as a fraction so an export recovers them exactly.
save.playTime = u8(bytes, O.playTimeHours) * 3600
+ u8(bytes, O.playTimeMinutes) * 60
+ u8(bytes, O.playTimeSeconds)
+ u8(bytes, O.playTimeFrames) / 60
save.warnings = warnings
save.rawImport = bytes -- template for a later encode(); see file header
return save
end
-- ------------------------------------------------------------------
-- encode: save.lua-shaped table -> raw 32768-byte SRAM string
-- ------------------------------------------------------------------
function GenSave.encode(save, data, template)
local cw = GenSave.crosswalks(data)
local src = template or save.rawImport
local buf = {}
if src then
for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end
else
local zero = string.char(0)
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
end
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
local badgesByte = 0
for bitIdx, name in pairs(BADGE_BY_BIT) do
if save.inventory and save.inventory[name] then
badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx))
end
end
setByte(buf, O.badges, badgesByte)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
bitSet(buf, O.pokedexOwned, bitIdx,
(save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false)
bitSet(buf, O.pokedexSeen, bitIdx,
(save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false)
end
-- Badges occupy real item IDs in data/generated/items.lua (Gen1's item
-- ID space includes them, $01-$08, for the "got the BOULDERBADGE!"
-- text display), but this project's save.lua stores them as truthy
-- save.inventory[id] entries alongside actual bag items (see the badge
-- block above and src/inventory/Badges.lua) -- a real save NEVER
-- writes them into wBagItems (they only ever live in wObtainedBadges,
-- already encoded above), so they must be filtered out here or they'd
-- corrupt the bag with bogus "badge items".
local bagInventory = {}
for id, qty in pairs(save.inventory or {}) do
if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end
end
local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw)
setByte(buf, O.numBagItems, bagN)
local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw)
setByte(buf, O.numPcItems, pcN)
local events = data.eventFlags
if events and save.flags then
for name in pairs(save.flags) do
local bitIdx = events.byName[name]
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
end
end
-- party
local party = save.party or {}
local partyN = math.min(#party, PARTY_LENGTH)
setByte(buf, O.partyCount, partyN)
for i = 0, partyN - 1 do
local mon = party[i + 1]
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
-- exactly as the template holds them (original stale data -> byte-identical
-- round-trip) or zero on a fresh export -- the game never reads past the
-- count, so this matches how it leaves those bytes itself.
setByte(buf, O.partySpecies + partyN, 0xFF)
-- boxes: current box mirrors save.currentBox into sCurBoxData; all 12
-- also get written into their bank-2/3 slot (sCurBoxData is a working
-- copy the real game keeps in sync on every PC visit, so keeping both
-- copies consistent here matches that invariant)
local function encodeBoxRegion(base, mons)
local n = math.min(#mons, MONS_PER_BOX)
setByte(buf, base, n)
for i = 0, n - 1 do
local mon = mons[i + 1]
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
-- export), just as the game leaves stale box data untouched past the count.
setByte(buf, base + 1 + n, 0xFF)
end
local boxes = save.boxes or {}
local curBoxNum = math.max(1, math.min(12, save.currentBox or 1))
encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {})
-- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the
-- 0-11 index; preserve it from the template, or set it on a templateless
-- export (any save we emit has an initialized box system).
local prevBoxByte = buf[O.currentBoxNum + 1]
local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80
setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit))
for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end
for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end
-- map + position
if save.player and save.player.map then
setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0)
setByte(buf, O.yCoord, save.player.y or 0)
setByte(buf, O.xCoord, save.player.x or 0)
end
if save.lastOutdoor and save.lastOutdoor.id then
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
end
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
-- mirror that cap rather than letting hours overflow a single byte.
local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5)
local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames
if hours > 255 then
setByte(buf, O.playTimeHours, 255)
setByte(buf, O.playTimeMaxed, 1)
setByte(buf, O.playTimeMinutes, 59)
setByte(buf, O.playTimeSeconds, 59)
setByte(buf, O.playTimeFrames, 59)
else
local rem = totalFrames - hours * 216000
local mins = math.floor(rem / 3600); rem = rem - mins * 3600
local secs = math.floor(rem / 60)
setByte(buf, O.playTimeHours, hours)
setByte(buf, O.playTimeMaxed, 0)
setByte(buf, O.playTimeMinutes, mins)
setByte(buf, O.playTimeSeconds, secs)
setByte(buf, O.playTimeFrames, rem - secs * 60)
end
local out = table.concat(buf)
-- checksums, computed last over the now-final bytes
local outBuf = {}
for i = 1, #out do outBuf[i] = out:sub(i, i) end
setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd))
-- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box
-- gets its own checksum, and the bank aggregate is CalcCheckSum over the
-- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums.
local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end
for b = 0, 5 do
setByte(outBuf, O.boxBank2IndividualChecksums + b,
boxChecksum(O.box1 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank2Checksum,
checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE))
for b = 0, 5 do
setByte(outBuf, O.boxBank3IndividualChecksums + b,
boxChecksum(O.box7 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank3Checksum,
checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE))
return table.concat(outBuf)
end
return GenSave
+189
View File
@@ -0,0 +1,189 @@
-- SaveConvert -- the runtime-facing entry point the launcher UI calls to
-- turn a vanilla Gen1 (Red/Blue, international) battery save into this
-- project's in-memory save table, and back out to a raw .sav image.
--
-- This is the ONE place the engine, the tests and the CLI
-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk
-- data loading, the merge over new-game defaults, and the version tag all
-- live here so every consumer behaves identically.
--
-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = {}
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
-- ------------------------------------------------------------------
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
-- `require` (works headless via package.path and fused via love's package
-- searcher); fall back to love.filesystem.load, then a plain dofile, for
-- the rare case a host has an unusual cwd or module path.
-- ------------------------------------------------------------------
-- { require-module-path, os-relative-file-path } for each table the codec
-- needs. pokemon/moves/items/maps come from the shared generated data;
-- charmap/event_flags are the save-convert-specific crosswalks.
local DATA_MODULES = {
pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" },
moves = { "data.generated.moves", "data/generated/moves.lua" },
items = { "data.generated.items", "data/generated/items.lua" },
maps = { "data.generated.maps", "data/generated/maps.lua" },
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
if ok and type(mod) == "table" then return mod end
-- fused build with an unexpected module path: read straight off the
-- mounted filesystem (love is a global here, only ever touched when it
-- actually exists -- stock Lua never reaches this branch)
if love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo(filePath) then
local chunk = love.filesystem.load(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
end
local chunk = loadfile(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
return nil, ("cannot load save-convert data module %q (tried require %q and file %q)")
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
end
end
crosswalk = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
if not cm then return nil, err end
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
end
-- ------------------------------------------------------------------
-- new-game default skeleton the decoded fields merge on top of. Carried
-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime
-- produce a byte-identical save table for the same input.
-- ------------------------------------------------------------------
local function defaultsSave()
return {
meta = { format = "gen1_import", mods = {} },
defeatedTrainers = {},
repelSteps = 0,
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
},
}
end
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
-- way convert.lua did, then stamp the requested version. The 32768-byte
-- import template GenSave stashes as `rawImport` and the decode `warnings`
-- are dropped here: neither belongs in a serialized slot file (a fresh
-- export always starts zero-filled -- see GenSave.lua's header).
local function mergeDefaults(decoded, version)
decoded.warnings = nil
decoded.rawImport = nil
local save = defaultsSave()
for k, v in pairs(decoded) do save[k] = v end
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
save.lastOutdoor = save.lastOutdoor or { id = save.player.map }
if version ~= nil then
save.meta = save.meta or {}
save.meta.version = version
end
return save
end
SaveConvert.mergeDefaults = mergeDefaults
-- ------------------------------------------------------------------
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
if not ok then return nil, "decode failed: " .. tostring(decoded) end
-- checksum validation: GenSave.decode records a warning rather than
-- throwing (so it can still read a foreign/corrupt save), but for the
-- runtime import path a bad main-data checksum means the file is not a
-- trustworthy save, so reject it.
for _, w in ipairs(decoded.warnings or {}) do
if tostring(w):find("checksum") then
return nil, "save data checksum invalid (" .. tostring(w) .. ")"
end
end
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
return bytes
end
return SaveConvert
+579
View File
@@ -0,0 +1,579 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Gen1 text byte <-> glyph/token charmap (fixed-length name
-- fields: player/rival/OT names, nicknames -- box/party mon
-- names are NUL-free, '@' ($50) terminated, space-padded).
-- byToken's key is the literal glyph for ordinary characters
-- ("A", "é", ...) or a bracketed control token
-- ("<PLAYER>", "@") for terminators/substitutions -- only
-- the plain single-glyph entries are meaningful inside a name.
return {
byByte = {
[0] = "<NULL>",
[5] = "",
[6] = "",
[7] = "",
[8] = "",
[9] = "",
[10] = "",
[11] = "",
[12] = "",
[13] = "",
[14] = "",
[15] = "",
[16] = "",
[17] = "",
[18] = "",
[19] = "",
[25] = "",
[26] = "",
[27] = "",
[28] = "",
[38] = "",
[39] = "",
[40] = "",
[41] = "",
[42] = "",
[43] = "",
[44] = "",
[45] = "",
[46] = "",
[47] = "",
[48] = "",
[49] = "",
[50] = "",
[51] = "",
[52] = "",
[58] = "",
[59] = "",
[60] = "",
[61] = "",
[62] = "",
[64] = "",
[65] = "",
[66] = "",
[67] = "",
[68] = "",
[69] = "",
[70] = "",
[71] = "",
[72] = "",
[73] = "<PAGE>",
[74] = "<PKMN>",
[75] = "<_CONT>",
[76] = "<SCROLL>",
[78] = "<NEXT>",
[79] = "<LINE>",
[80] = "@",
[81] = "<PARA>",
[82] = "<PLAYER>",
[83] = "<RIVAL>",
[84] = "#",
[85] = "<CONT>",
[86] = "<……>",
[87] = "<DONE>",
[88] = "<PROMPT>",
[89] = "<TARGET>",
[90] = "<USER>",
[91] = "<PC>",
[92] = "<TM>",
[93] = "<TRAINER>",
[94] = "<ROCKET>",
[95] = "<DEXEND>",
[96] = "<BOLD_A>",
[97] = "<BOLD_B>",
[98] = "<BOLD_C>",
[99] = "<BOLD_D>",
[100] = "<BOLD_E>",
[101] = "<BOLD_F>",
[102] = "<BOLD_G>",
[103] = "<BOLD_H>",
[104] = "<BOLD_I>",
[105] = "<BOLD_V>",
[106] = "<BOLD_S>",
[107] = "<BOLD_L>",
[108] = "<BOLD_M>",
[109] = "<COLON>",
[110] = "",
[111] = "",
[112] = "",
[113] = "",
[114] = "",
[115] = "",
[116] = "·",
[117] = "",
[118] = "",
[119] = "",
[120] = "",
[121] = "",
[122] = "",
[123] = "",
[124] = "",
[125] = "",
[126] = "",
[127] = " ",
[128] = "A",
[129] = "B",
[130] = "C",
[131] = "D",
[132] = "E",
[133] = "F",
[134] = "G",
[135] = "H",
[136] = "I",
[137] = "J",
[138] = "K",
[139] = "L",
[140] = "M",
[141] = "N",
[142] = "O",
[143] = "P",
[144] = "Q",
[145] = "R",
[146] = "S",
[147] = "T",
[148] = "U",
[149] = "V",
[150] = "W",
[151] = "X",
[152] = "Y",
[153] = "Z",
[154] = "(",
[155] = ")",
[156] = ":",
[157] = ";",
[158] = "[",
[159] = "]",
[160] = "a",
[161] = "b",
[162] = "c",
[163] = "d",
[164] = "e",
[165] = "f",
[166] = "g",
[167] = "h",
[168] = "i",
[169] = "j",
[170] = "k",
[171] = "l",
[172] = "m",
[173] = "n",
[174] = "o",
[175] = "p",
[176] = "q",
[177] = "r",
[178] = "s",
[179] = "t",
[180] = "u",
[181] = "v",
[182] = "w",
[183] = "x",
[184] = "y",
[185] = "z",
[186] = "é",
[187] = "'d",
[188] = "'l",
[189] = "'s",
[190] = "'t",
[191] = "'v",
[192] = "",
[193] = "",
[194] = "",
[195] = "",
[196] = "",
[197] = "",
[198] = "",
[199] = "",
[200] = "",
[201] = "",
[202] = "",
[203] = "",
[204] = "",
[205] = "",
[206] = "",
[207] = "",
[208] = "",
[209] = "",
[210] = "",
[211] = "",
[212] = "",
[213] = "",
[214] = "",
[215] = "",
[216] = "",
[217] = "",
[218] = "",
[219] = "",
[220] = "",
[221] = "",
[222] = "",
[223] = "",
[224] = "'",
[225] = "<PK>",
[226] = "<MN>",
[227] = "-",
[228] = "'r",
[229] = "'m",
[230] = "?",
[231] = "!",
[232] = ".",
[233] = "",
[234] = "",
[235] = "",
[236] = "",
[237] = "",
[238] = "",
[239] = "",
[240] = "<ED>",
[241] = "×",
[242] = "<DOT>",
[243] = "/",
[244] = ",",
[245] = "",
[246] = "0",
[247] = "1",
[248] = "2",
[249] = "3",
[250] = "4",
[251] = "5",
[252] = "6",
[253] = "7",
[254] = "8",
[255] = "9",
},
byToken = {
[" "] = 127,
["!"] = 231,
["#"] = 84,
["'"] = 224,
["'d"] = 187,
["'l"] = 188,
["'m"] = 229,
["'r"] = 228,
["'s"] = 189,
["'t"] = 190,
["'v"] = 191,
["("] = 154,
[")"] = 155,
[","] = 244,
["-"] = 227,
["."] = 232,
["/"] = 243,
["0"] = 246,
["1"] = 247,
["2"] = 248,
["3"] = 249,
["4"] = 250,
["5"] = 251,
["6"] = 252,
["7"] = 253,
["8"] = 254,
["9"] = 255,
[":"] = 156,
[";"] = 157,
["<BOLD_A>"] = 96,
["<BOLD_B>"] = 97,
["<BOLD_C>"] = 98,
["<BOLD_D>"] = 99,
["<BOLD_E>"] = 100,
["<BOLD_F>"] = 101,
["<BOLD_G>"] = 102,
["<BOLD_H>"] = 103,
["<BOLD_I>"] = 104,
["<BOLD_L>"] = 107,
["<BOLD_M>"] = 108,
["<BOLD_P>"] = 114,
["<BOLD_S>"] = 106,
["<BOLD_V>"] = 105,
["<COLON>"] = 109,
["<CONT>"] = 85,
["<DEXEND>"] = 95,
["<DONE>"] = 87,
["<DOT>"] = 242,
["<ED>"] = 240,
["<ID>"] = 115,
["<LINE>"] = 79,
["<LV>"] = 110,
["<MN>"] = 226,
["<NEXT>"] = 78,
["<NULL>"] = 0,
["<PAGE>"] = 73,
["<PARA>"] = 81,
["<PC>"] = 91,
["<PK>"] = 225,
["<PKMN>"] = 74,
["<PLAYER>"] = 82,
["<PROMPT>"] = 88,
["<RIVAL>"] = 83,
["<ROCKET>"] = 94,
["<SCROLL>"] = 76,
["<TARGET>"] = 89,
["<TM>"] = 92,
["<TRAINER>"] = 93,
["<USER>"] = 90,
["<_CONT>"] = 75,
["<to>"] = 112,
["<……>"] = 86,
["?"] = 230,
["@"] = 80,
A = 128,
B = 129,
C = 130,
D = 131,
E = 132,
F = 133,
G = 134,
H = 135,
I = 136,
J = 137,
K = 138,
L = 139,
M = 140,
N = 141,
O = 142,
P = 143,
Q = 144,
R = 145,
S = 146,
T = 147,
U = 148,
V = 149,
W = 150,
X = 151,
Y = 152,
Z = 153,
["["] = 158,
["]"] = 159,
a = 160,
b = 161,
c = 162,
d = 163,
e = 164,
f = 165,
g = 166,
h = 167,
i = 168,
j = 169,
k = 170,
l = 171,
m = 172,
n = 173,
o = 174,
p = 175,
q = 176,
r = 177,
s = 178,
t = 179,
u = 180,
v = 181,
w = 182,
x = 183,
y = 184,
z = 185,
["¥"] = 240,
["·"] = 116,
["×"] = 241,
["é"] = 186,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 117,
[""] = 96,
[""] = 97,
[""] = 116,
[""] = 117,
[""] = 122,
[""] = 124,
[""] = 121,
[""] = 123,
[""] = 125,
[""] = 126,
[""] = 237,
[""] = 237,
[""] = 236,
[""] = 238,
[""] = 245,
[""] = 239,
[" "] = 127,
[""] = 232,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 118,
[""] = 177,
[""] = 110,
[""] = 178,
[""] = 111,
[""] = 179,
[""] = 119,
[""] = 180,
[""] = 120,
[""] = 181,
[""] = 182,
[""] = 38,
[""] = 183,
[""] = 39,
[""] = 184,
[""] = 40,
[""] = 185,
[""] = 41,
[""] = 186,
[""] = 42,
[""] = 187,
[""] = 43,
[""] = 188,
[""] = 44,
[""] = 189,
[""] = 45,
[""] = 190,
[""] = 46,
[""] = 191,
[""] = 47,
[""] = 192,
[""] = 48,
[""] = 193,
[""] = 49,
[""] = 223,
[""] = 194,
[""] = 50,
[""] = 195,
[""] = 51,
[""] = 196,
[""] = 52,
[""] = 197,
[""] = 198,
[""] = 199,
[""] = 200,
[""] = 201,
[""] = 202,
[""] = 58,
[""] = 68,
[""] = 203,
[""] = 59,
[""] = 69,
[""] = 204,
[""] = 60,
[""] = 70,
[""] = 205,
[""] = 61,
[""] = 71,
[""] = 206,
[""] = 62,
[""] = 72,
[""] = 207,
[""] = 208,
[""] = 209,
[""] = 210,
[""] = 211,
[""] = 224,
[""] = 212,
[""] = 225,
[""] = 213,
[""] = 226,
[""] = 214,
[""] = 215,
[""] = 216,
[""] = 217,
[""] = 218,
[""] = 219,
[""] = 220,
[""] = 221,
[""] = 222,
[""] = 233,
[""] = 128,
[""] = 176,
[""] = 129,
[""] = 234,
[""] = 130,
[""] = 235,
[""] = 131,
[""] = 244,
[""] = 132,
[""] = 133,
[""] = 5,
[""] = 134,
[""] = 6,
[""] = 135,
[""] = 7,
[""] = 136,
[""] = 8,
[""] = 137,
[""] = 9,
[""] = 138,
[""] = 10,
[""] = 139,
[""] = 11,
[""] = 140,
[""] = 12,
[""] = 141,
[""] = 13,
[""] = 142,
[""] = 14,
[""] = 143,
[""] = 15,
[""] = 144,
[""] = 16,
[""] = 172,
[""] = 145,
[""] = 17,
[""] = 146,
[""] = 18,
[""] = 147,
[""] = 19,
[""] = 148,
[""] = 149,
[""] = 150,
[""] = 151,
[""] = 152,
[""] = 153,
[""] = 25,
[""] = 64,
[""] = 154,
[""] = 26,
[""] = 65,
[""] = 155,
[""] = 27,
[""] = 66,
[""] = 156,
[""] = 28,
[""] = 67,
[""] = 157,
[""] = 158,
[""] = 159,
[""] = 160,
[""] = 161,
[""] = 173,
[""] = 162,
[""] = 174,
[""] = 163,
[""] = 175,
[""] = 164,
[""] = 165,
[""] = 166,
[""] = 167,
[""] = 168,
[""] = 169,
[""] = 170,
[""] = 171,
[""] = 227,
[""] = 240,
[""] = 231,
[""] = 242,
[""] = 243,
[""] = 246,
[""] = 247,
[""] = 248,
[""] = 249,
[""] = 250,
[""] = 251,
[""] = 252,
[""] = 253,
[""] = 254,
[""] = 255,
[""] = 230,
[""] = 229,
[""] = 228,
},
source = "pokered constants/charmap.asm",
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -10,6 +10,7 @@
-- bottom line like pokered's.
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local Zoom = require("src.render.Zoom")
@@ -193,6 +194,15 @@ local function buildRows(game)
local o = g.save.options
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
Tilt.setLevel(o.tilt)
-- tilt and a mod's world pipeline are two answers to the same
-- question; turning this on switches that off (Pipelines does the
-- same in the other direction)
if o.tilt > 0 then
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end
end
Pipelines.syncOptions(o)
end
return true
end },
{ id = "gbcfx", label = "GBC FX",
@@ -295,6 +305,26 @@ local function buildRows(game)
end
rows = filtered
end
-- A mod's render pipelines are display modes like TILT, so their rows sit
-- with it rather than at the end of the list where a mod's own
-- ui.options.rows additions land. Nothing registered means nothing
-- spliced, so a vanilla install sees the list it always had.
local pipelineRows = Pipelines.rows(game)
if pipelineRows[1] then
local merged = {}
for _, row in ipairs(rows) do
merged[#merged + 1] = row
if row.id == "tilt" then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
end
-- no TILT row to anchor to (a future build could drop it): append
-- rather than silently lose the modes
if #merged == #rows then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
rows = merged
end
return rows
end
+263
View File
@@ -0,0 +1,263 @@
-- The boot shell: the heart of the self-updater. A fused build, before it
-- runs the game bundled inside it, looks in its save directory for a newer
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
-- runnable, mounts it over the bundled source and chainloads it -- so the
-- binary shipped once can keep updating the Lua it runs without a reinstall.
--
-- Only a fused build self-updates. A dev / source checkout IS the game, so
-- Boot.run is a no-op there.
--
-- Three pieces, deliberately layered so the risky part is small and the
-- decision part is testable:
-- * Boot.select -- pure: given probed candidates + the bundled version,
-- decide what to run and what to delete. No love.*.
-- * Boot.probePayload-- read one archive's advertised version, isolated.
-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and
-- (if a payload wins) mount + chainload with full
-- rollback on any failure so the bundled game always
-- boots.
--
-- Known limitation: the bundled love.run keeps driving the frame loop after a
-- handoff (it has already returned its stepper to LÖVE; redefining the global
-- love.run does nothing to the running one). A payload that must change
-- love.run itself therefore requires a minShell bump so an older shell refuses
-- to chainload it.
local Semver = require("src.update.Semver")
local Boot = {}
-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
local PAYLOAD_DIR = "updates"
local PENDING = "updates/pending.txt"
-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
-- copy never collides with the running source's copy at "/".
local PROBE_MOUNT = "__pokeport_probe"
-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
local function isPayloadName(name)
return name:match("^gen1recomp%-.+%.love$") ~= nil
end
-- The love callbacks the payload's main.lua chunk may redefine when it runs.
-- We snapshot these before a handoff and restore them if the handoff fails, so
-- the exact bundled closures (with their intact upvalues) drive the game
-- again. love.run is included: harmless to restore, and it is one of the
-- globals a payload main.lua reassigns.
local CALLBACK_NAMES = {
"load", "update", "draw", "quit", "run",
"keypressed", "keyreleased", "textinput",
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
"touchpressed", "touchmoved", "touchreleased",
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
"focus", "visible", "resize", "filedropped", "directorydropped",
"errorhandler", "threaderror", "lowmemory",
}
local function snapshotCallbacks()
local snap = {}
for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
return snap
end
local function restoreCallbacks(snap)
for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
end
-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
-- src.* modules plus the main/conf chunks. conf.lua cached the bundled
-- Version into package.loaded["src.core.Version"]; without this purge the next
-- require would hand back the bundled copy instead of the payload's. Setting
-- existing fields to nil during a pairs traversal is explicitly permitted.
local function purgeBundledModules()
for key in pairs(package.loaded) do
if key:match("^src%.") or key == "main" or key == "conf" then
package.loaded[key] = nil
end
end
end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
--
-- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with
-- loadstring (NEVER require -- we must not cache or run it as a module), then
-- unmount. Version.lua is zero-require, so running its chunk is safe.
function Boot.probePayload(rel)
if not love.filesystem.mount(rel, PROBE_MOUNT) then
return nil, "could not mount " .. tostring(rel)
end
local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
local ok, result = pcall(function()
local src = love.filesystem.read(chunkPath)
if not src then error("Version.lua missing", 0) end
local chunk = loadstring(src, "@" .. chunkPath)
if not chunk then error("Version.lua would not compile", 0) end
return chunk()
end)
love.filesystem.unmount(rel)
if not ok then return nil, tostring(result) end
local v = result
if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table"
end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host).
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too
-- high) is kept: a future shell upgrade may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell)
local chosen
for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell
if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c
end
end
end
local toDelete = {}
for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
if stale then toDelete[#toDelete + 1] = c.name end
end
end
return chosen and chosen.name or nil, toDelete
end
-- Mount the chosen payload and hand control to it. Returns true when the
-- payload is live and has completed its own love.load; false (with full
-- rollback) on any failure, so the caller runs the bundled game instead.
local function chainload(name, args)
local rel = PAYLOAD_DIR .. "/" .. name
-- Crash marker: if we die between here and clearing it, the next boot's
-- crash guard distrusts this payload and deletes it.
love.filesystem.write(PENDING, name)
-- Prepend-mount the payload at "/" (appendToPath = false) so its files win
-- over the fused source for every subsequent require / love.filesystem read.
if not love.filesystem.mount(rel, "/", false) then
love.filesystem.remove(PENDING)
return false
end
local snapshot = snapshotCallbacks()
purgeBundledModules()
_G.POKEPORT_PAYLOAD_MOUNTED = true
-- Chainload: run the payload's main.lua (redefines the love callbacks from
-- the NEW code), then call its love.load. The new love.load calls Boot.run
-- again, which no-ops via the flag set above.
local ok, err = pcall(function()
local chunk = assert(love.filesystem.load("main.lua"))
chunk()
love.load(args)
end)
if not ok then
-- Handoff failed after mounting. Unwind everything so the bundled game
-- boots cleanly: clear the flag, unmount the payload, purge any payload
-- modules it cached (so bundled requires reload from source), restore the
-- bundled love callbacks with their intact upvalues, and drop the marker.
-- Delete the payload too: it failed deterministically once, so leaving it
-- would re-select and re-fail it on every boot forever.
print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
_G.POKEPORT_PAYLOAD_MOUNTED = nil
pcall(love.filesystem.unmount, rel)
purgeBundledModules()
restoreCallbacks(snapshot)
love.filesystem.remove(rel)
love.filesystem.remove(PENDING)
return false
end
-- Success: the payload owns the game now. Drop the marker and tell the
-- caller to stop so the bundled love.load does not run on top of it.
love.filesystem.remove(PENDING)
return true
end
-- Everything after the fused / flag guards, wrapped so an unexpected error in
-- enumeration or selection can never crash the boot.
local function runInner(args)
-- Crash guard first: a pending.txt naming a payload means a previous boot
-- crashed mid-handoff. Distrust that payload -- delete it and the marker --
-- then continue (we may still pick an older valid payload, or fall through
-- to the bundled game).
local pending = love.filesystem.read(PENDING)
if pending then
pending = pending:gsub("%s+$", "")
if pending ~= "" then
love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
end
love.filesystem.remove(PENDING)
end
-- Enumerate and probe every payload in updates/.
local candidates = {}
if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
if isPayloadName(entry) then
local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
if info then
candidates[#candidates + 1] = {
name = entry,
engine = info.engine,
minShell = info.minShell,
}
end
end
end
end
local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
end
if not chosen then return false end
return chainload(chosen, args)
end
-- Boot.run(args) -> boolean
--
-- The first line of love.load. True means a payload was mounted and
-- chainloaded and the caller must return immediately; false means boot the
-- bundled game as normal.
function Boot.run(args)
-- Dev / source checkouts never self-update.
if not (love.filesystem.isFused and love.filesystem.isFused()) then
return false
end
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
local ok, result = pcall(runInner, args)
if not ok then
-- An error escaped before any handoff mount (chainload cleans up after
-- itself), so state is still clean. Never crash the boot.
return false
end
return result
end
return Boot
+177
View File
@@ -0,0 +1,177 @@
-- Async release-check and payload-download for the self-update flow.
--
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
-- on a background love.thread worker (src/update/check_worker.lua); this module
-- is only the thin main-thread state machine the UI polls. Two channels carry
-- the conversation:
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" }
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
-- state() simply reports "error" and the UI hides itself. See the shared
-- contract in the task brief for the status vocabulary and the file layout.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
-- worker can reuse the exact same code path via love.filesystem.load.
local Check = {}
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
local CMD = "update_check_cmd"
local STATE = "update_check_state"
-- ---------------------------------------------------------------------------
-- pure helpers (no love.*) -- also used inside the worker
-- ---------------------------------------------------------------------------
-- Find the release asset named exactly `name`, returning its download URL and
-- byte size (or nil when the release has no such asset).
function Check.pickAsset(assets, name)
if type(assets) ~= "table" then return nil end
for _, a in ipairs(assets) do
if type(a) == "table" and a.name == name then
return { url = a.browser_download_url, size = tonumber(a.size) }
end
end
return nil
end
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Decode a GitHub "releases/latest" response into just the fields the updater
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
-- falls back to require.
function Check.parseRelease(jsonText, Json)
Json = Json or require("src.link.Json")
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release json"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+$") then
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
end
local payloadName = "gen1recomp-" .. version .. ".love"
return {
version = version,
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
}
end
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
-- `target` argument returns just that file's hash (or nil); otherwise returns
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
function Check.parseSums(text, target)
local map = {}
for line in tostring(text):gmatch("[^\r\n]+") do
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
if hash and file then
map[(file:gsub("^%./", ""))] = hash:lower()
end
end
if target ~= nil then return map[target] end
return map
end
-- ---------------------------------------------------------------------------
-- main-thread state machine
-- ---------------------------------------------------------------------------
function Check.releaseUrl()
return "https://github.com/" .. Check.REPO .. "/releases/latest"
end
local worker -- the love.thread, once started
local cmdCh, stateCh -- the two channels
local workerReady -- nil = untried, true = running, false = unavailable
local requested -- a check has been asked for this session
local cache = { status = "idle" } -- newest snapshot from the worker
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not (love and love.thread and love.thread.newThread) then
workerReady = false
return false
end
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
if not ok or not th then
workerReady = false
return false
end
cmdCh = love.thread.getChannel(CMD)
stateCh = love.thread.getChannel(STATE)
if not pcall(function() th:start() end) then
workerReady = false
return false
end
worker = th
workerReady = true
return true
end
-- Pull every pending snapshot off the state channel (keeping the newest) and
-- surface a worker crash as a soft error the UI can hide on.
local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
cache = msg
msg = stateCh:pop()
end
end
if worker then
local err = worker:getError()
if err then
cache = { status = "error", error = tostring(err) }
end
end
end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
-- once a check is in flight or has reached a terminal state it is a no-op.
function Check.start()
drain()
if cache.status == "checking" or cache.status == "downloading" then return end
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" }
return
end
requested = true
cache = { status = "checking" }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
return {
status = cache.status or "idle",
latest = cache.latest,
progress = cache.progress,
error = cache.error,
}
end
-- Start downloading the payload announced by an "available" check. A no-op in
-- any other state (the worker still holds the release info from the check).
function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cmdCh:push({ cmd = "download" })
end
return Check
+53
View File
@@ -0,0 +1,53 @@
-- Strict X.Y.Z semantic-version parsing and comparison for the self-updater.
-- Every part of the updater (Boot, Check, the release picker) agrees on this
-- one notion of "newer". Zero requires and no love.* calls, so plain-Lua
-- tests can exercise it and Boot can use it during the earliest boot step.
--
-- We only need the numeric core (major.minor.patch): the engine field is a
-- bare X.Y.Z in shipped builds and the "0.0.0-dev" placeholder in the working
-- tree. Pre-release / build metadata is intentionally not supported -- a
-- "-dev" or any other suffix makes parse fail, which is the safe answer for
-- the updater (a dev checkout never counts as a real release to chainload).
local Semver = {}
-- Parse a strict "X.Y.Z" string (an optional leading "v" is allowed) into
-- { major = n, minor = n, patch = n }. Returns nil for anything else --
-- extra components, non-numeric parts, or a trailing suffix like "-dev".
function Semver.parse(s)
if type(s) ~= "string" then return nil end
local body = s:match("^v?(.+)$")
if not body then return nil end
local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$")
if not maj then return nil end
return {
major = tonumber(maj),
minor = tonumber(min),
patch = tonumber(pat),
}
end
-- Coerce an argument that is either an already-parsed table or a version
-- string into a parsed table (or nil).
local function coerce(v)
if type(v) == "table" then return v end
return Semver.parse(v)
end
-- Compare two versions, each a parsed table or an X.Y.Z string.
-- Returns -1 when a < b, 0 when equal, 1 when a > b. An unparseable side
-- sorts as the lowest possible version so a bogus value never wins a "newer"
-- test; two unparseable sides compare equal.
function Semver.compare(a, b)
local pa, pb = coerce(a), coerce(b)
if not pa and not pb then return 0 end
if not pa then return -1 end
if not pb then return 1 end
for _, field in ipairs({ "major", "minor", "patch" }) do
if pa[field] < pb[field] then return -1 end
if pa[field] > pb[field] then return 1 end
end
return 0
end
return Semver
+343
View File
@@ -0,0 +1,343 @@
-- Background worker for the self-update flow (driven by src/update/Check.lua).
--
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
-- touches the render thread. Talks over two channels:
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error }
--
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
-- or a hung download degrades to a "error"/"needs_full" state rather than
-- blocking or crashing the game. On Android curl is absent and the check
-- soft-fails to "error", which the UI hides.
--
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like
-- src/core/chip_worker.lua does. Semver and Boot are authored in parallel; we
-- load them defensively and degrade (a local semver fallback, a permissive
-- gate) if they are not present yet.
require("love.thread")
require("love.filesystem")
require("love.data")
require("love.timer")
require("love.system")
local function loadModule(path)
local ok, chunk = pcall(love.filesystem.load, path)
if not ok or type(chunk) ~= "function" then return nil end
local ok2, mod = pcall(chunk)
if not ok2 then return nil end
return mod
end
local Json = loadModule("src/link/Json.lua")
local Check = loadModule("src/update/Check.lua")
local Version = loadModule("src/core/Version.lua")
local Semver = loadModule("src/update/Semver.lua")
-- Boot's top-level require("src.update.Semver") cannot resolve in this thread
-- (no src.* searcher), which would leave Boot nil and the minShell gate
-- permanently permissive. Seed the loaded table first so it resolves.
if Semver then package.loaded["src.update.Semver"] = Semver end
local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
local function post(t) stateCh:push(t) end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows"
local saveDir = love.filesystem.getSaveDirectory()
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest"
-- the release picked by the last "check"; kept between commands so "download"
-- knows the payload url/size/name without re-fetching
local pending = nil
-- ---------------------------------------------------------------------------
-- shell / curl
-- ---------------------------------------------------------------------------
local function shq(s)
s = tostring(s)
if isWindows then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- run curl and return its response body (text), or nil on any failure. Used
-- for the small text resources (release JSON, sums file); -f makes curl exit
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
local function curlCapture(url)
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local ok, pipe = pcall(io.popen, cmd)
if not ok or not pipe then return nil end
local out = pipe:read("*a")
pipe:close()
if not out or out == "" then return nil end
return out
end
local function haveCurl()
local ok, pipe = pcall(io.popen, "curl --version")
if not ok or not pipe then return false end
local out = pipe:read("*a")
pipe:close()
return out ~= nil and out:find("curl", 1, true) ~= nil
end
-- ---------------------------------------------------------------------------
-- version compare (Semver per contract item 5, with a local fallback)
-- ---------------------------------------------------------------------------
local function parseTriple(s)
s = (tostring(s):gsub("^[vV]", ""))
local a, b, c = s:match("^(%d+)%.(%d+)%.(%d+)")
if not a then return nil end
return { tonumber(a), tonumber(b), tonumber(c) }
end
-- -1 | 0 | 1 for a<b | a==b | a>b
local function compareVersions(a, b)
if Semver and Semver.compare then
local ok, r = pcall(Semver.compare, a, b)
if ok and r ~= nil then return r end
end
local pa, pb = parseTriple(a), parseTriple(b)
if not pa or not pb then return 0 end
for i = 1, 3 do
if pa[i] ~= pb[i] then return pa[i] < pb[i] and -1 or 1 end
end
return 0
end
-- ---------------------------------------------------------------------------
-- verification and the shell gate
-- ---------------------------------------------------------------------------
local function sha256hex(data)
local digest = love.data.hash("sha256", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest)
end
-- Confirm the save-dir file `rel` hashes to the sum listed for `payloadName`.
local function verifyPayload(rel, payloadName, sumsText)
local want = Check.parseSums(sumsText, payloadName)
if not want then return false, "no checksum for " .. payloadName end
local data = love.filesystem.read(rel)
if not data then return false, "cannot read downloaded payload" end
if sha256hex(data):lower() ~= want:lower() then
return false, "checksum mismatch"
end
return true
end
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
local function gatePasses(rel)
if not (Boot and Boot.probePayload) then return true end
local info = Boot.probePayload(rel)
if not info then return true end
local shell = (Version and Version.shell) or 1
if info.minShell and info.minShell > shell then return false end
return true
end
-- ---------------------------------------------------------------------------
-- check
-- ---------------------------------------------------------------------------
local function doCheck()
post({ status = "checking" })
if not haveCurl() then
post({ status = "error", error = "curl not available" })
return
end
local body = curlCapture(API_URL)
if not body then
post({ status = "error", error = "release check failed" })
return
end
local rel, perr = Check.parseRelease(body, Json)
if not rel then
post({ status = "error", error = perr or "bad release json" })
return
end
pending = rel
-- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design).
local currentEngine = (Version and Version.engine) or "0.0.0-dev"
if currentEngine == "0.0.0-dev" then
post({ status = "uptodate", latest = rel.version })
return
end
if compareVersions(rel.version, currentEngine) <= 0 then
post({ status = "uptodate", latest = rel.version })
return
end
-- A newer release, but without the .love payload or its sums we cannot do an
-- in-place update: send the user to the full installers.
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
post({ status = "needs_full", latest = rel.version })
return
end
-- Already downloaded on a previous run? Verify and gate it rather than
-- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then
local sums = curlCapture(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel)
post({ status = "needs_full", latest = rel.version })
return
end
post({ status = "ready", latest = rel.version })
return
end
-- stale / corrupt: drop it and offer a fresh download
love.filesystem.remove(finalRel)
end
post({ status = "available", latest = rel.version })
end
-- ---------------------------------------------------------------------------
-- download
-- ---------------------------------------------------------------------------
-- Launch curl in the background writing `partAbs`, touching `doneAbs` when it
-- exits. Returns without waiting so the caller can poll the growing file for
-- progress. We deliberately do not capture curl's exit code: an incomplete or
-- failed transfer simply fails the checksum below, which is the real gate.
local function launchDownload(url, partAbs, doneAbs)
if isWindows then
-- a tiny batch file sidesteps cmd.exe's nested-quote madness
local batRel = "updates/dl.bat"
love.filesystem.write(batRel,
"@echo off\r\n"
.. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
.. partAbs .. "\" \"" .. url .. "\"\r\n"
.. "type nul > \"" .. doneAbs .. "\"\r\n")
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
else
-- ( ... ) & backgrounds the whole group so os.execute returns at once
os.execute("( curl -fsSL --connect-timeout 15 --max-time 900 -o "
.. shq(partAbs) .. " " .. shq(url)
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
end
end
local function doDownload()
if not (pending and pending.payload and pending.payload.url) then
post({ status = "error", error = "nothing to download" })
return
end
local rel = pending
post({ status = "downloading", latest = rel.version, progress = 0 })
love.filesystem.createDirectory("updates")
local partRel = "updates/" .. rel.payloadName .. ".part"
local doneRel = "updates/" .. rel.payloadName .. ".done"
local finalRel = "updates/" .. rel.payloadName
love.filesystem.remove(partRel)
love.filesystem.remove(doneRel)
local partAbs = saveDir .. "/updates/" .. rel.payloadName .. ".part"
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0
launchDownload(rel.payload.url, partAbs, doneAbs)
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
end
love.filesystem.remove(doneRel)
local sums = curlCapture(rel.sums and rel.sums.url or "")
if not sums then
love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" })
return
end
local ok, verr = verifyPayload(partRel, rel.payloadName, sums)
if not ok then
love.filesystem.remove(partRel)
post({ status = "error", error = verr or "verification failed" })
return
end
if gatePasses(partRel) == false then
love.filesystem.remove(partRel)
post({ status = "needs_full", latest = rel.version })
return
end
-- finalize: rename the verified .part to its real name (fall back to a
-- love.filesystem copy if os.rename is unavailable on this platform)
if not os.rename(partAbs, saveDir .. "/updates/" .. rel.payloadName) then
local data = love.filesystem.read(partRel)
if not data then
post({ status = "error", error = "finalize failed" })
return
end
love.filesystem.write(finalRel, data)
love.filesystem.remove(partRel)
end
post({ status = "ready", latest = rel.version })
end
-- ---------------------------------------------------------------------------
-- command loop
-- ---------------------------------------------------------------------------
while true do
local cmd = cmdCh:demand() -- blocks until the main thread pushes work
if type(cmd) == "table" then
if cmd.cmd == "quit" then
break
elseif cmd.cmd == "check" then
local ok, err = pcall(doCheck)
if not ok then post({ status = "error", error = tostring(err) }) end
elseif cmd.cmd == "download" then
local ok, err = pcall(doDownload)
if not ok then post({ status = "error", error = tostring(err) }) end
end
end
end
+11 -2
View File
@@ -101,9 +101,18 @@ function NPC:walkPhase()
return (p >= 4 and p < 12) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
-- phase this frame renders to -- so a render pipeline can pose an NPC
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
function NPC:pose()
return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false
end
function NPC:draw(camX, camY)
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
self:walkPhase(), self.stepFlip)
local sprite, px, py, facing, phase, flip = self:pose()
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
return NPC
+126 -6
View File
@@ -12,6 +12,7 @@ local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local NPC = require("src.world.NPC")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Player = require("src.world.Player")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -20,6 +21,7 @@ local Tilt = require("src.render.Tilt")
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local Warp = require("src.world.Warp")
local Zoom = require("src.render.Zoom")
-- isOverworld marks the live world state for WorldAPI's stack scan
local OverworldState = { isOpaque = true, isOverworld = true }
@@ -3539,11 +3541,22 @@ function OverworldState:drawWorld()
-- tilt is active). So the ground draw calls below never change with tilt;
-- only the sprite/FX draw path below them branches. The sorts below only
-- reorder (no draws), so they run once for both paths.
local tilt = Tilt.active()
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
-- A render pipeline (src/render/Pipelines.lua) replaces the ground draw
-- entirely with geometry of its own, so it is decided before tilt and
-- wins over it. It falls back to the tilt/flat path whenever it cannot
-- run this frame -- headless, a driver with no depth canvas, or a mod
-- that threw -- so no caller ever sees a blank frame.
local pipelineId = Pipelines.worldPipeline()
local tilt = (not pipelineId) and Tilt.active()
-- the pipeline's finished world image, once it has run; nil keeps every
-- path below on the vanilla flat/tilt draw
local override
if not pipelineId then
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
end
-- per-billboard SGB palette source; only needed (and only paid for) when
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
@@ -3774,7 +3787,114 @@ function OverworldState:drawWorld()
end
end
if not tilt then
if pipelineId then
-- === PIPELINE PATH: a mod owns the world pass. ======================
-- It renders terrain and characters however it likes and hands back one
-- window-resolution image; the field FX stay ordinary 2D draws
-- composited on top by ctx.drawFx, each anchored to where its ground
-- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run.
local pw, ph = love.graphics.getDimensions()
local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
width = pw, height = ph, scale = pscale,
level = Pipelines.level(pipelineId),
-- the SGB world palette a map draws under; nil in the true-colour
-- modes, whose art is already baked (and must not be re-mapped)
paletteFor = function(map)
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
spriteColors = function(map)
if PaletteFX.usesGbcPack() then return nil end
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
}
-- Draw every active field FX into the finished scene. `project(wx, wy)`
-- maps a world point to canvas pixels (nil when it is behind the
-- camera) and `scale` is canvas pixels per world pixel; the pipeline
-- owns the camera, this owns where each effect belongs and how the
-- closures' flat coordinates are slid onto the projected anchor.
-- Deliberately unscaled by depth, like :billboard: an effect keeps its
-- crisp authored size and only its anchor moves.
ctx.drawFx = function(project, scale)
scale = scale or pscale
local colors = ctx.spriteColors()
local function at(drawFn, wx, wy)
if not drawFn then return end
local sx, sy = project(wx, wy)
if not sx then return end -- behind the camera
local shader = colors and PaletteFX.shader() or nil
if shader then
PaletteFX.sendColors(shader, colors)
love.graphics.setShader(shader)
end
-- the closures draw relative to the flat foot; slide that onto the
-- projected anchor, in world-pixel units inside the scaled transform
local fx, fy = wx - cam.x, wy - cam.y
love.graphics.push()
love.graphics.scale(scale, scale)
love.graphics.translate(sx / scale - fx, sy / scale - fy)
drawFn()
love.graphics.pop()
if shader then love.graphics.setShader() end
end
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8)
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
end
if self.healAnim then
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
end
-- standing effects anchor at the foot of whoever they belong to
if self.emote and self.emote.npc then
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
end
if self.flyAnim then
at(fxBird, self.player.px + 8, self.player.py + 16)
end
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
-- Rock Tunnel darkness is a screen-space light window, not a ground
-- object: draw it flat over the finished scene like the tilt path.
-- It fills the view in world-pixel units, so it only needs the scale.
if self.dark then
love.graphics.push()
love.graphics.scale(scale, scale)
fxDark()
love.graphics.pop()
end
end
override = Pipelines.drawWorld(pipelineId, ctx)
-- world post-processes (a miniature-diorama blur, a colour grade) fold
-- over the finished scene here, so they never touch the UI drawn on top
if override then
override = Pipelines.worldPresent(override, ctx)
end
Game.renderer:setWorldOverride(override)
if not override then
-- The pipeline declined this frame (nothing to draw, or it threw and
-- was retired). The ground pass was skipped on its behalf above, so
-- draw it now and fall through to the flat path below rather than
-- compositing an empty canvas.
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
end
end
if override then
-- the pipeline owns the whole frame; nothing else draws into the world
elseif not tilt then
-- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
+34 -16
View File
@@ -140,28 +140,29 @@ end
local SPIN_ORDER = { "down", "left", "up", "right" }
function Player:draw(camX, camY)
-- What this frame renders to: the sheet, where it sits, which way it faces
-- and how far through a step it is. Shared by the 2D draw below and by a
-- render pipeline's own geometry (src/render/Pipelines.lua), so the two can
-- never disagree about which sprite or facing is current.
--
-- The last return says the player is mid-ledge-hop, which is what the 2D
-- path draws the ground shadow from and a 3D path turns into vertical lift.
--
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
-- and draw() may run per frame -- and draw() is written in terms of pose()
-- to keep that true by construction. (hopFrames counts down in
-- Player:update, on the fixed step, so it is safe to read here.)
function Player:pose()
local py = self.py
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
-- hopFrames counts down in Player:update (fixed step), never here.
local hopping = false
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
if self.hopFrames and self.hopFrames > 0 then
local total = self.hopTotal or 32
-- update runs before draw, so remaining N means N steps already
-- consumed this hop → t matches the old draw-side post-decrement phase
local t = 1 - self.hopFrames / total
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
hopping = true
elseif self.surfing then
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
py = py + (self.bobTimer < 16 and 0 or 1)
@@ -186,7 +187,24 @@ function Player:draw(camX, camY)
end
local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing, phase, flip)
return sprite, self.px, py, facing, phase, flip, hopping
end
function Player:draw(camX, camY)
local sprite, px, py, facing, phase, flip, hopping = self:pose()
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if hopping and self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
return Player
+193
View File
@@ -0,0 +1,193 @@
-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the
-- status derivation (deriveList) over a synthetic manifest list + options
-- table, and the archive-root location logic (locateRoot). The discovery and
-- installZip paths need love.filesystem and are exercised by the launcher; the
-- decision logic under them lives here so a bad range/conflict/root call fails
-- one line instead of the app.
-- luajit tests/engine/launcher_mods_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Manifest = require("src.mods.Manifest")
local Version = require("src.core.Version")
local LauncherMods = require("src.mods.LauncherMods")
-- validated manifests are the exact shape deriveList/resolveToggle read
local function mf(raw)
return Manifest.validate(raw)
end
-- index a deriveList result by mod id for assertions
local function byId(list)
local m = {}
for _, row in ipairs(list) do m[row.id] = row end
return m
end
-- ------- badge derivation: category, then profile, then MOD (uppercased)
do
local list = LauncherMods.deriveList({
mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua",
category = "gameplay" }),
mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua",
profile = "overhaul" }),
mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }),
}, { mods = {} })
local m = byId(list)
eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased")
eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category")
-- no category field, so the fallback reaches the profile default ("content")
eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile")
eq(#list, 3, "every discovered manifest yields one row")
check(m.cat.id < m.plain.id and m.plain.id < m.prof.id,
"rows come back sorted by id (cat < plain < prof)")
end
-- ------- enabled defaults to true; a false entry disables
do
local manifests = {
mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }),
mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
check(not m.bbb.enabled, "an explicit false disables the mod")
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end
-- ------- conflict: only when this mod is enabled and the other is too
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" } }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
-- both enabled: the declaring side (and, symmetrically, the other) conflict
local both = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod")
check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil,
"conflict detail names the other mod")
eq(both.beta.status, "conflict",
"resolveToggle conflict is bidirectional: the target is flagged too")
-- disable beta: alpha no longer conflicts (nothing enabled to conflict with)
local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } }))
eq(off.alpha.status, "ok", "no conflict once the other side is disabled")
eq(off.beta.status, "ok", "a disabled mod is never a conflict")
end
-- ------- warn: unsatisfied game_version range against Version.engine
do
-- a range the -dev engine cannot satisfy (needs a released >=1.0.0)
local manifests = {
mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua",
game_version = ">=1.0.0" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.future.status, "warn", "engine outside the game_version range warns")
check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil,
"version warn detail quotes the required range")
check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil,
"version warn detail quotes the engine version")
end
-- ------- warn: hard dependency missing, disabled, or wrong version
do
local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" }
local needsMissing = { id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "ghost" } }
local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} }))
eq(m.needy.status, "warn", "a missing hard dependency warns")
check(m.needy.statusDetail:find("not installed", 1, true) ~= nil,
"missing-dep detail says not installed")
-- present but disabled
local m2 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base" } }) },
{ mods = { base = false } }))
eq(m2.needy.status, "warn", "a disabled hard dependency warns")
check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil,
"disabled-dep detail says disabled")
-- present, enabled, but the version is out of range
local m3 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=2.0.0" } }) },
{ mods = {} }))
eq(m3.needy.status, "warn", "a dependency below the required range warns")
eq(m3.base.status, "ok", "the satisfied dependency itself stays ok")
-- the same dep satisfied: needy is ok
local m4 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=1.0.0" } }) },
{ mods = {} }))
eq(m4.needy.status, "ok", "a satisfied dependency clears the warn")
end
-- ------- conflict outranks warn when a mod trips both
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" }, game_version = ">=1.0.0" }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.alpha.status, "conflict",
"conflict is reported ahead of a version warn on the same mod")
end
-- ------- locateRoot: manifest at the archive root
do
local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" })
eq(root, "", "a root-level manifest.json resolves to the empty prefix")
eq(err, nil, "no error for a root-level manifest")
end
-- ------- locateRoot: manifest inside a single top-level folder
do
local root = LauncherMods.locateRoot({
"mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" })
eq(root, "mymod", "a single wrapping folder resolves to that folder name")
end
-- ------- locateRoot: no manifest anywhere
do
local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" })
eq(root, nil, "an archive with no manifest.json resolves to nil")
check(err:find("no manifest.json", 1, true) ~= nil,
"the no-manifest reason is user-presentable")
end
-- ------- locateRoot: multiple top-level folders is ambiguous
do
local root, err = LauncherMods.locateRoot({
"one/manifest.json", "two/manifest.json" })
eq(root, nil, "two candidate mod folders resolves to nil")
check(err:find("single mod folder", 1, true) ~= nil,
"the ambiguous reason asks for a single mod folder")
end
-- ------- locateRoot: a lone folder without a manifest is not a root
do
local root, err = LauncherMods.locateRoot({ "assets/x.png" })
eq(root, nil, "a single folder with no manifest is not a mod root")
check(err ~= nil, "the no-root case carries a reason")
end
T.finish("launcher_mods")
+233
View File
@@ -0,0 +1,233 @@
-- Launcher save Import/Export glue (src/import/SaveFileIO.lua): the end-to-end
-- importToSlot -> listSlots roundtrip and the exportActiveSlot output-byte
-- sanity check, driven love-free through the same in-memory filesystem stub
-- tests/engine/save_slots.lua uses. A synthetic 32KB SRAM image is built via
-- GenSave.encode (no real save checked in); a fixture-gated case exercises the
-- real .sav when POKEPORT_SAV_FIXTURE points at one.
-- luajit tests/engine/save_file_io_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = require("src.import.SaveFileIO")
local realFS = love.filesystem
-- A love.filesystem stub keyed by full path, extended past save_slots' memfs
-- with the export surface SaveFileIO reaches for (createDirectory /
-- getSaveDirectory). A directory key is implied by any file under it.
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
createDirectory = function() return true end,
getSaveDirectory = function() return "/fake/save" end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do)
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
-- independent checksum re-derivation (complement of the additive byte sum) so
-- the export sanity check does not trust the encoder that wrote it
local bit = require("bit")
local OFF = GenSave.OFFSETS
local function rawChecksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
local function mainChecksumValid(bytes)
return rawChecksum(bytes, OFF.checksumStart, OFF.checksumEnd)
== bytes:byte(OFF.mainChecksum + 1)
end
local function syntheticSave(name)
local seed = SaveData.newGame({ playerName = name, rivalName = "BLUE" })
seed.money = 4321
seed.inventory = { POTION = 2, POKE_BALL = 7, BOULDERBADGE = 1 }
seed.bagOrder = { "POTION", "POKE_BALL" }
seed.party = { {
species = "SQUIRTLE", level = 6, exp = 200,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
hp = 22, status = nil,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
} }
return GenSave.encode(seed, data, nil)
end
-- ---------------------------------------------- importToSlot -> listSlots
do
fresh()
local bytes = syntheticSave("IMP")
eq(#bytes, GenSave.SAVE_SIZE, "the synthetic save is 32768 bytes")
local ok, slotId = SaveFileIO.importToSlot(bytes, "red")
eq(ok, true, "importToSlot succeeds on a valid 32KB save")
eq(slotId, "slot1", "the first import registers slot1")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "the imported save shows up as exactly one slot")
eq(slots[1].id, "slot1", "the listed slot is slot1")
eq(slots[1].exists, true, "the imported slot reports a save present")
eq(slots[1].name, "IMP", "the imported slot surfaces the decoded player name")
eq(SaveData.activeSlot("red"), "slot1", "the imported slot is made active")
-- the slot loads cleanly (meta re-stamped from gen1_import to the numeric
-- format, so runMigrations does not choke)
local loaded = SaveData.load("red")
check(loaded ~= nil, "the imported slot loads back")
eq(loaded and loaded.player.name, "IMP", "loaded save keeps the player name")
eq(loaded and loaded.money, 4321, "loaded save keeps the money")
eq(loaded and #loaded.party, 1, "loaded save keeps the party")
-- a second import allocates a fresh slot and makes it active
local ok2, slot2 = SaveFileIO.importToSlot(syntheticSave("TWO"), "red")
eq(ok2, true, "a second import succeeds")
eq(slot2, "slot2", "the second import allocates slot2")
eq(#SaveData.listSlots("red"), 2, "both imported slots are listed")
eq(SaveData.activeSlot("red"), "slot2", "the newest import becomes active")
end
-- ---------------------------------------------- exportActiveSlot byte sanity
do
local files = fresh()
SaveFileIO.importToSlot(syntheticSave("EXP"), "red")
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
"the export path is absolute and names the version + slot")
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
-- the export re-imports to an equivalent save
local re = SaveConvert.importSav(outBytes, "red")
check(re ~= nil, "the export re-imports through SaveConvert")
eq(re and re.player and re.player.name, "EXP", "the export round-trips the player name")
eq(re and re.party[1] and re.party[1].species, "SQUIRTLE",
"the export round-trips the party")
eq(re and re.inventory and re.inventory.BOULDERBADGE, 1,
"the export round-trips a badge")
end
-- ---------------------------------------------- failure UX (never raises)
do
fresh()
-- wrong size via a DroppedFile-shaped source (100 bytes)
local shortFile = {
_bytes = string.rep("\0", 100),
open = function() return true end,
getSize = function(self) return #self._bytes end,
read = function(self) return self._bytes end,
close = function() return true end,
}
local ok, err = SaveFileIO.importToSlot(shortFile, "red")
eq(ok, false, "a wrong-size save is rejected, not imported")
check(type(err) == "string" and err:find("32", 1, true) ~= nil,
"the wrong-size error names the required size")
eq(#SaveData.listSlots("red"), 0, "a rejected import creates no slot")
-- bad checksum: flip a modeled byte in an otherwise valid image
local good = syntheticSave("BAD")
local corrupt = good:sub(1, OFF.money)
.. string.char((good:byte(OFF.money + 1) + 1) % 256)
.. good:sub(OFF.money + 2)
local okc, errc = SaveFileIO.importToSlot(corrupt, "red")
eq(okc, false, "a bad-checksum save is rejected")
check(type(errc) == "string" and errc:find("checksum", 1, true) ~= nil,
"the bad-checksum error mentions the checksum")
eq(#SaveData.listSlots("red"), 0, "a rejected checksum creates no slot")
-- export with nothing to export
local oke, erre = SaveFileIO.exportActiveSlot("red")
eq(oke, false, "exportActiveSlot fails cleanly when there is no save")
check(type(erre) == "string", "the empty-export failure carries a message")
end
-- ---------------------------------------------- fixture-gated real save
do
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
local fixtureBytes
if fixturePath then
local ff = io.open(fixturePath, "rb")
if ff then
fixtureBytes = ff:read("*a")
ff:close()
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
end
end
if not fixtureBytes then
print("save_file_io fixture case skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav)")
else
local files = fresh()
local ok, slotId = SaveFileIO.importToSlot(fixtureBytes, "red")
eq(ok, true, "fixture: a real .sav imports to a slot")
check(slotId ~= nil, "fixture: the import returns a slot id")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "fixture: the real save shows as one slot")
check(slots[1].exists and type(slots[1].name) == "string" and #slots[1].name > 0,
"fixture: the imported slot has a non-empty player name")
local loaded = SaveData.load("red")
check(loaded ~= nil and #loaded.party >= 1 and #loaded.party <= 6,
"fixture: the imported slot loads with a 1..6 party")
local eok, path = SaveFileIO.exportActiveSlot("red")
eq(eok, true, "fixture: the imported real save exports")
local rel = path:gsub("^/fake/save/", "")
local outBytes = files[rel]
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "fixture: the export is 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"fixture: the export has a valid main-data checksum")
end
end
love.filesystem = realFS
T.finish("save_file_io")
+211
View File
@@ -0,0 +1,211 @@
-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot
-- registry in options.lua, listSlots/setActiveSlot/createSlot, and the
-- active-slot resolution behind saveNames/save/load. Self-contained: it
-- installs the love stub only for a swappable in-memory filesystem, the
-- same way tests/mod_save_tests isolates its save round-trips.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
-- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua"
-- needs no directory support (createDirectory is deliberately absent, which
-- is exactly what the ensureParentDir no-op path handles)
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
-- a fresh filesystem + cleared process globals: each scenario is a first boot
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- a minimal but fully decodable Red save
local function legacySave(name, dexOwned, badges, playTime)
local owned = {}
for _, id in ipairs(dexOwned or {}) do owned[id] = true end
local inv = {}
for _, id in ipairs(badges or {}) do inv[id] = true end
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = owned },
inventory = inv,
playTime = playTime or 0,
}
end
-- ---------------------------------------------- slotSummary (pure)
do
local name, meta = SaveData.slotSummary(
legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" },
{ "BOULDERBADGE", "CASCADEBADGE" }, 3661))
T.eq(name, "ASH", "slotSummary reads the player name")
T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries")
T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory")
T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM")
local n2, m2 = SaveData.slotSummary(nil)
T.eq(n2, nil, "slotSummary of an empty slot has no name")
T.eq(m2, nil, "slotSummary of an empty slot has no meta")
end
-- ---------------------------------------------- legacy migration happy path
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325))
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "legacy save migrates into exactly one slot")
T.eq(slots[1].id, "slot1", "the migrated slot is slot1")
T.eq(slots[1].exists, true, "the migrated slot reports a save present")
T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name")
T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count")
T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count")
T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time")
T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration")
T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active")
T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot")
-- load() now resolves the active slot and reads the migrated save
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "RED", "load reads the active slot")
end
-- ---------------------------------------------- migration idempotence
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0))
SaveData.listSlots("red") -- first boot: migrates
local slotBytes = files["saves/red/slot1.lua"]
-- a second boot: registry exists, no flat file, so nothing re-migrates
SaveData.resetSlotState()
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot")
T.eq(files["save.lua"], nil, "no flat file reappears on re-boot")
T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot")
end
-- ---------------------------------------------- mixed real / empty slots
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60))
SaveData.listSlots("red") -- slot1 = the migrated real save
local empty = SaveData.createSlot("red")
T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1")
local slots = SaveData.listSlots("red")
T.eq(#slots, 2, "both the real and empty slots are listed")
T.eq(slots[1].exists, true, "the migrated slot still reports a save")
T.eq(slots[1].name, "REAL", "the real slot keeps its name")
T.eq(slots[2].exists, false, "the freshly created slot is empty")
T.eq(slots[2].name, nil, "an empty slot has no name")
T.eq(slots[2].meta, nil, "an empty slot has no meta")
end
-- ---------------------------------------------- setActiveSlot persistence
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id")
T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved")
T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list")
-- selecting a slot that was never registered adds it
SaveData.setActiveSlot("red", "slot7")
opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added")
T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list")
end
-- ---------------------------------------------- createSlot id allocation
do
fresh()
T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1")
T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2")
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
end
-- ---------------------------------------------- saveNames follows the slot
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local save = SaveData.newGame()
save.player.name = "SLOT2"
T.check(SaveData.save(save), "save writes to the active slot")
T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file")
T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save")
T.eq(files["save.lua"], nil, "no flat file is written once a slot is active")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
end
-- ---------------------------------------------- a version with no slots
do
local files = fresh()
local slots = SaveData.listSlots("red")
T.eq(#slots, 0, "a fresh install with no legacy save lists no slots")
-- with nothing registered, save/load use the flat legacy path, exactly
-- as they did before slots existed
local save = SaveData.newGame()
save.player.name = "FLAT"
T.check(SaveData.save(save), "a slotless version saves to the flat file")
T.check(files["save.lua"] ~= nil, "the flat save.lua is written")
T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file")
T.eq(SaveData.saveFilename("red"), "save.lua",
"saveFilename still resolves the flat name with no slot in use")
end
love.filesystem = realFS
T.finish("save_slots")
+65
View File
@@ -0,0 +1,65 @@
-- Pure-surface coverage for src/update/Check.lua (the self-update release
-- check / payload download module). The network, hashing and archive-probe
-- logic lives in src/update/check_worker.lua and needs love + curl; these are
-- the love-free extraction/parsing seams the worker and UI both trust.
-- luajit tests/engine/update_check_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- releaseUrl is the fixed public landing page the UI links on needs_full
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest",
"releaseUrl points at the repo's latest release")
-- parseRelease: a well-formed release with the .love payload and its sums
local body = Json.encode({
tag_name = "v1.4.2",
assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
},
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "leading v stripped from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version")
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "version parsed without assets")
eq(noPayload.payload, nil, "no payload asset -> nil")
eq(noPayload.sums, nil, "no sums asset -> nil")
-- rejects: non-semver tag, and a document with no tag at all
local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(bad, nil, "non-X.Y.Z tag rejected")
check(badErr ~= nil, "rejection carries an error string")
eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected")
-- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './'
-- prefix and CRLF line endings; unrelated lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"not a checksum line\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed")
eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered")
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash")
eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly")
-- pickAsset guards a non-table assets field
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
T.finish("update_check")
+300
View File
@@ -0,0 +1,300 @@
-- Pure-logic coverage for the self-updater (src/update/*). Every export
-- exercised here is love-free: Semver's parse/compare, Boot's select()
-- decision function, and Check's release-JSON / sha256sums / asset parsers.
-- The love-bound halves (Boot.run's mount+chainload, Check's thread worker,
-- curl, hashing) need a real LOVE process and are covered elsewhere; this
-- suite is the plain-Lua seam the whole updater trusts.
-- luajit tests/engine/update_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Semver = require("src.update.Semver")
local Boot = require("src.update.Boot")
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- ---------------------------------------------------------------------------
-- Semver.parse
-- ---------------------------------------------------------------------------
-- valid triples decode to numeric fields, not strings
local p = Semver.parse("1.2.3")
check(p ~= nil, "parse accepts a plain X.Y.Z")
eq(p.major, 1, "parse major")
eq(p.minor, 2, "parse minor")
eq(p.patch, 3, "parse patch")
eq(type(p.major), "number", "parse yields numbers, not strings")
local zero = Semver.parse("0.0.0")
eq(zero.major, 0, "parse zeros: major")
eq(zero.patch, 0, "parse zeros: patch")
local big = Semver.parse("10.20.30")
eq(big.major, 10, "parse multi-digit major")
eq(big.minor, 20, "parse multi-digit minor")
eq(big.patch, 30, "parse multi-digit patch")
-- an optional leading lowercase "v" is stripped
local v = Semver.parse("v2.5.9")
check(v ~= nil, "parse accepts a leading v")
eq(v.major, 2, "leading v: major")
eq(v.minor, 5, "leading v: minor")
eq(v.patch, 9, "leading v: patch")
-- rejects: partial versions, extra components, non-numeric parts, suffixes,
-- a bare v, whitespace, empties, and non-string inputs -- all return nil, not
-- a raise (the safe answer for the updater is "not a real version")
eq(Semver.parse("1.2"), nil, "parse rejects a two-part version")
eq(Semver.parse("1"), nil, "parse rejects a one-part version")
eq(Semver.parse("1.2.3.4"), nil, "parse rejects a four-part version")
eq(Semver.parse("1.2.x"), nil, "parse rejects a non-numeric part")
eq(Semver.parse("1.2.3-dev"), nil, "parse rejects a pre-release suffix")
eq(Semver.parse("0.0.0-dev"), nil, "parse rejects the working-tree placeholder")
eq(Semver.parse("v"), nil, "parse rejects a bare v")
eq(Semver.parse(" 1.2.3"), nil, "parse rejects leading whitespace (anchored)")
eq(Semver.parse("1.2.3 "), nil, "parse rejects trailing whitespace (anchored)")
eq(Semver.parse(""), nil, "parse rejects the empty string")
eq(Semver.parse("nightly"), nil, "parse rejects a non-numeric tag")
eq(Semver.parse(nil), nil, "parse rejects nil")
eq(Semver.parse(123), nil, "parse rejects a number")
eq(Semver.parse({}), nil, "parse rejects a table")
-- ---------------------------------------------------------------------------
-- Semver.compare
-- ---------------------------------------------------------------------------
-- ordering is major, then minor, then patch
eq(Semver.compare("2.0.0", "1.9.9"), 1, "compare: major dominates (a > b)")
eq(Semver.compare("1.0.0", "2.0.0"), -1, "compare: major dominates (a < b)")
eq(Semver.compare("1.2.0", "1.1.9"), 1, "compare: minor breaks a major tie (a > b)")
eq(Semver.compare("1.1.0", "1.2.0"), -1, "compare: minor breaks a major tie (a < b)")
eq(Semver.compare("1.1.2", "1.1.1"), 1, "compare: patch breaks a minor tie (a > b)")
eq(Semver.compare("1.1.1", "1.1.2"), -1, "compare: patch breaks a minor tie (a < b)")
-- equality
eq(Semver.compare("1.2.3", "1.2.3"), 0, "compare: identical versions are equal")
eq(Semver.compare("v1.2.3", "1.2.3"), 0, "compare: leading v does not change value")
-- string and already-parsed-table inputs interoperate on either side
eq(Semver.compare(Semver.parse("1.2.3"), "1.2.4"), -1, "compare: parsed-table a vs string b")
eq(Semver.compare("1.3.0", Semver.parse("1.2.9")), 1, "compare: string a vs parsed-table b")
eq(Semver.compare({ major = 2, minor = 0, patch = 0 },
{ major = 1, minor = 9, patch = 9 }), 1, "compare: raw tables on both sides")
eq(Semver.compare(Semver.parse("4.4.4"), Semver.parse("4.4.4")), 0, "compare: equal parsed tables")
-- an unparseable side sorts as the lowest possible version, so a bogus value
-- never wins a "newer" test; two unparseable sides are equal
eq(Semver.compare("garbage", "1.0.0"), -1, "compare: unparseable a loses to a real version")
eq(Semver.compare("1.0.0", "garbage"), 1, "compare: a real version beats an unparseable b")
eq(Semver.compare("garbage", "junk"), 0, "compare: two unparseable sides are equal")
eq(Semver.compare(nil, "1.0.0"), -1, "compare: nil a sorts lowest")
eq(Semver.compare("1.0.0", nil), 1, "compare: nil b sorts lowest")
-- ---------------------------------------------------------------------------
-- Boot.select (pure: no love.*)
-- ---------------------------------------------------------------------------
-- membership helper: toDelete order is deterministic, but assert on the set so
-- the tests document intent rather than iteration accidents
local function nameSet(list)
local s = {}
for _, n in ipairs(list) do s[n] = true end
return s
end
-- empty candidate list: nothing to run, nothing to delete
do
local chosen, del = Boot.select({}, "1.0.0", 1)
eq(chosen, nil, "select: empty candidate list picks nothing")
eq(#del, 0, "select: empty candidate list deletes nothing")
end
-- picks the highest eligible payload and marks the lower runnable ones (still
-- newer than bundled, but superseded by the winner) for deletion
do
local candidates = {
{ name = "a.love", engine = "1.1.0" }, -- no minShell -> defaults to 1
{ name = "b.love", engine = "1.3.0", minShell = 1 },
{ name = "c.love", engine = "1.2.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "b.love", "select: picks the highest eligible engine")
local d = nameSet(del)
eq(#del, 2, "select: both losers are marked for deletion")
check(d["a.love"] and d["c.love"], "select: superseded runnable payloads are deleted")
check(not d["b.love"], "select: the chosen payload is never deleted")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it
do
local candidates = {
{ name = "future.love", engine = "2.0.0", minShell = 2 }, -- unrunnable at shell 1
{ name = "ok.love", engine = "1.5.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "ok.love", "select: skips a payload whose minShell exceeds the bundled shell")
eq(#del, 0, "select: a newer-but-unrunnable payload is kept, not deleted")
check(not nameSet(del)["future.love"], "select: unrunnable-newer payload survives")
end
-- skips payloads not strictly newer than bundled (older AND equal) and marks
-- them stale for deletion
do
local candidates = {
{ name = "old.love", engine = "0.9.0", minShell = 1 }, -- older than bundled
{ name = "same.love", engine = "1.0.0", minShell = 1 }, -- equal to bundled
{ name = "new.love", engine = "1.1.0", minShell = 1 }, -- the only real update
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "new.love", "select: only a strictly-newer payload is eligible")
local d = nameSet(del)
eq(#del, 2, "select: older and equal payloads are both stale")
check(d["old.love"], "select: an older payload is deleted")
check(d["same.love"], "select: a same-version payload is deleted")
check(not d["new.love"], "select: the winner is not in the delete list")
end
-- no eligible payload at all (all older or equal): pick nothing, delete every
-- stale candidate
do
local candidates = {
{ name = "old.love", engine = "0.5.0", minShell = 1 },
{ name = "same.love", engine = "1.0.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, nil, "select: no strictly-newer payload -> nothing chosen")
eq(#del, 2, "select: every stale candidate is cleaned up when nothing wins")
end
-- the full mix in one pass: a superseded runnable one and a stale old one are
-- deleted; the chosen winner and a newer-but-unrunnable payload both survive
do
local candidates = {
{ name = "sup.love", engine = "1.2.0", minShell = 1 }, -- newer, runnable, < winner
{ name = "win.love", engine = "1.4.0", minShell = 1 }, -- the winner
{ name = "future.love", engine = "2.0.0", minShell = 5 }, -- newer than winner, unrunnable
{ name = "old.love", engine = "0.1.0", minShell = 1 }, -- stale
}
local chosen, del = Boot.select(candidates, "1.1.0", 1)
eq(chosen, "win.love", "select(mix): highest runnable-newer engine wins")
local d = nameSet(del)
eq(#del, 2, "select(mix): exactly the superseded and stale payloads are deleted")
check(d["sup.love"], "select(mix): a runnable payload below the winner is superseded")
check(d["old.love"], "select(mix): a stale payload is cleaned up")
check(not d["future.love"], "select(mix): a newer-but-unrunnable payload is kept")
check(not d["win.love"], "select(mix): the winner is kept")
end
-- ---------------------------------------------------------------------------
-- Check.pickAsset
-- ---------------------------------------------------------------------------
local assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
}
local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love")
check(picked ~= nil, "pickAsset finds an asset by exact name")
eq(picked.url, "http://x/love", "pickAsset returns the download url")
eq(picked.size, 12345, "pickAsset returns the numeric size")
eq(Check.pickAsset(assets, "does-not-exist.love"), nil, "pickAsset misses cleanly on an unknown name")
-- coerces a string size to a number and tolerates non-table junk entries mixed
-- into the asset list
local coerced = Check.pickAsset({ "junk", 42, { name = "w", browser_download_url = "U", size = "7" } }, "w")
eq(coerced.size, 7, "pickAsset coerces a string size to a number")
eq(type(coerced.size), "number", "pickAsset size is a number after coercion")
-- guards a non-table / nil assets field instead of raising
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
eq(Check.pickAsset("nope", "x"), nil, "pickAsset tolerates a non-table asset list")
-- ---------------------------------------------------------------------------
-- Check.parseRelease (release-JSON extraction)
-- ---------------------------------------------------------------------------
-- a well-formed release: version (leading v stripped), derived payload name,
-- and both the .love payload and its sums asset with url + size
local body = Json.encode({
tag_name = "v1.4.2",
assets = assets,
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version")
eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
-- a release with no .love yet still parses; payload/sums are nil so the worker
-- routes to a full reinstall rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "parseRelease reads the version without any assets")
eq(noPayload.payload, nil, "parseRelease reports a missing payload asset as nil")
eq(noPayload.sums, nil, "parseRelease reports a missing sums asset as nil")
-- rejections carry an error string and never raise
local badTag, badTagErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(badTag, nil, "parseRelease rejects a non-X.Y.Z tag")
check(badTagErr ~= nil, "parseRelease rejection carries an error string")
local noTag, noTagErr = Check.parseRelease(Json.encode({ foo = 1 }))
eq(noTag, nil, "parseRelease rejects a document with no tag_name")
check(noTagErr ~= nil, "parseRelease missing-tag rejection carries an error string")
-- malformed input returns nil rather than raising (Json.decode yields nil, and
-- a bare non-object literal has no tag_name)
local ok1, garbage = pcall(Check.parseRelease, "this is not json {{{")
check(ok1, "parseRelease does not raise on unparseable JSON")
eq(garbage, nil, "parseRelease returns nil on unparseable JSON")
local ok2, empty = pcall(Check.parseRelease, "")
check(ok2, "parseRelease does not raise on empty input")
eq(empty, nil, "parseRelease returns nil on empty input")
local ok3, literal = pcall(Check.parseRelease, "42")
check(ok3, "parseRelease does not raise on a bare JSON literal")
eq(literal, nil, "parseRelease returns nil on a non-object JSON literal")
-- ---------------------------------------------------------------------------
-- Check.parseSums (shasum -a 256 line parsing)
-- ---------------------------------------------------------------------------
-- standard "<hex> <file>" lines, the '*' binary marker, a './' prefix, CRLF
-- endings and mixed hash case; junk lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"deadBEEF ./nested.love\n" ..
"not a checksum line at all\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line")
eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases")
eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./")
eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums")
-- the target form returns just that file's hash (hit / miss)
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash")
eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file")
-- degenerate inputs: empty text yields an empty map, a targeted miss is nil,
-- and a nil text does not raise
local emptyMap = Check.parseSums("")
eq(type(emptyMap), "table", "parseSums('') returns an (empty) table")
eq(next(emptyMap), nil, "parseSums('') has no entries")
eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil without raising")
-- ---------------------------------------------------------------------------
-- Check.releaseUrl (the fixed public landing page)
-- ---------------------------------------------------------------------------
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest",
"releaseUrl points at the repo's latest release")
T.finish("update")
+40 -5
View File
@@ -28,6 +28,17 @@ end
local files = {} -- in-memory love.filesystem
-- Minimal graphics-state tracking so push("all")/pop actually save and
-- restore, and getShader/getCanvas/etc can be read back. The render-pipeline
-- fold fences each mod callback between push("all")/pop so a callback that
-- dirties state cannot leak into the engine composite; mod_render_tests
-- asserts exactly that, which needs the stub to model the save/restore rather
-- than no-op it. Plain push()/pop() (the tilt upright pass) ride the same
-- stack and restore the same fields, which for those call sites is a no-op.
local gstate = { shader = nil, canvas = nil, blend = "alpha",
color = { 1, 1, 1, 1 } }
local gstack = {}
stub.graphics = {
newImage = function(path)
local w, h = pngSize(path)
@@ -44,13 +55,37 @@ stub.graphics = {
function batch:setTexture(tex) self.texture = tex end
return batch
end,
draw = noop, rectangle = noop, setColor = noop, clear = noop,
setCanvas = noop, setDefaultFilter = noop, print = noop,
draw = noop, rectangle = noop, clear = noop,
setDefaultFilter = noop, print = noop,
setColor = function(r, g, b, a) gstate.color = { r, g, b, a } end,
getColor = function()
local c = gstate.color
return c[1], c[2], c[3], c[4]
end,
setCanvas = function(c) gstate.canvas = c or nil end,
getCanvas = function() return gstate.canvas end,
setShader = function(s) gstate.shader = s or nil end,
getShader = function() return gstate.shader end,
setBlendMode = function(m) gstate.blend = m or "alpha" end,
getBlendMode = function() return gstate.blend end,
-- coordinate-transform + state stack used by the tilt-mode upright pass
-- (billboards); plain no-ops here (tests that need to observe them swap
-- (billboards) and the render-pipeline fold; push snapshots the tracked
-- state, pop restores it (tests that need to observe the transforms swap
-- in their own recorders, e.g. tests/parity_tilt.lua)
push = noop, pop = noop, translate = noop, scale = noop,
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
push = function()
gstack[#gstack + 1] = { shader = gstate.shader, canvas = gstate.canvas,
blend = gstate.blend, color = gstate.color }
end,
pop = function()
local s = gstack[#gstack]
if s then
gstack[#gstack] = nil
gstate.shader, gstate.canvas = s.shader, s.canvas
gstate.blend, gstate.color = s.blend, s.color
end
end,
translate = noop, scale = noop,
rotate = noop, origin = noop, setScissor = noop,
getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
+250
View File
@@ -0,0 +1,250 @@
-- The battle-sprite-scale seam, exercised through the public mod API.
--
-- Modders scale battle pics per species (pokemon.battleScaleFront /
-- battleScaleBack) or per image path (the battle_sprite_scales registry,
-- the only handle on non-species pics like the trainer back). The
-- properties worth pinning: the schema rejects out-of-range scales and a
-- pathless record, image-level beats species-level beats the vanilla
-- default, and above all the pic stays GROUNDED -- feet on the text-box
-- top, bottom edge in its slot -- at every scale and through the send-out
-- grow. The placement math and the scale resolver are pure (no love.*),
-- so the grounding contract is asserted directly.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Loader = require("src.mods.Loader")
local Schemas = require("src.mods.Schemas")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("mod battle scale")
local check, eq = S.check, S.eq
local function memfs(files)
return {
read = function(path) return files[path] end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
getDirectoryItems = function(path)
local seen, items = {}, {}
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
end
table.sort(items)
return items
end,
}
end
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' ..
'"entry":"main.lua"%s}'):format(id, id, extra or "")
end
-- a minimal, internally consistent base so a pokemon patch has something
-- to fold onto; the cross-reference pass skips registries no mod touched,
-- so the untouched type/move refs on these records never surface
local function baseData()
return {
pokemon = {
PIKACHU = { id = "PIKACHU", name = "PIKACHU", dex = 25,
types = { "ELECTRIC" },
baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, special = 50 },
catchRate = 190, baseExp = 82, level1Moves = { "THUNDERSHOCK" },
growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {},
spriteFront = "pikachu_front.png", spriteBack = "pikachu_back.png",
frontSize = 5 },
RAICHU = { id = "RAICHU", name = "RAICHU", dex = 26,
types = { "ELECTRIC" },
baseStats = { hp = 60, attack = 90, defense = 55, speed = 110, special = 90 },
catchRate = 75, baseExp = 122, level1Moves = { "THUNDERSHOCK" },
growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {},
spriteFront = "raichu_front.png", spriteBack = "raichu_back.png",
frontSize = 6 },
},
moves = {
THUNDERSHOCK = { id = "THUNDERSHOCK", name = "THUNDERSHOCK",
type = "ELECTRIC", power = 40, accuracy = 100, pp = 30,
effect = "PARALYZE_SIDE_EFFECT1" },
},
}
end
-- ------- schema: the battle_sprite_scales registry
do
local spec = Schemas.REGISTRIES.battle_sprite_scales
check(spec ~= nil, "the battle_sprite_scales registry is in the catalog")
eq(spec.semantics, "record", "battle_sprite_scales merges as records")
eq(spec.target, "battle_sprite_scales",
"battle_sprite_scales writes to its own namespace")
local ok = Schemas.check(spec, "battle_sprite_scales", "abra_back",
{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 }, "register")
check(ok, "a path + in-range scale validates")
-- the boundaries are inclusive
check(Schemas.check(spec, "battle_sprite_scales", "lo",
{ path = "x.png", scale = 0.25 }, "register"), "scale 0.25 is accepted")
check(Schemas.check(spec, "battle_sprite_scales", "hi",
{ path = "x.png", scale = 4.0 }, "register"), "scale 4.0 is accepted")
local tooBig, bigErr = Schemas.check(spec, "battle_sprite_scales", "big",
{ path = "x.png", scale = 5 }, "register")
check(not tooBig, "a scale above 4.0 is rejected")
check(tostring(bigErr):find("0.25", 1, true) ~= nil,
"the rejection names the range it wanted: " .. tostring(bigErr))
check(not Schemas.check(spec, "battle_sprite_scales", "small",
{ path = "x.png", scale = 0.1 }, "register"),
"a scale below 0.25 is rejected")
local noPath, pathErr = Schemas.check(spec, "battle_sprite_scales", "nopath",
{ scale = 1 }, "register")
check(not noPath, "a record with no path is rejected")
check(tostring(pathErr):find("path", 1, true) ~= nil,
"the rejection names the missing path: " .. tostring(pathErr))
check(not Schemas.check(spec, "battle_sprite_scales", "emptypath",
{ path = "", scale = 1 }, "register"), "an empty path is rejected")
end
-- ------- schema: the per-species scale fields
do
local spec = Schemas.REGISTRIES.pokemon
check(Schemas.check(spec, "pokemon", "PIKACHU",
{ battleScaleBack = 3, battleScaleFront = 0.5 }, "patch"),
"in-range species scale overrides validate as a patch")
local bad, err = Schemas.check(spec, "pokemon", "PIKACHU",
{ battleScaleBack = 5 }, "patch")
check(not bad, "an out-of-range species scale is rejected")
check(tostring(err):find("battleScaleBack", 1, true) ~= nil,
"the rejection names the field: " .. tostring(err))
end
-- ------- the full merge: a mod patches a species and registers an image
local FILES = {
["mods/biggun/manifest.json"] = manifest("biggun"),
["mods/biggun/main.lua"] = [[
local mod = ...
-- species-level: PIKACHU's back pic at 3x
mod.content.pokemon:patch("PIKACHU", { battleScaleBack = 3 })
-- image-level, keyed by path: overrides the species scale for this pic
mod.content.battle_sprite_scales:register("pika_back", {
path = "pikachu_back.png", scale = 1.25,
})
-- and a bare, non-species pic (a trainer back) reachable only here
mod.content.battle_sprite_scales:register("hero_back", {
path = "assets/generated/battle/back/redb.png", scale = 1.5,
})
]],
}
local data = baseData()
local loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data)
check(okLoad, "the scale mod loads clean: " .. table.concat(loader.errors, "; "))
eq(data.pokemon.PIKACHU.battleScaleBack, 3,
"the species patch reached the merged data")
check(type(data.battle_sprite_scales) == "table",
"the merge created the battle_sprite_scales namespace")
-- image-level beats species-level for the same pic
eq(BattleState.resolveBattleScale(data, "back", "pikachu_back.png", "PIKACHU"),
1.25, "an image-level entry overrides the species scale for its path")
-- a different pic of the same species falls through to the species scale
eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "PIKACHU"),
3, "a species with an override but no image entry uses the species scale")
-- the non-species trainer back is reachable only by path
eq(BattleState.resolveBattleScale(data, "back",
"assets/generated/battle/back/redb.png", nil),
1.5, "a bare pic is scaled by its image-level entry with no species")
-- an unregistered species, unregistered path: the vanilla side defaults
eq(BattleState.resolveBattleScale(data, "front", "raichu_front.png", "RAICHU"),
1, "enemy front defaults to 1x when nothing is registered")
eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "RAICHU"),
2, "player back defaults to 2x when nothing is registered")
-- ------- default unchanged with no registry at all
do
local bare = { pokemon = { PIKACHU = {} } }
eq(BattleState.resolveBattleScale(bare, "front", "any.png", "PIKACHU"), 1,
"front default holds with no battle_sprite_scales table")
eq(BattleState.resolveBattleScale(bare, "back", "any.png", "PIKACHU"), 2,
"back default holds with no battle_sprite_scales table")
eq(BattleState.resolveBattleScale({}, "back", nil, nil), 2,
"back default holds with empty data and no path or species")
end
-- ------- placement math: feet stay pinned at every scale
local W, H, PAD, PADL = 56, 40, 3, 2
do
for _, s in ipairs({ 0.5, 1, 2, 3 }) do
local x, y, sc = BattleState.backPlacement(W, H, PAD, PADL, s)
eq(sc, s, "back placement returns the scale (scale " .. s .. ")")
eq(y + (H - PAD) * s, 96,
"player feet stay on the text-box top at scale " .. s)
eq(x, 8 - PADL * s,
"player left pad is pulled back proportionally at scale " .. s)
end
local ex, ey = 100, 20
for _, s in ipairs({ 0.5, 1, 2, 3 }) do
local x, y = BattleState.frontPlacement(ex, ey, W, H, s)
eq(y + H * s, ey + H,
"enemy bottom edge stays pinned to its slot at scale " .. s)
eq(x + W * s / 2, ex + W / 2,
"enemy horizontal centre stays pinned at scale " .. s)
end
-- the s=1 case is the vanilla draw exactly: no shift
local x1, y1 = BattleState.frontPlacement(ex, ey, W, H, 1)
check(x1 == ex and y1 == ey, "scale 1 front placement is the untouched slot")
end
-- ------- composition with the send-out grow
do
-- growInScale returns the AnimateSendingOutMon stages; a mod scale
-- composes multiplicatively, and the composed pic is still grounded
local moddedBack = BattleState.resolveBattleScale(
{ pokemon = { GROWMON = { battleScaleBack = 1.5 } } }, "back", nil, "GROWMON")
eq(moddedBack, 1.5, "species back override resolved for the grow test")
for _, gs in ipairs({ 3 / 7, 5 / 7, 1 }) do
local eff = moddedBack * gs
local _, y = BattleState.backPlacement(W, H, PAD, PADL, eff)
eq(y + (H - PAD) * eff, 96,
"player feet stay pinned through grow stage " .. gs)
local ex, ey = 100, 20
local _, ey2 = BattleState.frontPlacement(ex, ey, W, H, eff)
eq(ey2 + H * eff, ey + H,
"enemy bottom stays pinned through grow stage " .. gs)
end
end
S.finish()
+1 -1
View File
@@ -160,7 +160,7 @@ check(not pcall(Manifest.validate, {
local versionLoader = Loader.new({ fs = memfs({
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end",
["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=1.0 <2.0"' }),
["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=0.0.0-0 <2.0"' }),
["mods/current/main.lua"] = NOOP,
}) })
local versionData = { items = {} }
+352
View File
@@ -0,0 +1,352 @@
-- The rendering-pipeline seam, exercised through the public mod API.
--
-- A render pipeline is the one extension point that owns part of the
-- frame, so the properties worth pinning are the ones a mod cannot be
-- trusted to honor on its own: that a pipeline nobody switched on costs
-- nothing, that its callbacks are dispatched in priority order, and above
-- all that a mod which throws mid-frame degrades to the vanilla 2D path
-- instead of taking the frame down with it.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Loader = require("src.mods.Loader")
local Schemas = require("src.mods.Schemas")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local S = require("tests.harness").suite("mod render")
local check, eq = S.check, S.eq
local function memfs(files)
return {
read = function(path) return files[path] end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
getDirectoryItems = function(path)
local seen, items = {}, {}
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
end
table.sort(items)
return items
end,
}
end
local function manifest(id, extra)
local body = ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' ..
'"entry":"main.lua"%s}'):format(id, id, extra or "")
return body
end
-- ------- schema: a record must actually do something
do
local spec = Schemas.REGISTRIES.render_pipelines
check(spec ~= nil, "the render_pipelines registry is in the catalog")
eq(spec.semantics, "record", "render_pipelines merges as records")
eq(spec.target, "render_pipelines", "render_pipelines writes to its own namespace")
local ok = Schemas.check(spec, "render_pipelines", "good",
{ label = "GOOD", drawWorld = function() end }, "register")
check(ok, "a drawWorld-only record validates")
local okPresent = Schemas.check(spec, "render_pipelines", "grade",
{ label = "GRADE", present = function() end }, "register")
check(okPresent, "a present-only record validates")
-- the whole point of the record is to draw something
local bad, err = Schemas.check(spec, "render_pipelines", "inert",
{ label = "INERT" }, "register")
check(not bad, "a record with no draw callback is rejected")
check(tostring(err):find("drawWorld", 1, true) ~= nil,
"the rejection names the callbacks it wanted: " .. tostring(err))
local wrong = Schemas.check(spec, "render_pipelines", "typo",
{ label = "T", drawWorld = "not a function" }, "register")
check(not wrong, "a non-function draw callback is rejected")
end
-- ------- a mod registers two pipelines and the engine dispatches them
local trace = {}
local FILES = {
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
["mods/painter/main.lua"] = [[
local mod = ...
local T = _G.__RENDER_TEST
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA",
levels = { "OFF", "LOW", "HIGH" },
hotkey = "7",
priority = 20,
available = function() return T.available end,
update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end,
-- the folds composite only a real Canvas, so the mod hands back the
-- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut)
drawWorld = function(ctx)
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
return T.worldOut
end,
worldPresent = function(canvas)
T.trace[#T.trace + 1] = "worldPresent"
return T.blurOut
end,
})
mod.content.render_pipelines:register("grade", {
label = "GRADE",
priority = 5,
present = function(canvas)
T.trace[#T.trace + 1] = "present"
return T.gradeOut
end,
})
]],
}
_G.__RENDER_TEST = { trace = trace, available = true }
-- the world/present folds accept only a real Canvas, so give the mod concrete
-- ones to return and pin identity through the dispatch
_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2)
local data = {}
local loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data)
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
Pipelines.install(data)
check(type(data.render_pipelines) == "table",
"the merge created the render_pipelines namespace")
eq(data.render_pipelines._owners.diorama, "painter",
"the merge stamped the owning mod for runtime attribution")
-- priority order, highest first, is what selection and the folds walk
local list = Pipelines.list()
eq(#list, 2, "both pipelines are catalogued")
eq(list[1].id, "diorama", "the higher-priority pipeline sorts first")
eq(list[2].id, "grade", "the lower-priority pipeline sorts second")
check(list[1].id ~= "_owners" and list[2].id ~= "_owners",
"the provenance key is not mistaken for a pipeline")
-- ------- switched off costs nothing
eq(Pipelines.worldPipeline(), nil, "nothing owns the world while off")
eq(Pipelines.wantsPresent(), false, "no present pass is wanted while off")
eq(Pipelines.present("frame"), "frame", "present is identity while off")
eq(Pipelines.worldPresent("frame"), "frame", "worldPresent is identity while off")
eq(#trace, 0, "no callback ran for a switched-off pipeline")
-- update ticks every pipeline regardless, so a mode easing out still eases
Pipelines.update(0.016)
eq(trace[1], "update:0", "update ticks a switched-off pipeline")
-- ------- switched on, the callbacks dispatch
trace[1] = nil
Pipelines.setLevel("diorama", 2)
Pipelines.setLevel("grade", 1)
eq(Pipelines.worldPipeline(), "diorama",
"the eligible world pipeline claims the world pass")
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut,
"drawWorld returns the mod's canvas")
eq(trace[#trace], "world:ctx", "drawWorld received the frame context")
eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut,
"worldPresent folds its canvas over the world image")
eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas")
eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut,
"present folds its canvas over the finished composite")
-- ------- the hardware gate
_G.__RENDER_TEST.available = false
eq(Pipelines.worldPipeline(), nil,
"an unavailable pipeline never takes the world pass")
eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
"an unavailable pipeline's worldPresent is skipped")
_G.__RENDER_TEST.available = true
eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame")
-- ------- the gate governs input, never the draw
--
-- Regression: gating the DRAW on the free-roam state made the world drop
-- to the flat 2D path for the handful of frames a warp is transitioning,
-- so walking through a door flashed 2D before snapping back to 3D. A mode
-- that is on renders until it is off; the gate only stops the player
-- CHANGING it at a bad moment.
Pipelines.setLevel("diorama", 2)
-- a state that every free-roam gate refuses: mid-warp, and running a script
local warping = { transitioning = true }
local overworld = warping
eq(Pipelines.canToggle("diorama", warping, overworld), false,
"the gate refuses a mode change mid-warp")
eq(Pipelines.worldPipeline(), "diorama",
"but the mode keeps rendering through the warp -- no 2D flash")
local scripted = { runner = { isRunning = function() return true end } }
eq(Pipelines.canToggle("diorama", scripted, scripted), false,
"the gate refuses a mode change mid-cutscene")
eq(Pipelines.worldPipeline(), "diorama",
"and the mode keeps rendering through the cutscene")
-- a menu on top of the overworld is not the overworld, so the gate refuses
-- there too -- and the world beneath it must still be the 3D one
eq(Pipelines.canToggle("diorama", { menu = true }, overworld), false,
"the gate refuses a mode change from a menu")
eq(Pipelines.worldPipeline(), "diorama",
"the world under an open menu keeps rendering in the pipeline")
eq(Pipelines.hotkey("7", warping, overworld), nil,
"a hotkey press mid-warp is refused")
eq(Pipelines.level("diorama"), 2, "and the refused press changed no level")
-- ------- mutual exclusion
Tilt.setLevel(3)
Pipelines.setLevel("diorama", 1)
eq(Tilt.level, 0, "a world pipeline switches the engine's TILT off")
Tilt.setLevel(0)
-- ------- a throwing mod loses its pipeline, not the frame
local BOOM = {
["mods/boom/manifest.json"] = manifest("boom"),
["mods/boom/main.lua"] = [[
local mod = ...
mod.content.render_pipelines:register("boom", {
label = "BOOM",
drawWorld = function() error("pipeline exploded", 0) end,
})
]],
}
local boomData = {}
local boomLoader = Loader.new({ fs = memfs(BOOM) })
boomLoader:load(boomData)
Pipelines.install(boomData)
Pipelines.setLevel("boom", 1)
eq(Pipelines.worldPipeline(), "boom", "the pipeline is eligible before it throws")
eq(Pipelines.drawWorld("boom", {}), nil,
"a throwing drawWorld yields nil, so the caller falls back to 2D")
eq(Pipelines.worldPipeline(), nil,
"a pipeline that threw is retired rather than retried every frame")
-- the failure has to reach the feed the mod manager shows, named after the
-- mod that owns it -- a console line alone leaves the player with a world
-- that silently stopped being 3D and nothing to disable
local blamed = nil
for _, message in ipairs(boomLoader.errors) do
if message:find("boom:", 1, true) and message:find("pipeline exploded", 1, true) then
blamed = message
end
end
check(blamed ~= nil,
"the runtime failure is attributed to its mod in the manager's error feed")
-- ------- a non-canvas return is ignored, and a dirty callback cannot leak
--
-- The fold composites only a real Canvas, so a present that forgets its
-- return -- or hands back a truthy shade string, flag or number -- must leave
-- the composite untouched rather than blank or crash the frame. Unlike a
-- throw, a clean-but-useless return is NOT a crash, so the pipeline stays
-- eligible instead of being retired. Separately, a callback that returns
-- cleanly while leaving a shader bound or the canvas redirected must not leak
-- that state into the engine composite that follows: the fold fences each
-- dispatch in push("all")/pop.
local SLOPPY = {
["mods/sloppy/manifest.json"] = manifest("sloppy"),
["mods/sloppy/main.lua"] = [[
local mod = ...
local T = _G.__SLOPPY
mod.content.render_pipelines:register("sloppy", {
label = "SLOPPY",
present = function(canvas)
T.ran = (T.ran or 0) + 1
return T.ret
end,
})
mod.content.render_pipelines:register("dirty", {
label = "DIRTY",
present = function(canvas)
love.graphics.setShader("mod-shader")
love.graphics.setCanvas("mod-canvas")
love.graphics.setColor(0.1, 0.2, 0.3, 0.4)
love.graphics.setBlendMode("add")
return canvas
end,
})
]],
}
_G.__SLOPPY = { ran = 0 }
local sloppyData = {}
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
sloppyLoader:load(sloppyData)
Pipelines.install(sloppyData)
local composite = love.graphics.newCanvas(4, 4)
Pipelines.setLevel("sloppy", 1)
for _, bad in ipairs({ "just-a-string", true, 42 }) do
_G.__SLOPPY.ret = bad
eq(Pipelines.present(composite), composite,
"a present returning a " .. type(bad) .. " leaves the composite untouched")
end
check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame")
check(Pipelines.eligible("sloppy") == true,
"a non-canvas return does not retire the pipeline as broken")
Pipelines.setLevel("sloppy", 0)
love.graphics.setShader("engine-shader")
love.graphics.setCanvas("engine-canvas")
love.graphics.setBlendMode("alpha")
love.graphics.setColor(1, 1, 1, 1)
Pipelines.setLevel("dirty", 1)
eq(Pipelines.present(composite), composite,
"a dirty present that returns its input leaves the composite unchanged")
eq(love.graphics.getShader(), "engine-shader",
"a present that bound a shader cannot leak it past the fold")
eq(love.graphics.getCanvas(), "engine-canvas",
"a present that redirected the canvas cannot leak it past the fold")
eq(love.graphics.getBlendMode(), "alpha",
"a present that changed blend mode cannot leak it past the fold")
Pipelines.setLevel("dirty", 0)
_G.__SLOPPY = nil
Pipelines.reset()
Pipelines.install(nil)
_G.__RENDER_TEST = nil
-- ------- and with no mods at all, the whole subsystem is inert
eq(#Pipelines.list(), 0, "a mod-free boot registers no pipelines")
eq(Pipelines.worldPipeline(), nil, "a mod-free boot draws the vanilla world")
eq(Pipelines.wantsPresent(), false, "a mod-free boot allocates no present canvas")
eq(Pipelines.present("frame"), "frame", "a mod-free present is the identity")
eq(#Pipelines.rows({}), 0, "a mod-free options menu gains no rows")
eq(Pipelines.hotkey("6", nil, nil), nil, "a mod-free build claims no hotkeys")
S.finish()
+3 -2
View File
@@ -4,6 +4,7 @@ local Registry = require("src.mods.Registry")
local Events = require("src.mods.Events")
local Hooks = require("src.mods.Hooks")
local Manifest = require("src.mods.Manifest")
local Semver = require("src.mods.Semver")
local Logger = require("src.core.Logger")
local Version = require("src.core.Version")
local Runtime = require("src.mods.Runtime")
@@ -141,8 +142,8 @@ check(manifest.id == "test_mod" and manifest.path == "mods/test_mod",
"manifest validation")
check(type(Version.engine) == "string"
and Version.engine:match("^%d+%.%d+%.%d+$") ~= nil,
"engine version is a semver triple")
and Semver.parse(Version.engine) ~= nil,
"engine version parses as a semver (triple, optionally with a pre-release)")
check(Version.modApi == 2, "mod api version is 2")
check(Version.title("X") == "X v" .. Version.engine,
"window title carries the engine version")
+1
View File
@@ -2940,6 +2940,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", {
"tests/mod_constants_tests.lua", "tests/mod_catalog_tests.lua",
"tests/mod_audio_tests.lua", "tests/mod_world_tests.lua",
"tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua",
"tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua",
"tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua",
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
}, {
+476
View File
@@ -0,0 +1,476 @@
-- Gen1 save transformer tests (src/save_convert/GenSave.lua): a
-- round-trip of a fresh SaveData.newGame() save (no real save data
-- checked in as a fixture), plus targeted checks for the checksum
-- routine, the species/move/item/map crosswalks, and name encoding.
--
-- Run: luajit tests/save_convert_tests.lua
package.path = "./?.lua;" .. package.path
_G.love = require("tests.love_stub")
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local checks, failures = 0, 0
local function check(cond, msg)
checks = checks + 1
if not cond then
failures = failures + 1
print("FAIL: " .. msg)
end
end
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
-- Independent re-implementation of CalcCheckSum (complement of the additive
-- byte sum) so the export scenarios below can verify all three SRAM checksums
-- straight off the emitted bytes, without trusting GenSave's own writer.
local bit = require("bit")
local OFF = GenSave.OFFSETS
local function rawChecksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
local function checksumValid(bytes, from, to, storeOff)
return rawChecksum(bytes, from, to) == bytes:byte(storeOff + 1)
end
-- A box bank (2 or 3) holds 6 box regions, one one-byte checksum each, plus a
-- bank aggregate computed over the ENTIRE six-box region (pokered
-- engine/menus/save.asm: CalcCheckSum over all 6 x 1122 bytes), independently
-- re-derived here so this test cannot inherit an encoder bug.
local function boxBankChecksumValid(bytes, bankBase, aggOff, indivOff)
for b = 0, 5 do
local base = bankBase + b * GenSave.BOX_REGION_SIZE
local c = rawChecksum(bytes, base, base + GenSave.BOX_REGION_SIZE)
if c ~= bytes:byte(indivOff + b + 1) then return false end
end
local agg = rawChecksum(bytes, bankBase, bankBase + 6 * GenSave.BOX_REGION_SIZE)
return agg == bytes:byte(aggOff + 1)
end
-- ------------------------------------------------------------------
-- crosswalks: one species/move/item/map roundtrip each
-- ------------------------------------------------------------------
local cw = GenSave.crosswalks(data)
check(cw.pokemonIndex.MEW == 21, "MEW's internal ROM index is 21 (the classic MissingNo fact)")
check(cw.pokemonByIndex[21] == "MEW", "index 21 resolves back to MEW")
check(cw.pokemonDex.MEW == 151, "MEW's national dex number is 151 (BaseStats[151])")
check(cw.pokemonByDex[151] == "MEW", "dex 151 resolves back to MEW")
check(cw.pokemonDex.BULBASAUR == 1, "BULBASAUR is dex #1")
check(cw.movesByIndex[cw.movesIndex.THUNDERBOLT] == "THUNDERBOLT",
"a move id round-trips through its index")
check(cw.itemsByIndex[cw.itemsIndex.POKE_BALL] == "POKE_BALL",
"an item id round-trips through its index")
check(cw.itemsByIndex[cw.itemsIndex.TM_THUNDER_WAVE] == "TM_THUNDER_WAVE",
"a TM item (no `index` field, only machine.number) round-trips via its derived item id")
check(cw.mapsIndex.PALLET_TOWN == 0, "PALLET_TOWN is map index 0")
check(cw.mapsByIndex[0] == "PALLET_TOWN", "map index 0 resolves back to PALLET_TOWN")
-- ------------------------------------------------------------------
-- a fresh new-game save round-trips through encode -> decode
-- ------------------------------------------------------------------
local fresh = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
-- newGame's party/boxes are empty and its map is an interior with no
-- gen1-save equivalent tileset concerns -- exactly the baseline this
-- codec needs to handle cleanly with no real playthrough data at all.
local bytes = GenSave.encode(fresh, data, nil)
check(#bytes == GenSave.SAVE_SIZE, "encode() produces exactly 32768 bytes")
local decoded = GenSave.decode(bytes, data)
check(#decoded.warnings == 0, "a freshly-encoded save passes its own checksum")
check(decoded.player.name == "RED", "player name round-trips")
check(decoded.player.rival == "BLUE", "rival name round-trips")
check(decoded.player.map == fresh.player.map, "spawn map round-trips (" ..
tostring(decoded.player.map) .. " vs " .. tostring(fresh.player.map) .. ")")
check(decoded.player.x == fresh.player.x and decoded.player.y == fresh.player.y,
"spawn position round-trips")
check(decoded.money == fresh.money, "money round-trips")
check(#decoded.party == 0, "an empty party stays empty")
-- ------------------------------------------------------------------
-- a populated save: party, boxes, badges, bag, pokedex, flags
-- ------------------------------------------------------------------
local save = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" })
save.player.id = 12345
save.money = 3000
save.coins = 50
save.inventory = { POKE_BALL = 5, BOULDERBADGE = 1, ANTIDOTE = 1 }
save.bagOrder = { "POKE_BALL", "ANTIDOTE" }
save.pcItems = { REVIVE = 2 }
save.pokedex = { seen = { MEW = true, PIKACHU = true }, owned = { PIKACHU = true } }
save.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_POKEDEX = true }
save.boxes = {}
save.party = {
{
species = "MEW", level = 100, exp = 1059860,
dvs = { hp = 13, attack = 15, defense = 11, speed = 12, special = 15 },
statExp = { hp = 65535, attack = 65535, defense = 65535, speed = 65535, special = 65535 },
stats = { hp = 399, attack = 298, defense = 290, speed = 292, special = 298 },
hp = 399, status = nil,
moves = { { id = "TRANSFORM", pp = 16, ppUps = 3 }, { id = "MEGA_PUNCH", pp = 16, ppUps = 3 } },
nickname = "MEW", ot = "Lt<DOT>Ash", otId = 55721, catchRate = 45,
},
}
for i = 1, 12 do save.boxes[i] = {} end
save.boxes[3] = { {
species = "PIKACHU", level = 10, exp = 1000,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
hp = 30, status = "PSN",
moves = { { id = "THUNDERSHOCK", pp = 30, ppUps = 0 } },
nickname = "PIKA", ot = "ASH", otId = 12345, catchRate = 190,
} }
save.currentBox = 1
local bytes2 = GenSave.encode(save, data, nil)
local decoded2 = GenSave.decode(bytes2, data)
check(#decoded2.warnings == 0, "a populated save passes its own checksum")
check(decoded2.player.id == 12345, "player ID round-trips")
check(decoded2.money == 3000 and decoded2.coins == 50, "money and coins round-trip")
check(decoded2.inventory.BOULDERBADGE == 1, "a badge round-trips as a truthy inventory entry")
check(decoded2.inventory.POKE_BALL == 5 and decoded2.inventory.ANTIDOTE == 1,
"bag items round-trip")
check(decoded2.inventory.POKE_BALL and not decoded2.pcItems.POKE_BALL,
"bag items don't leak into PC storage")
check(decoded2.pcItems.REVIVE == 2, "PC items round-trip")
check(decoded2.pokedex.seen.MEW and decoded2.pokedex.seen.PIKACHU and decoded2.pokedex.owned.PIKACHU,
"pokedex seen/owned round-trip")
check(not decoded2.pokedex.owned.MEW, "a species only marked seen doesn't also come back owned")
check(decoded2.flags.EVENT_GOT_STARTER and decoded2.flags.EVENT_GOT_POKEDEX,
"event flags round-trip")
local mon1 = decoded2.party[1]
check(mon1 and mon1.species == "MEW", "party mon species round-trips")
check(mon1 and mon1.level == 100 and mon1.exp == 1059860, "party mon level/exp round-trip")
check(mon1 and mon1.hp == 399 and mon1.stats and mon1.stats.hp == 399,
"party mon current HP and max HP stat round-trip")
check(mon1 and mon1.dvs.attack == 15 and mon1.dvs.speed == 12, "party mon DVs round-trip")
check(mon1 and mon1.moves[1].id == "TRANSFORM" and mon1.moves[1].pp == 16
and mon1.moves[1].ppUps == 3, "party mon move/PP/PP-Up round-trip")
check(mon1 and mon1.nickname == "MEW" and mon1.otId == 55721, "party mon nickname/OT ID round-trip")
check(mon1 and mon1.ot == "Lt<DOT>Ash",
"an OT name containing a bracketed charmap token (\"<DOT>\") round-trips as one unit, "..
"not per-byte \"?\" (got " .. tostring(mon1 and mon1.ot) .. ")")
local box3mon = decoded2.boxes[3][1]
check(box3mon and box3mon.species == "PIKACHU" and box3mon.status == "PSN",
"a boxed mon's species and status condition round-trip")
check(box3mon and box3mon.moves[1].id == "THUNDERSHOCK", "a boxed mon's move round-trips")
check(decoded2.currentBox == 1, "current box selection round-trips")
-- ------------------------------------------------------------------
-- checksum: a corrupted byte is detected on decode (warned, not thrown --
-- decode() must still succeed on a foreign save with a bad checksum)
-- ------------------------------------------------------------------
local O = GenSave.OFFSETS
local corrupted = bytes2:sub(1, O.money) ..
string.char((bytes2:byte(O.money + 1) + 1) % 256) ..
bytes2:sub(O.money + 2)
local decodedCorrupt = GenSave.decode(corrupted, data)
check(#decodedCorrupt.warnings == 1, "a corrupted byte trips the checksum warning")
-- ------------------------------------------------------------------
-- Scenario 2 (always-on): engine-origin export. A save that never came
-- from a real cartridge -- SaveData.newGame() plus a small party, bag,
-- PC and badge set built through the documented save shape -- must
-- encode with NO template into a structurally valid 32768-byte SRAM
-- image: exact size, all three SRAM checksums valid, and a clean
-- re-import that reproduces party / items / badges / name. (The vendor
-- gen1lib parse of this same image is exercised out-of-band under Lua
-- 5.4, since gen1lib cannot even be loaded by luajit.)
-- ------------------------------------------------------------------
local eng = SaveData.newGame({ playerName = "OAK", rivalName = "BLUE" })
eng.money = 1234
eng.inventory = { POTION = 3, POKE_BALL = 10, THUNDERBADGE = 1 }
eng.bagOrder = { "POTION", "POKE_BALL" }
eng.pcItems = { REVIVE = 1, FULL_RESTORE = 2 }
eng.pcOrder = { "REVIVE", "FULL_RESTORE" }
eng.party = {
{
species = "CHARMANDER", level = 5, exp = 135,
dvs = { hp = 8, attack = 9, defense = 10, speed = 11, special = 12 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 20, attack = 11, defense = 10, speed = 12, special = 11 },
hp = 20, status = nil,
moves = { { id = "SCRATCH", pp = 35, ppUps = 0 }, { id = "GROWL", pp = 40, ppUps = 0 } },
nickname = "CHAR", ot = "OAK", otId = eng.player.id, catchRate = 45,
},
{
species = "PIDGEY", level = 4, exp = 64,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 18, attack = 9, defense = 9, speed = 10, special = 8 },
hp = 18, status = nil,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "PIDGE", ot = "OAK", otId = eng.player.id, catchRate = 255,
},
}
local engBytes = GenSave.encode(eng, data, nil) -- NO template: pure engine origin
check(#engBytes == GenSave.SAVE_SIZE, "engine-origin: encode is exactly 32768 bytes")
check(checksumValid(engBytes, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"engine-origin: main data checksum valid")
check(boxBankChecksumValid(engBytes, OFF.box1, OFF.boxBank2Checksum, OFF.boxBank2IndividualChecksums),
"engine-origin: bank 2 box checksums valid")
check(boxBankChecksumValid(engBytes, OFF.box7, OFF.boxBank3Checksum, OFF.boxBank3IndividualChecksums),
"engine-origin: bank 3 box checksums valid")
local engDec = GenSave.decode(engBytes, data)
check(#engDec.warnings == 0, "engine-origin: re-import passes its own checksum")
check(engDec.player.name == "OAK", "engine-origin: player name reproduces")
check(#engDec.party == 2, "engine-origin: party size reproduces (got " .. #engDec.party .. ")")
check(engDec.party[1] and engDec.party[1].species == "CHARMANDER" and engDec.party[1].level == 5,
"engine-origin: party[1] species/level reproduce")
check(engDec.party[2] and engDec.party[2].species == "PIDGEY"
and engDec.party[2].moves[1] and engDec.party[2].moves[1].id == "TACKLE",
"engine-origin: party[2] species/move reproduce")
check(engDec.inventory.POTION == 3 and engDec.inventory.POKE_BALL == 10,
"engine-origin: bag items reproduce")
check(engDec.pcItems.REVIVE == 1 and engDec.pcItems.FULL_RESTORE == 2,
"engine-origin: PC items reproduce")
check(engDec.inventory.THUNDERBADGE == 1, "engine-origin: badge reproduces as an inventory entry")
check(not engDec.pcItems.THUNDERBADGE, "engine-origin: badge doesn't leak into PC items")
-- expose the image for the out-of-band gen1lib parse (scenario 2 oracle)
do local w = io.open("/tmp/engine_origin.sav", "wb"); if w then w:write(engBytes); w:close() end end
-- ------------------------------------------------------------------
-- SaveConvert: the runtime-facing module (moved paths + shared merge).
-- SaveConvert loads its OWN crosswalk data through `require` (the same
-- src/core/Data.lua pattern), independent of the `data` table above, so
-- these checks also prove the moved src/save_convert/{GenSave,data/*} paths
-- resolve. bytes2 (a valid populated image built earlier) is the input.
-- ------------------------------------------------------------------
local scSave, scErr = SaveConvert.importSav(bytes2, 2)
check(scSave ~= nil, "SaveConvert.importSav returns a save table (" .. tostring(scErr) .. ")")
check(scSave and scSave.player and scSave.player.id == 12345,
"SaveConvert.importSav: decoded player fields survive the merge")
check(scSave and scSave.inventory and scSave.inventory.POKE_BALL == 5,
"SaveConvert.importSav: decoded bag items survive the merge")
-- merge over new-game defaults
check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "gen1_faithful",
"SaveConvert.importSav: new-game default options merged in")
check(scSave and type(scSave.defeatedTrainers) == "table" and type(scSave.modData) == "table"
and scSave.repelSteps == 0,
"SaveConvert.importSav: default defeatedTrainers/modData/repelSteps merged in")
-- version tag
check(scSave and scSave.meta and scSave.meta.version == 2,
"SaveConvert.importSav: save is tagged with the requested version")
-- derived heal/outdoor anchors
check(scSave and scSave.lastHeal and scSave.lastHeal.map == scSave.player.map,
"SaveConvert.importSav: lastHeal derives from the decoded position")
check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil,
"SaveConvert.importSav: lastOutdoor is set")
-- the import template + decode warnings never leak into the slot table
check(scSave and scSave.rawImport == nil and scSave.warnings == nil,
"SaveConvert.importSav: rawImport/warnings stripped from the returned table")
-- size / type validation
local badSize, badSizeErr = SaveConvert.importSav("too short", 2)
check(badSize == nil and type(badSizeErr) == "string",
"SaveConvert.importSav: rejects a wrong-size input with an error")
local nilIn, nilInErr = SaveConvert.importSav(nil, 2)
check(nilIn == nil and type(nilInErr) == "string",
"SaveConvert.importSav: rejects a non-string input with an error")
-- checksum validation: flip a modeled byte so the stored checksum no longer
-- matches -> importSav must reject (GenSave.decode alone only warns).
local scCorrupt = bytes2:sub(1, OFF.money) ..
string.char((bytes2:byte(OFF.money + 1) + 1) % 256) ..
bytes2:sub(OFF.money + 2)
local scBad, scBadErr = SaveConvert.importSav(scCorrupt, 2)
check(scBad == nil and type(scBadErr) == "string" and tostring(scBadErr):find("checksum"),
"SaveConvert.importSav: rejects a bad-checksum save with a checksum error")
-- exportSav zero-fill path: a merged import table carries no template, so the
-- export must still be a structurally valid 32768-byte image.
local scOut, scOutErr = SaveConvert.exportSav(scSave)
check(scOut ~= nil and #scOut == GenSave.SAVE_SIZE,
"SaveConvert.exportSav: produces exactly 32768 bytes (" .. tostring(scOutErr) .. ")")
check(scOut and checksumValid(scOut, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"SaveConvert.exportSav: main data checksum valid on a templateless export")
local scRt = SaveConvert.importSav(scOut, 2)
check(scRt and scRt.party[1] and scRt.party[1].species == "MEW",
"SaveConvert import -> export -> import round-trips the party")
check(scRt and scRt.inventory.BOULDERBADGE == 1,
"SaveConvert round-trip preserves a badge")
-- exportSav bad input
local scNilOut, scNilOutErr = SaveConvert.exportSav("not a table")
check(scNilOut == nil and type(scNilOutErr) == "string",
"SaveConvert.exportSav: rejects a non-table input with an error")
-- exportSav template-aware path: a table still carrying the stashed import
-- template reproduces the source's UNMODELED regions byte-for-byte. Poke a
-- sentinel into the sprite-buffer region (inside the checksum window but not
-- written by encode), decode straight through GenSave (which keeps rawImport),
-- and confirm it survives on export while a templateless export zero-fills it.
local spriteOff = OFF.spriteData + 10
local sentinel = 0xAB
local templateSrc = bytes2:sub(1, spriteOff) .. string.char(sentinel) .. bytes2:sub(spriteOff + 2)
local tmplSave = GenSave.decode(templateSrc, data) -- rawImport = templateSrc
local tmplOut, tmplErr = SaveConvert.exportSav(tmplSave)
check(tmplOut ~= nil and #tmplOut == GenSave.SAVE_SIZE,
"SaveConvert.exportSav: template-aware export is 32768 bytes (" .. tostring(tmplErr) .. ")")
check(tmplOut and tmplOut:byte(spriteOff + 1) == sentinel,
"SaveConvert.exportSav: template-aware export carries an unmodeled region byte through")
check(tmplOut and checksumValid(tmplOut, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"SaveConvert.exportSav: template-aware export still writes a valid checksum")
check(scOut and scOut:byte(spriteOff + 1) == 0,
"SaveConvert.exportSav: templateless export zero-fills the same unmodeled region")
-- SaveConvert.loadData exposes the shared crosswalk set (require-loaded).
local scData = SaveConvert.loadData()
check(type(scData) == "table" and type(scData.pokemon) == "table"
and type(scData.eventFlags) == "table",
"SaveConvert.loadData: returns the crosswalk data set via require")
-- ------------------------------------------------------------------
-- Real-save import audit (fixture-gated). POKEPORT_SAV_FIXTURE must point
-- at a readable 32768-byte battery save (a personal .sav never checked in);
-- when it's unset or unusable the whole block skips with one notice, so the
-- suite stays green on any machine. When present, the full import is run and
-- audited for plausibility -- the same checks convert.lua's output has to
-- satisfy for SaveData.load to accept it without quarantine.
-- ------------------------------------------------------------------
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
local fixtureBytes
if fixturePath then
local ff = io.open(fixturePath, "rb")
if ff then
fixtureBytes = ff:read("*a")
ff:close()
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
end
end
if not fixtureBytes then
print("fixture checks skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav to run them)")
else
local rs = GenSave.decode(fixtureBytes, data)
check(type(rs.player.name) == "string" and #rs.player.name > 0,
"fixture: player name decodes non-empty")
local badges = 0
for bit0 = 0, 7 do
local names = { "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE",
"SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE" }
if rs.inventory[names[bit0 + 1]] then badges = badges + 1 end
end
check(badges >= 0 and badges <= 8, "fixture: badge count in 0..8 (got " .. badges .. ")")
check(type(rs.money) == "number" and rs.money >= 0 and rs.money <= 999999,
"fixture: money is a sane BCD value (got " .. tostring(rs.money) .. ")")
check(type(rs.coins) == "number" and rs.coins >= 0 and rs.coins <= 9999,
"fixture: coins is a sane BCD value (got " .. tostring(rs.coins) .. ")")
check(#rs.party >= 1 and #rs.party <= 6,
"fixture: party holds 1..6 mons (got " .. #rs.party .. ")")
for i, mon in ipairs(rs.party) do
check(data.pokemon[mon.species] ~= nil,
"fixture: party mon " .. i .. " has a known species (" .. tostring(mon.species) .. ")")
check(mon.level >= 2 and mon.level <= 100,
"fixture: party mon " .. i .. " level in 2..100 (got " .. tostring(mon.level) .. ")")
check(#mon.moves >= 1 and #mon.moves <= 4,
"fixture: party mon " .. i .. " has 1..4 moves (got " .. #mon.moves .. ")")
for _, mv in ipairs(mon.moves) do
check(data.moves[mv.id] ~= nil,
"fixture: party mon " .. i .. " move is known (" .. tostring(mv.id) .. ")")
check(mv.pp >= 0 and mv.pp <= 63,
"fixture: party mon " .. i .. " move PP in 0..63 (got " .. tostring(mv.pp) .. ")")
end
check(type(mon.exp) == "number" and mon.exp > 0,
"fixture: party mon " .. i .. " has nonzero EXP")
end
local owned, seen = 0, 0
for _ in pairs(rs.pokedex.owned) do owned = owned + 1 end
for _ in pairs(rs.pokedex.seen) do seen = seen + 1 end
check(owned <= seen and seen <= 151,
"fixture: pokedex owned <= seen <= 151 (owned " .. owned .. ", seen " .. seen .. ")")
check(rs.player.map ~= nil and data.maps[rs.player.map] ~= nil,
"fixture: current map resolves to a real map id (" .. tostring(rs.player.map) .. ")")
check(type(rs.player.x) == "number" and type(rs.player.y) == "number"
and rs.player.x >= 0 and rs.player.y >= 0,
"fixture: player position is in-bounds non-negative")
local boxed = 0
for b = 1, 12 do boxed = boxed + #rs.boxes[b] end
check(boxed >= 0 and boxed <= 12 * 20,
"fixture: boxed mon count within 12 boxes x 20 (got " .. boxed .. ")")
local nflags = 0
for _ in pairs(rs.flags) do nflags = nflags + 1 end
check(nflags > 0, "fixture: at least one event flag populated (got " .. nflags .. ")")
-- play time (mapped from wPlayTimeHours/Minutes/Seconds/Frames into
-- save.playTime seconds): a real playthrough has a positive clock, and
-- it must round-trip through encode() back to the same H:M:S:F.
check(type(rs.playTime) == "number" and rs.playTime > 0,
"fixture: play time decodes to a positive second count (got "
.. tostring(rs.playTime) .. ")")
local rtBytes = GenSave.encode(rs, data, fixtureBytes)
local rt = GenSave.decode(rtBytes, data)
check(math.abs(rt.playTime - rs.playTime) < 1e-6,
"fixture: play time round-trips through encode()")
check(#rt.warnings == 0, "fixture: re-encoded save passes its own checksum")
-- Scenario 1 (fixture-gated): full export fidelity of the real save.
check(#rtBytes == GenSave.SAVE_SIZE, "fixture export: exactly 32768 bytes")
check(checksumValid(rtBytes, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"fixture export: main data checksum valid")
check(boxBankChecksumValid(rtBytes, OFF.box1, OFF.boxBank2Checksum, OFF.boxBank2IndividualChecksums),
"fixture export: bank 2 box checksums valid")
check(boxBankChecksumValid(rtBytes, OFF.box7, OFF.boxBank3Checksum, OFF.boxBank3IndividualChecksums),
"fixture export: bank 3 box checksums valid")
-- Byte-for-byte fidelity with the original as template: every byte GenSave
-- emits must reproduce the source EXCEPT the derived integrity bytes (the
-- main checksum and the two 7-byte box-bank checksum footers). Zero content
-- diffs proves both that every modeled region re-encodes identically AND
-- that the template carries each unmodeled region (sprite buffers, Hall of
-- Fame, Day Care, options, connection cache, ...) through untouched. A
-- tampered source can carry stale box checksums; the export rewrites them to
-- valid values, which is why the checksum bytes are the only exemptions.
local exempt = {}
exempt[OFF.mainChecksum] = true
for b = 0, 6 do exempt[OFF.boxBank2Checksum + b] = true end
for b = 0, 6 do exempt[OFF.boxBank3Checksum + b] = true end
local contentDiffs = 0
for i = 0, GenSave.SAVE_SIZE - 1 do
if not exempt[i] and rtBytes:byte(i + 1) ~= fixtureBytes:byte(i + 1) then
contentDiffs = contentDiffs + 1
end
end
check(contentDiffs == 0,
"fixture export: modeled + template-preserved bytes reproduce the source "
.. "byte-for-byte (got " .. contentDiffs .. " unexpected diffs)")
-- Expose the export so crosscheck.lua can be reused as the vendor oracle:
-- POKEPORT_SAV_FIXTURE=/tmp/roundtrip.sav lua tools/save_convert/crosscheck.lua
-- confirms gen1lib parse_save accepts these exported bytes and agrees.
do local w = io.open("/tmp/roundtrip.sav", "wb"); if w then w:write(rtBytes); w:close() end end
print(("fixture audit OK: name=%s badges=%d money=%d party=%d boxed=%d dex=%d/%d play=%dh%02dm"):format(
rs.player.name, badges, rs.money, #rs.party, boxed, owned, seen,
math.floor(rs.playTime / 3600), math.floor(rs.playTime / 60) % 60))
end
print(string.format("save convert: %d/%d checks passed", checks - failures, checks))
if failures > 0 then os.exit(1) end
+93
View File
@@ -0,0 +1,93 @@
"""Extract the Gen1 text byte <-> glyph/token charmap.
Source: pokered/constants/charmap.asm -- `charmap "TOKEN", $NN` lines (one
byte value per token; TOKEN is a printable glyph for ordinary characters,
or a bracketed control token like "<PLAYER>"/"@" for terminators and
runtime substitutions). This is the encoding fixed-length name fields
(sPlayerName, sRivalName, party/box OT names, nicknames) use -- NOT the
same thing as data/generated/text.lua, which holds already-decoded
dialogue strings extracted from asm source text and never needed a raw
byte<->glyph table of its own.
Several byte VALUES are deliberately reused across different on-screen
graphics contexts later in the file (font_extra.png bold letters, then
font_battle_extra.png, then misc one-off glyphs, THEN the real A-Z table
at $80-$99, which unused Japanese katakana entries further down redefine
again at the same range) -- legal for RGBDS charmap (it only needs
token->byte to be unambiguous for encoding source text; nothing in this
English-only source ever assembles the literal token "", so its
redefinition is inert for the actual ROM). For our purposes it means:
- byToken[token] = byte is unambiguous either way (last-definition-wins,
matching RGBDS's own semantics), used to ENCODE a name.
- byByte[byte] = token needs the FIRST definition of each byte, not the
last, to DECODE a byte back to the international glyph that's actually
in the shipped font at that position instead of a later vestigial
redefinition (confirmed against the file: Latin "A".."Z" at $80-$99 are
defined once, early, cleanly, before later `charmap "", $80` etc.
entries that reuse those same bytes for characters this ROM's font
never draws there).
Output: src/save_convert/data/charmap.lua (committed; independent of ROM
import, like data/palettes_gbc.lua)
byByte[byte] = token (first definition per byte -- see above)
byToken[token] = byte (last definition per token -- see above)
"""
import argparse
import os
import re
import sys
from . import util
def extract(pokered, out_path):
path = os.path.join(pokered, "constants/charmap.asm")
by_byte = {}
by_token = {}
for lineno, line in util.read_asm(path):
s = line.strip()
m = re.match(r'charmap\s+"((?:[^"\\]|\\.)*)"\s*,\s*(\S+)', s)
if not m:
continue
token = m.group(1)
value = util.parse_number(m.group(2))
if not (0 <= value <= 255):
util.die(f"{path}:{lineno}: byte value {value} out of range for {token!r}")
if value not in by_byte: # first definition wins for decoding
by_byte[value] = token
by_token[token] = value # last definition wins for encoding
if not by_byte:
util.die(f"{path}: parsed 0 charmap entries")
util.write_lua(
out_path,
{"source": "pokered constants/charmap.asm",
"byByte": by_byte,
"byToken": by_token},
header="Gen1 text byte <-> glyph/token charmap (fixed-length name\n"
"fields: player/rival/OT names, nicknames -- box/party mon\n"
"names are NUL-free, '@' ($50) terminated, space-padded).\n"
"byToken's key is the literal glyph for ordinary characters\n"
"(\"A\", \"\", ...) or a bracketed control token\n"
"(\"<PLAYER>\", \"@\") for terminators/substitutions -- only\n"
"the plain single-glyph entries are meaningful inside a name.")
return by_byte, by_token
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--pokered", required=True)
parser.add_argument("--out", default="src/save_convert/data/charmap.lua")
args = parser.parse_args(argv)
extract(args.pokered, args.out)
return 0
if __name__ == "__main__":
if __package__ is None:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
__package__ = "extract"
from extract import util as util # noqa: F811
raise SystemExit(main())
+117
View File
@@ -0,0 +1,117 @@
"""Extract wEventFlags' bit-index -> EVENT_* name table.
Source: pokered/constants/event_constants.asm -- an RGBDS const block using
`const_def` / `const NAME` / `const_skip N` / `const_next N` (jump the
counter to an absolute bit position; N is usually `$hex` but occasionally
a small arithmetic expression like `$F0 - 2`). This is NOT the same shape
tools/extract/util.py's parse_const_block handles (that one only knows
const_def/const/const_skip), so this file gets its own small tracker
rather than stretching a shared helper to fit one caller.
wEventFlags (ram/wram.asm) is a flat NUM_EVENTS-bit array; each EVENT_*
constant IS its bit index. NUM_EVENTS is set by the file's own trailing
`const_next $A00` (2560 bits = 320 bytes), matched by `flag_array
NUM_EVENTS` at the wEventFlags declaration.
Output: src/save_convert/data/event_flags.lua (committed; independent of ROM
import, like data/palettes_gbc.lua)
byName[EVENT_NAME] = bit index (int)
byBit[bit index] = EVENT_NAME
count = total bit width of wEventFlags (NUM_EVENTS)
"""
import argparse
import os
import re
import sys
from . import util
def _eval_next(expr):
"""`$XX` or `$XX - N` / `$XX + N` -> int."""
m = re.match(r"^(\S+)\s*([+-])\s*(\S+)$", expr)
if m:
base = util.parse_number(m.group(1))
n = util.parse_number(m.group(3))
return base + n if m.group(2) == "+" else base - n
return util.parse_number(expr)
def extract(pokered, out_path):
path = os.path.join(pokered, "constants/event_constants.asm")
by_name = {}
by_bit = {}
value = None
count = None
for lineno, line in util.read_asm(path):
s = line.strip()
if not s:
continue
m = re.match(r"const_def(?:\s+(\S+))?$", s)
if m:
value = util.parse_number(m.group(1)) if m.group(1) else 0
continue
m = re.match(r"const\s+(\w+)", s)
if m:
if value is None:
util.die(f"{path}:{lineno}: const before const_def")
name = m.group(1)
if name in by_name:
util.die(f"{path}:{lineno}: duplicate flag {name}")
by_name[name] = value
by_bit[value] = name
value += 1
continue
m = re.match(r"const_skip(?:\s+(\S+))?$", s)
if m:
if value is None:
util.die(f"{path}:{lineno}: const_skip before const_def")
value += util.parse_number(m.group(1)) if m.group(1) else 1
continue
m = re.match(r"const_next\s+(.+)$", s)
if m:
value = _eval_next(m.group(1).strip())
continue
m = re.match(r"DEF\s+NUM_EVENTS\s+EQU\s+const_value\s*$", s)
if m:
if value is None:
util.die(f"{path}:{lineno}: NUM_EVENTS before any const_def")
count = value
continue
if count is None:
util.die(f"{path}: NUM_EVENTS EQU const_value not found")
if count % 8 != 0:
util.die(f"{path}: NUM_EVENTS={count} is not byte-aligned")
if not by_name:
util.die(f"{path}: parsed 0 EVENT_* flags")
util.write_lua(
out_path,
{"source": "pokered constants/event_constants.asm",
"count": count,
"byName": by_name,
"byBit": by_bit},
header="wEventFlags bit index <-> EVENT_* name (see ram/wram.asm\n"
"wEventFlags, a flat NUM_EVENTS-bit / (NUM_EVENTS/8)-byte\n"
"array). byBit only has entries for bits with a name --\n"
"reserved/padding bits are intentionally absent.")
return by_name, by_bit, count
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--pokered", required=True)
parser.add_argument("--out", default="src/save_convert/data/event_flags.lua")
args = parser.parse_args(argv)
extract(args.pokered, args.out)
return 0
if __name__ == "__main__":
if __package__ is None:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
__package__ = "extract"
from extract import util as util # noqa: F811
raise SystemExit(main())
+1 -1
View File
@@ -113,7 +113,7 @@ def engine_version(repo):
src = open(os.path.join(repo, "src", "core", "Version.lua"),
encoding="utf-8").read()
match = re.search(r'engine\s*=\s*"([^"]+)"', src)
return match.group(1) if match else "1.0.0"
return match.group(1) if match else "0.0.0-dev"
def known_permissions(repo):
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env luajit
-- CLI: import a vanilla Gen1 (Red/Blue, international) save -- either a
-- raw 32768-byte .sav file, or a JSON wrapper carrying one as its
-- `raw_base64` field -- into this project's save.lua format, or export a
-- save.lua back out to a raw .sav. Run from the repo root:
--
-- luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua>
-- luajit tools/save_convert/convert.lua export <in.lua> <out.sav>
--
-- This is a thin shell: all the actual work (size/checksum validation, the
-- GenSave codec, crosswalk data loading, the merge over new-game defaults)
-- lives in src/save_convert/SaveConvert.lua, shared with the runtime. This
-- file only handles the filesystem + the JSON/base64 input framing. See
-- src/save_convert/GenSave.lua for the codec and its documented scope.
package.path = "./?.lua;" .. package.path
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version")
local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64_LOOKUP = {}
for i = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(i, i)] = i - 1 end
local function b64decode(s)
s = s:gsub("[^%w+/=]", "")
local out = {}
local i = 1
while i <= #s do
local c1, c2, c3, c4 = s:sub(i, i), s:sub(i + 1, i + 1), s:sub(i + 2, i + 2), s:sub(i + 3, i + 3)
local n1, n2 = B64_LOOKUP[c1], B64_LOOKUP[c2]
local n3 = c3 ~= "=" and c3 ~= "" and B64_LOOKUP[c3] or nil
local n4 = c4 ~= "=" and c4 ~= "" and B64_LOOKUP[c4] or nil
out[#out + 1] = string.char(n1 * 4 + math.floor(n2 / 16))
if n3 then
out[#out + 1] = string.char((n2 % 16) * 16 + math.floor(n3 / 4))
if n4 then out[#out + 1] = string.char((n3 % 4) * 64 + n4) end
end
i = i + 4
end
return table.concat(out)
end
local function readFile(path, mode)
local f = assert(io.open(path, mode or "r"), "cannot open " .. path)
local content = f:read("*a")
f:close()
return content
end
local function cmdImport(inPath, outPath)
local content = readFile(inPath, "rb")
local bytes
if #content == SaveConvert.SAVE_SIZE then
bytes = content
else
local b64 = content:match('"raw_base64"%s*:%s*"([^"]+)"')
assert(b64, "input is neither a 32768-byte .sav nor JSON with a raw_base64 field")
bytes = b64decode(b64)
assert(#bytes == SaveConvert.SAVE_SIZE,
("decoded raw_base64 is %d bytes, want %d"):format(#bytes, SaveConvert.SAVE_SIZE))
end
local save, err = SaveConvert.importSav(bytes, Version.saveFormat)
assert(save, err)
local out = assert(io.open(outPath, "w"))
out:write(SaveSerializer.encode(save))
out:close()
print(("wrote %s (party %d, boxed %d, %d flags)"):format(
outPath, #save.party,
(function() local n = 0 for _, b in ipairs(save.boxes) do n = n + #b end return n end)(),
(function() local n = 0 for _ in pairs(save.flags) do n = n + 1 end return n end)()))
end
local function cmdExport(inPath, outPath)
local content = readFile(inPath)
local save = assert(SaveSerializer.decode(content))
local bytes, err = SaveConvert.exportSav(save)
assert(bytes, err)
local out = assert(io.open(outPath, "wb"))
out:write(bytes)
out:close()
print(("wrote %s (%d bytes)"):format(outPath, #bytes))
end
local cmd = arg[1]
if cmd == "import" and arg[2] and arg[3] then
cmdImport(arg[2], arg[3])
elseif cmd == "export" and arg[2] and arg[3] then
cmdExport(arg[2], arg[3])
else
io.stderr:write(
"usage: luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua>\n" ..
" luajit tools/save_convert/convert.lua export <in.lua> <out.sav>\n")
os.exit(1)
end
+446
View File
@@ -0,0 +1,446 @@
-- crosscheck.lua -- adversarial cross-validation of GenSave.decode against
-- the INDEPENDENT vendor parser (vendor/gen1lib.lua, a PKHeX-derived generic
-- Gen1 .sav<->JSON codec) on a real 32768-byte battery save. Two codecs
-- triangulated from different authorities (GenSave from the pokered
-- disassembly, the vendor from PKHeX.Core) reading the same bytes: any
-- semantic field they disagree on is a bug in one of them.
--
-- run: lua5.4 tools/save_convert/crosscheck.lua (needs POKEPORT_SAV_FIXTURE)
--
-- INTERPRETER NOTE. The vendor lib is written in Lua 5.3+ (native << >> & //
-- operators, utf8 library) and cannot even be PARSED by LuaJIT, while GenSave
-- `require("bit")`s LuaJIT's BitOp. So this harness must run under a stock
-- Lua 5.3/5.4, and we hand GenSave a tiny `bit` shim backed by 5.4's native
-- operators. (The main suite, tests/save_convert_tests.lua, still runs under
-- luajit; this maintenance tool is the one place both codecs coexist.)
--
-- COVERAGE ASYMMETRY / ORACLE CHOICE. The vendor decodes trainer block,
-- party, and all 12 boxes, but deliberately leaves items, Pokedex, options,
-- event flags, map and coords inside its opaque `raw_base64` blob (it only
-- round-trips them, never interprets them). For every such field this harness
-- reads the RAW BYTES straight out of the vendor's own byte buffer at the
-- vendor's own M.OFS.* offsets (an authority fully independent of GenSave's
-- offset table) and compares GenSave's decode against that. So even the
-- "vendor doesn't model it" fields still get a genuine second-source check.
--
-- NO STANDING VENDOR DISAGREEMENTS. Every field the vendor DOES interpret
-- agrees with GenSave on the real fixture, so there is no vendor bug to work
-- around here. (Were one found, the rule per the task is: leave vendor code
-- untouched, keep GenSave's value, and document the disagreement in this
-- block.) The one bug this cross-check flushed out was on GenSave's side: its
-- flag_array reader packed bits MSB-first, but pokered's FlagAction (and
-- PKHeX) pack LSB-first. It survived the round-trip suite because encode and
-- decode shared the wrong convention; it did NOT survive contact with a real
-- save, where boxed ZAPDOS (dex 145) read back as un-owned. Fixed in GenSave
-- (bitGet/bitSet); this harness re-decodes the dex from raw bytes LSB-first
-- and now agrees.
------------------------------------------------------------------------
-- `bit` shim for GenSave, backed by native Lua 5.4 operators.
------------------------------------------------------------------------
if not pcall(require, "bit") then
package.preload["bit"] = function()
local M = {}
-- LuaJIT's BitOp band/bor/bxor are VARIADIC (fold over all args); GenSave
-- relies on that in decodeDVs' 4-arg bit.bor for the HP DV. A 2-arg shim
-- silently drops the tail args -- so fold explicitly here.
function M.band(a, ...) local r = a; for _, v in ipairs({...}) do r = r & v end; return r & 0xFFFFFFFF end
function M.bor(a, ...) local r = a; for _, v in ipairs({...}) do r = r | v end; return r & 0xFFFFFFFF end
function M.bxor(a, ...) local r = a; for _, v in ipairs({...}) do r = r ~ v end; return r & 0xFFFFFFFF end
function M.bnot(a) return (~a) & 0xFFFFFFFF end
function M.lshift(a, n) return (a << n) & 0xFFFFFFFF end
function M.rshift(a, n) return (a & 0xFFFFFFFF) >> n end
return M
end
end
package.path = "./?.lua;" .. package.path
------------------------------------------------------------------------
-- Load GenSave + the same generated data the codec uses in production.
------------------------------------------------------------------------
local GenSave = require("src.save_convert.GenSave")
local charmap = loadfile("src/save_convert/data/charmap.lua")()
GenSave.setCharmap(charmap)
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
local cw = GenSave.crosswalks(data)
------------------------------------------------------------------------
-- Load the fixture.
------------------------------------------------------------------------
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
if not fixturePath then
io.stderr:write("POKEPORT_SAV_FIXTURE is not set (point it at a 32768-byte .sav)\n")
os.exit(2)
end
local ff, oerr = io.open(fixturePath, "rb")
if not ff then
io.stderr:write("cannot open POKEPORT_SAV_FIXTURE: " .. tostring(oerr) .. "\n")
os.exit(2)
end
local rawStr = ff:read("*a"); ff:close()
if #rawStr ~= GenSave.SAVE_SIZE then
io.stderr:write(("fixture is %d bytes, expected %d\n"):format(#rawStr, GenSave.SAVE_SIZE))
os.exit(2)
end
------------------------------------------------------------------------
-- Parse the SAME bytes through both codecs.
------------------------------------------------------------------------
local gen = GenSave.decode(rawStr, data)
local gen1 = dofile("tools/save_convert/vendor/gen1lib.lua")
local vbuf = gen1.string_to_bytes(rawStr) -- vendor's 1-indexed byte array
local ven = gen1.parse_save(vbuf)
-- Raw readers over the vendor's byte buffer (fileOffset -> byte). Used as an
-- independent oracle for the fields the vendor leaves inside raw_base64.
-- These numeric offsets are exactly the ones gen1lib uses internally (its
-- M.OFS), transcribed here so the oracle is anchored to the VENDOR's layout,
-- not GenSave's.
local RAW = {
DexCaught = 0x25A3, DexSeen = 0x25B6, Items = 0x25C9, Options = 0x2601,
PCItems = 0x27E6,
}
local function rb(off) return vbuf[off + 1] end -- raw byte at file offset
local function dexbit_lsb(base, idx) return ((rb(base + (idx // 8)) >> (idx % 8)) & 1) == 1 end
------------------------------------------------------------------------
-- Diff collector.
------------------------------------------------------------------------
local fails, notes = {}, {}
local function fail(field, msg) fails[#fails + 1] = { field = field, msg = msg } end
local function eq(field, a, b, ctx)
if a ~= b then
fail(field, ("%s: GenSave=%s vendor=%s"):format(ctx or "", tostring(a), tostring(b)))
return false
end
return true
end
local function note(msg) notes[#notes + 1] = msg end
local NAME_LEN = 11 -- GenSave NAME_LENGTH == vendor STRING_LENGTH
-- Name equivalence. GenSave and the vendor spell a few glyphs differently:
-- GenSave uses bracket control tokens ("<DOT>" for byte 0xE8, "<TRAINER>" for
-- the 0x5D in-game-trade OT marker); the vendor uses the raw Unicode glyph
-- ("\u{2024}") / "*". These are the SAME underlying Gen1 byte, so instead of
-- comparing the decoded text we canonicalize each side back to its 11-byte
-- Gen1 sequence via its own table and compare THOSE. A genuine offset or
-- coverage bug (wrong name bytes) still surfaces as a byte-sequence diff.
local function genNameToBytes(text) -- mirrors GenSave's local encodeName
local out, i, pos = {}, 0, 1
text = text or ""
while i < NAME_LEN - 1 and pos <= #text do
local bracket = text:match("^(<[^<>]*>)", pos)
local ch, clen
if bracket and charmap.byToken[bracket] then
ch, clen = bracket, #bracket
else
local b0 = text:byte(pos)
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
ch = text:sub(pos, pos + clen - 1)
end
out[i + 1] = string.char(charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
for j = i, NAME_LEN - 1 do out[j + 1] = string.char(0x50) end
return table.concat(out)
end
local function venNameToBytes(text) -- via the vendor's own encoder
local b = {}
gen1.encode_string(b, 0, NAME_LEN, text or "")
local out = {}
for k = 1, NAME_LEN do out[k] = string.char((b[k] or 0x50) & 0xFF) end
return table.concat(out)
end
local repNoted = {}
local function compareName(field, g, v, ctx)
if g == v then return end
if genNameToBytes(g) == venNameToBytes(v) then
local key = tostring(g) .. "|" .. tostring(v)
if not repNoted[key] then
repNoted[key] = true
note(("name glyph representation differs, bytes identical: GenSave %q == vendor %q")
:format(tostring(g), tostring(v)))
end
else
fail(field, ("%s: GenSave=%q vendor=%q (byte sequences differ)")
:format(ctx or "", tostring(g), tostring(v)))
end
end
------------------------------------------------------------------------
-- 1. Trainer block (vendor decodes all of these).
------------------------------------------------------------------------
compareName("player.name", gen.player.name, ven.trainer.name, "player name")
eq("player.id", gen.player.id, ven.trainer.id, "trainer id")
compareName("player.rival", gen.player.rival, ven.trainer.rival_name, "rival name")
eq("money", gen.money, ven.trainer.money, "money")
eq("coins", gen.coins, ven.trainer.coins, "coins")
-- badges: GenSave exposes them as truthy inventory entries; vendor gives the
-- raw wObtainedBadges byte. Compare bit for bit (BIT_BOULDERBADGE=0 .. =7).
local BADGE = { [0]="BOULDERBADGE",[1]="CASCADEBADGE",[2]="THUNDERBADGE",
[3]="RAINBOWBADGE",[4]="SOULBADGE",[5]="MARSHBADGE",[6]="VOLCANOBADGE",[7]="EARTHBADGE" }
for i = 0, 7 do
local vset = ((ven.trainer.badges >> i) & 1) == 1
local gset = gen.inventory[BADGE[i]] == 1
eq("badges." .. BADGE[i], gset, vset, "badge bit " .. i)
end
------------------------------------------------------------------------
-- 2. Play time (vendor decodes H/M/S/F/maxed; GenSave folds to one float).
------------------------------------------------------------------------
do
local pt = ven.trainer.play_time
local expected = pt.hours * 3600 + pt.minutes * 60 + pt.seconds + pt.frames / 60
if math.abs(gen.playTime - expected) > 1e-6 then
fail("playTime", ("GenSave=%s vendor=%dh%02dm%02ds%02df"):format(
tostring(gen.playTime), pt.hours, pt.minutes, pt.seconds, pt.frames))
end
end
------------------------------------------------------------------------
-- 3. Options -- GenSave does not model this field; verify that's the only
-- reason for silence, and record the raw value the vendor would carry.
------------------------------------------------------------------------
if gen.options == nil then
note(("options: not modeled by GenSave (raw wOptions byte = 0x%02X, preserved "
.. "verbatim via the export template)"):format(rb(RAW.Options)))
else
eq("options", gen.options, rb(RAW.Options), "options byte")
end
------------------------------------------------------------------------
-- 4. Current box + map/coords.
------------------------------------------------------------------------
eq("currentBox", gen.currentBox, ven.current_box, "current box number")
-- Map + coords: the vendor does not decode wCurMap/X/Y at all. Sanity-check
-- GenSave's values against its own data tables (no second source available).
if gen.player.map and data.maps[gen.player.map] then
note(("map/coords: vendor does not decode these; GenSave says map=%s x=%s y=%s "
.. "(a valid map id)"):format(gen.player.map, tostring(gen.player.x), tostring(gen.player.y)))
else
fail("player.map", "GenSave decoded an unknown/absent current map")
end
------------------------------------------------------------------------
-- 5. Bag + PC items -- vendor leaves these in raw_base64; read the raw
-- (id,qty) lists straight from its byte buffer as the oracle.
------------------------------------------------------------------------
local function rawItemList(base, capacity)
local count = rb(base)
local list = {}
for i = 0, math.min(count, capacity) - 1 do
local idb = rb(base + 1 + i * 2)
if idb == 0xFF then break end
list[#list + 1] = { idByte = idb, qty = rb(base + 2 + i * 2) }
end
return count, list
end
-- Bag: GenSave preserves order in save.bagOrder; compare id+qty in sequence.
do
local count, raw = rawItemList(RAW.Items, 20)
eq("bag.count", #gen.bagOrder, count, "bag item count")
local n = math.max(#gen.bagOrder, #raw)
for i = 1, n do
local gid = gen.bagOrder[i]
local r = raw[i]
local gidx = gid and cw.itemsIndex[gid]
eq("bag[" .. i .. "].id", gidx, r and r.idByte, "bag slot " .. i .. " item")
if gid and r then eq("bag[" .. i .. "].qty", gen.inventory[gid], r.qty, "bag slot " .. i .. " qty") end
end
end
-- PC: GenSave keeps only a {id->qty} map (order dropped); compare as a set.
do
local count, raw = rawItemList(RAW.PCItems, 50)
local rawMap, rawCount = {}, 0
for _, r in ipairs(raw) do
local id = cw.itemsByIndex[r.idByte]
if id then rawMap[id] = r.qty; rawCount = rawCount + 1 end
end
local genCount = 0
for id, qty in pairs(gen.pcItems) do
genCount = genCount + 1
eq("pcItems." .. id, qty, rawMap[id], "PC item " .. id .. " qty")
end
eq("pcItems.count", genCount, rawCount, "PC item count")
end
------------------------------------------------------------------------
-- 6. Pokedex owned/seen -- vendor leaves these in raw_base64; decode the
-- raw bitsets LSB-first (pokered/PKHeX convention) as the oracle.
------------------------------------------------------------------------
do
for dex, species in pairs(cw.pokemonByDex) do
if dex >= 1 and dex <= 151 then
local rawOwned = dexbit_lsb(RAW.DexCaught, dex - 1)
local rawSeen = dexbit_lsb(RAW.DexSeen, dex - 1)
eq("dex.owned." .. species, gen.pokedex.owned[species] == true, rawOwned, "owned " .. species)
eq("dex.seen." .. species, gen.pokedex.seen[species] == true, rawSeen, "seen " .. species)
end
end
-- physical invariant: every possessed species must be owned AND seen.
local possessed = {}
for _, m in ipairs(gen.party) do possessed[m.species] = true end
for b = 1, 12 do for _, m in ipairs(gen.boxes[b]) do possessed[m.species] = true end end
for sp in pairs(possessed) do
if not gen.pokedex.owned[sp] then fail("dex.invariant", sp .. " is possessed but not owned") end
if not gen.pokedex.seen[sp] then fail("dex.invariant", sp .. " is possessed but not seen") end
end
end
------------------------------------------------------------------------
-- 7. Event flags -- vendor leaves these in raw_base64; there is no vendor
-- field to compare against, but the physical invariant that the very
-- first story flag (EVENT_FOLLOWED_OAK_INTO_LAB, bit 0) is set on any
-- save past the intro is a real LSB-vs-MSB discriminator.
------------------------------------------------------------------------
do
local nflags = 0
for _ in pairs(gen.flags) do nflags = nflags + 1 end
if nflags == 0 then fail("flags", "no event flags decoded at all") end
if not gen.flags.EVENT_FOLLOWED_OAK_INTO_LAB then
fail("flags.EVENT_FOLLOWED_OAK_INTO_LAB",
"bit 0 not set -- expected on any save past the intro (LSB-order check)")
end
end
------------------------------------------------------------------------
-- 8. Mon-by-mon comparison (party + boxes) against the vendor's PK1 parse.
------------------------------------------------------------------------
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
local function statusFromByte(b)
if (b & 7) > 0 then return "SLP" end
for name, bi in pairs(STATUS_BIT) do if (b & (1 << bi)) ~= 0 then return name end end
return nil
end
local function compareMon(tag, g, v, isParty)
if not g or not v then
fail(tag, "one side missing this slot (GenSave=" .. tostring(g) .. " vendor=" .. tostring(v) .. ")")
return
end
-- species: GenSave id string -> national dex must equal vendor's dex number.
eq(tag .. ".species", cw.pokemonDex[g.species], v.species, tag .. " species (" .. tostring(g.species) .. ")")
eq(tag .. ".level", g.level, v.level, tag .. " level")
eq(tag .. ".hp", g.hp, v.current_hp, tag .. " current HP")
eq(tag .. ".otId", g.otId, v.ot_id, tag .. " OT id")
eq(tag .. ".exp", g.exp, v.exp, tag .. " exp")
eq(tag .. ".catchRate", g.catchRate, v.catch_rate, tag .. " catch rate")
compareName(tag .. ".ot", g.ot, v.ot_name, tag .. " OT name")
compareName(tag .. ".nickname", g.nickname, v.nickname, tag .. " nickname")
-- DVs / IVs
eq(tag .. ".dv.atk", g.dvs.attack, v.ivs.atk, tag .. " DV atk")
eq(tag .. ".dv.def", g.dvs.defense, v.ivs.def, tag .. " DV def")
eq(tag .. ".dv.spe", g.dvs.speed, v.ivs.spe, tag .. " DV spe")
eq(tag .. ".dv.spc", g.dvs.special, v.ivs.spc, tag .. " DV spc")
eq(tag .. ".dv.hp", g.dvs.hp, v.ivs.hp, tag .. " DV hp")
-- stat EXP / EVs
eq(tag .. ".ev.hp", g.statExp.hp, v.evs.hp, tag .. " statExp hp")
eq(tag .. ".ev.atk", g.statExp.attack, v.evs.atk, tag .. " statExp atk")
eq(tag .. ".ev.def", g.statExp.defense, v.evs.def, tag .. " statExp def")
eq(tag .. ".ev.spe", g.statExp.speed, v.evs.spe, tag .. " statExp spe")
eq(tag .. ".ev.spc", g.statExp.special, v.evs.spc, tag .. " statExp spc")
-- status: GenSave string vs vendor raw byte (decoded the same way)
eq(tag .. ".status", g.status, statusFromByte(v.status_condition), tag .. " status")
-- moves: GenSave keeps only nonzero slots, in order; line them up against
-- the vendor's 4 raw slots skipping zeros.
local vmoves = {}
for j = 1, 4 do
if v.moves[j] and v.moves[j] > 0 then
vmoves[#vmoves + 1] = { idx = v.moves[j], pp = v.pp[j], ppUps = v.pp_ups[j] }
end
end
eq(tag .. ".moves.count", #g.moves, #vmoves, tag .. " move count")
for j = 1, math.max(#g.moves, #vmoves) do
local gm, vm = g.moves[j], vmoves[j]
local gidx = gm and cw.movesIndex[gm.id]
eq(tag .. ".move[" .. j .. "].id", gidx, vm and vm.idx, tag .. " move " .. j)
if gm and vm then
eq(tag .. ".move[" .. j .. "].pp", gm.pp, vm.pp, tag .. " move " .. j .. " PP")
eq(tag .. ".move[" .. j .. "].ppUps", gm.ppUps, vm.ppUps, tag .. " move " .. j .. " PP-ups")
end
end
-- party-only computed stats
if isParty then
eq(tag .. ".stat.hp", g.stats.hp, v.stats.hp_max, tag .. " stat maxHP")
eq(tag .. ".stat.atk", g.stats.attack, v.stats.atk, tag .. " stat atk")
eq(tag .. ".stat.def", g.stats.defense, v.stats.def, tag .. " stat def")
eq(tag .. ".stat.spe", g.stats.speed, v.stats.spe, tag .. " stat spe")
eq(tag .. ".stat.spc", g.stats.special, v.stats.spc, tag .. " stat spc")
end
end
-- party
eq("party.count", #gen.party, #ven.party, "party size")
for i = 1, math.max(#gen.party, #ven.party) do
compareMon("party[" .. i .. "]", gen.party[i], ven.party[i], true)
end
-- boxes: the vendor returns all 12 boxes 1..12 (current box read from its
-- live offset). GenSave stores them the same 1-based way.
for b = 1, 12 do
local vbox = ven.boxes[b] and ven.boxes[b].pokemon or {}
local gbox = gen.boxes[b] or {}
eq("box[" .. b .. "].count", #gbox, #vbox, "box " .. b .. " size")
for i = 1, math.max(#gbox, #vbox) do
compareMon(("box[%d][%d]"):format(b, i), gbox[i], vbox[i], false)
end
end
------------------------------------------------------------------------
-- 9. Checksum cross-check: both codecs sum [0x2598,0x3523). The fixture is a
-- real save, so GenSave's stored-checksum verification must pass, and the
-- vendor's independent recompute must match the same stored byte.
------------------------------------------------------------------------
do
if #gen.warnings > 0 then
for _, w in ipairs(gen.warnings) do fail("checksum", "GenSave warning: " .. w) end
end
local sum = 0
for i = 0x2598, 0x3523 - 1 do sum = (sum + rb(i)) & 0xFF end
local vend = (255 - sum) & 0xFF
eq("checksum.byte", rb(0x3523), vend, "stored main checksum vs vendor recompute")
end
------------------------------------------------------------------------
-- Report.
------------------------------------------------------------------------
print("== crosscheck: GenSave.decode vs vendor gen1lib on the real fixture ==")
print(("player=%s id=%d money=%d coins=%d party=%d")
:format(gen.player.name, gen.player.id, gen.money, gen.coins, #gen.party))
do
local owned, seen = 0, 0
for _ in pairs(gen.pokedex.owned) do owned = owned + 1 end
for _ in pairs(gen.pokedex.seen) do seen = seen + 1 end
local boxed = 0
for b = 1, 12 do boxed = boxed + #gen.boxes[b] end
print(("dex owned/seen=%d/%d boxed=%d currentBox=%d")
:format(owned, seen, boxed, gen.currentBox))
end
if #notes > 0 then
print("\n-- notes (fields the vendor does not model; checked against raw bytes) --")
for _, m in ipairs(notes) do print(" * " .. m) end
end
if #fails == 0 then
print("\nALL SHARED FIELDS AGREE -- GenSave.decode matches the vendor parser (and\n"
.. "the raw-byte oracle for the fields the vendor leaves opaque).")
os.exit(0)
else
print(("\n%d MISMATCH(ES):"):format(#fails))
for _, f in ipairs(fails) do
print((" [%s] %s"):format(f.field, f.msg))
end
os.exit(1)
end
+884
View File
@@ -0,0 +1,884 @@
-- gen1lib.lua
--
-- Shared library for converting Pokemon Generation 1 (Red/Blue/Yellow)
-- save files between their native binary format and JSON.
--
-- Scope: International (non-Japanese) 32768-byte (0x8000) save files only.
-- Logic ported from PKHeX.Core:
-- PKHeX.Core/Saves/SAV1.cs, SAV1Offsets.cs
-- PKHeX.Core/PKM/PK1.cs, GBPKM.cs, GBPKML.cs
-- PKHeX.Core/PKM/Strings/StringConverter1.cs
-- PKHeX.Core/PKM/Util/Conversion/SpeciesConverter.cs
-- PKHeX.Core/Saves/Storage/PokeList1.cs
--
-- Requires Lua 5.3+ (native bitwise operators, floor division, utf8 library).
local M = {}
------------------------------------------------------------------------
-- Layout constants (International save layout only)
------------------------------------------------------------------------
M.SIZE_SAVE = 0x8000
M.OFS = {
OT = 0x2598,
DexCaught = 0x25A3,
DexSeen = 0x25B6,
Items = 0x25C9,
Money = 0x25F3,
Rival = 0x25F6,
Options = 0x2601,
Badges = 0x2602,
TID16 = 0x2605,
PikaFriendship = 0x271C,
PikaBeachScore = 0x2741,
PrinterBrightness = 0x2744,
PCItems = 0x27E6,
CurrentBoxIndex = 0x284C,
HallOfFameCount = 0x284E,
Coin = 0x2850,
ObjectSpawnFlags = 0x2852,
EventWork = 0x289C,
Starter = 0x29C3,
EventFlag = 0x29F3,
PlayTime = 0x2CED,
Daycare = 0x2CF4,
Party = 0x2F2C,
CurrentBox = 0x30C0,
ChecksumOfs = 0x3523,
}
M.BOX_COUNT = 12
M.BOX_SLOT_COUNT = 20
M.STRING_LENGTH = 11 -- OT name / rival name / nickname raw buffer length (incl. terminator)
M.SIZE_STORED = 33 -- boxed PK1 struct size
M.SIZE_PARTY = 44 -- party PK1 struct size
M.SIZE_BOX_LIST = ((M.STRING_LENGTH * 2) + M.SIZE_STORED + 1) * M.BOX_SLOT_COUNT + 2
M.SIZE_PARTY_LIST = ((M.STRING_LENGTH * 2) + M.SIZE_PARTY + 1) * 6 + 2
-- Non-current boxes live in two banked arrays at 0x4000 (boxes 1-6) and
-- 0x6000 (boxes 7-12). Whichever box is "current" is instead read from/
-- written to M.OFS.CurrentBox, and mirrored back into its normal slot here
-- on save (SAV1.cs Initialize()/GetFinalData()).
function M.box_bank_offset(boxIndexZero)
local half = M.BOX_COUNT // 2
if boxIndexZero < half then
return 0x4000 + boxIndexZero * M.SIZE_BOX_LIST
else
return 0x6000 + (boxIndexZero - half) * M.SIZE_BOX_LIST
end
end
------------------------------------------------------------------------
-- Byte buffer helpers
-- `buf` is a plain Lua array of integers 0-255, 1-indexed, where
-- buf[fileOffset + 1] holds the byte at `fileOffset`.
------------------------------------------------------------------------
local function get(buf, ofs) return buf[ofs + 1] end
local function set(buf, ofs, v) buf[ofs + 1] = v & 0xFF end
local function read_u16be(buf, ofs) return (get(buf, ofs) << 8) | get(buf, ofs + 1) end
local function write_u16be(buf, ofs, v)
set(buf, ofs, (v >> 8) & 0xFF)
set(buf, ofs + 1, v & 0xFF)
end
local function read_u24be(buf, ofs)
return (get(buf, ofs) << 16) | (get(buf, ofs + 1) << 8) | get(buf, ofs + 2)
end
local function write_u24be(buf, ofs, v)
set(buf, ofs, (v >> 16) & 0xFF)
set(buf, ofs + 1, (v >> 8) & 0xFF)
set(buf, ofs + 2, v & 0xFF)
end
-- n-byte binary-coded decimal (two decimal digits per byte).
local function bcd_read(buf, ofs, n, littleEndian)
local bytes = {}
for i = 0, n - 1 do bytes[i + 1] = get(buf, ofs + i) end
if littleEndian then
local rev = {}
for i = 1, n do rev[i] = bytes[n - i + 1] end
bytes = rev
end
local v = 0
for _, b in ipairs(bytes) do
v = v * 100 + ((b >> 4) * 10) + (b & 0xF)
end
return v
end
local function bcd_write(buf, ofs, n, littleEndian, value)
local bytes = {}
local v = value
for i = n, 1, -1 do
local d = v % 100
v = (v - d) // 100
bytes[i] = ((d // 10) << 4) | (d % 10)
end
if littleEndian then
local rev = {}
for i = 1, n do rev[i] = bytes[n - i + 1] end
bytes = rev
end
for i = 0, n - 1 do set(buf, ofs + i, bytes[i + 1]) end
end
------------------------------------------------------------------------
-- Species: Gen 1 internal index <-> National Dex ID
-- (PKHeX.Core/PKM/Util/Conversion/SpeciesConverter.cs, Table1*)
------------------------------------------------------------------------
-- index (1-based) = National Dex ID + 1; value = Gen1 internal species byte
local NAT_TO_INTERNAL = {
0x00, 0x99, 0x09, 0x9A, 0xB0, 0xB2, 0xB4, 0xB1, 0xB3, 0x1C, 0x7B, 0x7C, 0x7D, 0x70, 0x71, 0x72,
0x24, 0x96, 0x97, 0xA5, 0xA6, 0x05, 0x23, 0x6C, 0x2D, 0x54, 0x55, 0x60, 0x61, 0x0F, 0xA8, 0x10,
0x03, 0xA7, 0x07, 0x04, 0x8E, 0x52, 0x53, 0x64, 0x65, 0x6B, 0x82, 0xB9, 0xBA, 0xBB, 0x6D, 0x2E,
0x41, 0x77, 0x3B, 0x76, 0x4D, 0x90, 0x2F, 0x80, 0x39, 0x75, 0x21, 0x14, 0x47, 0x6E, 0x6F, 0x94,
0x26, 0x95, 0x6A, 0x29, 0x7E, 0xBC, 0xBD, 0xBE, 0x18, 0x9B, 0xA9, 0x27, 0x31, 0xA3, 0xA4, 0x25,
0x08, 0xAD, 0x36, 0x40, 0x46, 0x74, 0x3A, 0x78, 0x0D, 0x88, 0x17, 0x8B, 0x19, 0x93, 0x0E, 0x22,
0x30, 0x81, 0x4E, 0x8A, 0x06, 0x8D, 0x0C, 0x0A, 0x11, 0x91, 0x2B, 0x2C, 0x0B, 0x37, 0x8F, 0x12,
0x01, 0x28, 0x1E, 0x02, 0x5C, 0x5D, 0x9D, 0x9E, 0x1B, 0x98, 0x2A, 0x1A, 0x48, 0x35, 0x33, 0x1D,
0x3C, 0x85, 0x16, 0x13, 0x4C, 0x66, 0x69, 0x68, 0x67, 0xAA, 0x62, 0x63, 0x5A, 0x5B, 0xAB, 0x84,
0x4A, 0x4B, 0x49, 0x58, 0x59, 0x42, 0x83, 0x15,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
-- index (1-based) = Gen1 internal species byte + 1; value = National Dex ID
local INTERNAL_TO_NAT = {
0x00, 0x70, 0x73, 0x20, 0x23, 0x15, 0x64, 0x22, 0x50, 0x02, 0x67, 0x6C, 0x66, 0x58, 0x5E, 0x1D,
0x1F, 0x68, 0x6F, 0x83, 0x3B, 0x97, 0x82, 0x5A, 0x48, 0x5C, 0x7B, 0x78, 0x09, 0x7F, 0x72, 0x00,
0x00, 0x3A, 0x5F, 0x16, 0x10, 0x4F, 0x40, 0x4B, 0x71, 0x43, 0x7A, 0x6A, 0x6B, 0x18, 0x2F, 0x36,
0x60, 0x4C, 0x00, 0x7E, 0x00, 0x7D, 0x52, 0x6D, 0x00, 0x38, 0x56, 0x32, 0x80, 0x00, 0x00, 0x00,
0x53, 0x30, 0x95, 0x00, 0x00, 0x00, 0x54, 0x3C, 0x7C, 0x92, 0x90, 0x91, 0x84, 0x34, 0x62, 0x00,
0x00, 0x00, 0x25, 0x26, 0x19, 0x1A, 0x00, 0x00, 0x93, 0x94, 0x8C, 0x8D, 0x74, 0x75, 0x00, 0x00,
0x1B, 0x1C, 0x8A, 0x8B, 0x27, 0x28, 0x85, 0x88, 0x87, 0x86, 0x42, 0x29, 0x17, 0x2E, 0x3D, 0x3E,
0x0D, 0x0E, 0x0F, 0x00, 0x55, 0x39, 0x33, 0x31, 0x57, 0x00, 0x00, 0x0A, 0x0B, 0x0C, 0x44, 0x00,
0x37, 0x61, 0x2A, 0x96, 0x8F, 0x81, 0x00, 0x00, 0x59, 0x00, 0x63, 0x5B, 0x00, 0x65, 0x24, 0x6E,
0x35, 0x69, 0x00, 0x5D, 0x3F, 0x41, 0x11, 0x12, 0x79, 0x01, 0x03, 0x49, 0x00, 0x76, 0x77, 0x00,
0x00, 0x00, 0x00, 0x4D, 0x4E, 0x13, 0x14, 0x21, 0x1E, 0x4A, 0x89, 0x8E, 0x00, 0x51, 0x00, 0x00,
0x04, 0x07, 0x05, 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x2B, 0x2C, 0x2D, 0x45, 0x46, 0x47, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}
function M.national_to_internal(species)
return NAT_TO_INTERNAL[species + 1] or 0
end
function M.internal_to_national(raw)
return INTERNAL_TO_NAT[raw + 1] or 0
end
------------------------------------------------------------------------
-- Species names (English, National Dex 1-151; PKHeX.Core Resources/text/other/en/text_Species_en.txt)
-- Informational only ("species_name" in JSON) -- ignored when converting back to a save.
------------------------------------------------------------------------
M.SPECIES_NAMES = {
[1] = "Bulbasaur", [2] = "Ivysaur", [3] = "Venusaur", [4] = "Charmander",
[5] = "Charmeleon", [6] = "Charizard", [7] = "Squirtle", [8] = "Wartortle",
[9] = "Blastoise", [10] = "Caterpie", [11] = "Metapod", [12] = "Butterfree",
[13] = "Weedle", [14] = "Kakuna", [15] = "Beedrill", [16] = "Pidgey",
[17] = "Pidgeotto", [18] = "Pidgeot", [19] = "Rattata", [20] = "Raticate",
[21] = "Spearow", [22] = "Fearow", [23] = "Ekans", [24] = "Arbok",
[25] = "Pikachu", [26] = "Raichu", [27] = "Sandshrew", [28] = "Sandslash",
[29] = "Nidoran\u{2640}", [30] = "Nidorina", [31] = "Nidoqueen", [32] = "Nidoran\u{2642}",
[33] = "Nidorino", [34] = "Nidoking", [35] = "Clefairy", [36] = "Clefable",
[37] = "Vulpix", [38] = "Ninetales", [39] = "Jigglypuff", [40] = "Wigglytuff",
[41] = "Zubat", [42] = "Golbat", [43] = "Oddish", [44] = "Gloom",
[45] = "Vileplume", [46] = "Paras", [47] = "Parasect", [48] = "Venonat",
[49] = "Venomoth", [50] = "Diglett", [51] = "Dugtrio", [52] = "Meowth",
[53] = "Persian", [54] = "Psyduck", [55] = "Golduck", [56] = "Mankey",
[57] = "Primeape", [58] = "Growlithe", [59] = "Arcanine", [60] = "Poliwag",
[61] = "Poliwhirl", [62] = "Poliwrath", [63] = "Abra", [64] = "Kadabra",
[65] = "Alakazam", [66] = "Machop", [67] = "Machoke", [68] = "Machamp",
[69] = "Bellsprout", [70] = "Weepinbell", [71] = "Victreebel", [72] = "Tentacool",
[73] = "Tentacruel", [74] = "Geodude", [75] = "Graveler", [76] = "Golem",
[77] = "Ponyta", [78] = "Rapidash", [79] = "Slowpoke", [80] = "Slowbro",
[81] = "Magnemite", [82] = "Magneton", [83] = "Farfetch\u{2019}d", [84] = "Doduo",
[85] = "Dodrio", [86] = "Seel", [87] = "Dewgong", [88] = "Grimer",
[89] = "Muk", [90] = "Shellder", [91] = "Cloyster", [92] = "Gastly",
[93] = "Haunter", [94] = "Gengar", [95] = "Onix", [96] = "Drowzee",
[97] = "Hypno", [98] = "Krabby", [99] = "Kingler", [100] = "Voltorb",
[101] = "Electrode", [102] = "Exeggcute", [103] = "Exeggutor", [104] = "Cubone",
[105] = "Marowak", [106] = "Hitmonlee", [107] = "Hitmonchan", [108] = "Lickitung",
[109] = "Koffing", [110] = "Weezing", [111] = "Rhyhorn", [112] = "Rhydon",
[113] = "Chansey", [114] = "Tangela", [115] = "Kangaskhan", [116] = "Horsea",
[117] = "Seadra", [118] = "Goldeen", [119] = "Seaking", [120] = "Staryu",
[121] = "Starmie", [122] = "Mr. Mime", [123] = "Scyther", [124] = "Jynx",
[125] = "Electabuzz", [126] = "Magmar", [127] = "Pinsir", [128] = "Tauros",
[129] = "Magikarp", [130] = "Gyarados", [131] = "Lapras", [132] = "Ditto",
[133] = "Eevee", [134] = "Vaporeon", [135] = "Jolteon", [136] = "Flareon",
[137] = "Porygon", [138] = "Omanyte", [139] = "Omastar", [140] = "Kabuto",
[141] = "Kabutops", [142] = "Aerodactyl", [143] = "Snorlax", [144] = "Articuno",
[145] = "Zapdos", [146] = "Moltres", [147] = "Dratini", [148] = "Dragonair",
[149] = "Dragonite", [150] = "Mewtwo", [151] = "Mew",
}
------------------------------------------------------------------------
-- Gen 1 text encoding, international/English table
-- (PKHeX.Core/PKM/Strings/StringConverter1.cs, TableEN)
-- `false` marks a byte that decodes to the string terminator (NUL).
------------------------------------------------------------------------
local TABLE_EN = {
-- 0x00-0x0F
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
-- 0x10-0x1F
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
-- 0x20-0x2F
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
-- 0x30-0x3F
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
-- 0x40-0x4F
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
-- 0x50-0x5F (0x50 terminator, 0x5D in-game-trade marker)
false, false, false, false, false, false, false, false, false, false, false, false, false, "*", false, false,
-- 0x60-0x6F
false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false,
-- 0x70-0x7F
"@", "#", "\u{201C}", "\u{201D}", false, "\u{2026}", false, false, false, "\u{250C}", "\u{2500}", "\u{2510}", "\u{2502}", "\u{2514}", "\u{2518}", " ",
-- 0x80-0x8F
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
-- 0x90-0x9F
"Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "(", ")", ":", ";", "[", "]",
-- 0xA0-0xAF
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p",
-- 0xB0-0xBF
"q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "\u{E0}", "\u{E8}", "\u{E9}", "\u{F9}", "\u{C0}", "\u{C1}",
-- 0xC0-0xCF
"\u{C4}", "\u{D6}", "\u{DC}", "\u{E4}", "\u{F6}", "\u{FC}", "\u{C8}", "\u{C9}", "\u{CC}", "\u{CD}", "\u{D1}", "\u{D2}", "\u{D3}", "\u{D9}", "\u{DA}", "\u{E1}",
-- 0xD0-0xDF
"\u{EC}", "\u{ED}", "\u{F1}", "\u{F2}", "\u{F3}", "\u{FA}", "\u{BA}", false, false, false, false, false, false, false, "\u{2190}", "'",
-- 0xE0-0xEF
"\u{2019}", "{", "}", "-", false, false, "?", "!", "\u{2024}", "&", "%", "\u{2192}", "\u{25B7}", "\u{25B6}", "\u{25BC}", "\u{2642}",
-- 0xF0-0xFF
"\u{A5}", "\u{D7}", ".", "/", ",", "\u{2640}", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
}
local CHAR_TO_BYTE = {}
for b = 0, 255 do
local c = TABLE_EN[b + 1]
if c then CHAR_TO_BYTE[c] = b end
end
-- Decodes a fixed-length Gen 1 string buffer into a Lua (UTF-8) string.
function M.decode_string(buf, ofs, bufLen)
if get(buf, ofs) == 0x5D then return "*" end -- in-game trade OT placeholder
local out = {}
for i = 0, bufLen - 1 do
local c = TABLE_EN[get(buf, ofs + i) + 1]
if not c then break end
out[#out + 1] = c
end
return table.concat(out)
end
-- Encodes a Lua (UTF-8) string into a fixed-length Gen 1 string buffer,
-- padding the remainder with the 0x50 terminator byte.
function M.encode_string(buf, ofs, bufLen, str)
for i = 0, bufLen - 1 do set(buf, ofs + i, 0x50) end
str = str or ""
if str == "" then return end
if str == "*" then
set(buf, ofs, 0x5D)
if bufLen > 1 then set(buf, ofs + 1, 0x50) end
return
end
local i = 0
for _, cp in utf8.codes(str) do
if i >= bufLen then break end
local ch = utf8.char(cp)
local b = CHAR_TO_BYTE[ch]
if not b then
error(string.format("character %q is not representable in the Gen 1 international character set", ch))
end
set(buf, ofs + i, b)
i = i + 1
end
if i < bufLen then set(buf, ofs + i, 0x50) end
end
------------------------------------------------------------------------
-- PK1 struct (PKHeX.Core/PKM/PK1.cs, GBPKM.cs)
-- Offsets below are relative to the start of the 33/44-byte struct.
------------------------------------------------------------------------
local function parse_pk1_body(buf, ofs, isParty)
local speciesInternal = get(buf, ofs + 0x00)
local dv16 = read_u16be(buf, ofs + 0x1B)
local ivAtk = (dv16 >> 12) & 0xF
local ivDef = (dv16 >> 8) & 0xF
local ivSpe = (dv16 >> 4) & 0xF
local ivSpc = dv16 & 0xF
local ivHp = ((ivAtk & 1) << 3) | ((ivDef & 1) << 2) | ((ivSpe & 1) << 1) | (ivSpc & 1)
local pp1b, pp2b, pp3b, pp4b = get(buf, ofs + 0x1D), get(buf, ofs + 0x1E), get(buf, ofs + 0x1F), get(buf, ofs + 0x20)
local species = M.internal_to_national(speciesInternal)
local pairs_ = {
{ "species", species },
{ "species_name", M.SPECIES_NAMES[species] },
{ "nickname", "" }, -- filled in by parse_mon_list; placeholder keeps the key ordered here
{ "level", isParty and get(buf, ofs + 0x21) or get(buf, ofs + 0x03) },
{ "ot_name", "" }, -- filled in by parse_mon_list
{ "ot_id", read_u16be(buf, ofs + 0x0C) },
{ "current_hp", read_u16be(buf, ofs + 0x01) },
{ "status_condition", get(buf, ofs + 0x04) },
{ "type1", get(buf, ofs + 0x05) },
{ "type2", get(buf, ofs + 0x06) },
{ "catch_rate", get(buf, ofs + 0x07) },
{ "moves", { get(buf, ofs + 0x08), get(buf, ofs + 0x09), get(buf, ofs + 0x0A), get(buf, ofs + 0x0B) } },
{ "pp", { pp1b & 0x3F, pp2b & 0x3F, pp3b & 0x3F, pp4b & 0x3F } },
{ "pp_ups", { (pp1b >> 6) & 0x3, (pp2b >> 6) & 0x3, (pp3b >> 6) & 0x3, (pp4b >> 6) & 0x3 } },
{ "exp", read_u24be(buf, ofs + 0x0E) },
{ "evs", M.omap({
{ "hp", read_u16be(buf, ofs + 0x11) }, { "atk", read_u16be(buf, ofs + 0x13) },
{ "def", read_u16be(buf, ofs + 0x15) }, { "spe", read_u16be(buf, ofs + 0x17) },
{ "spc", read_u16be(buf, ofs + 0x19) },
}) },
{ "ivs", M.omap({
{ "atk", ivAtk }, { "def", ivDef }, { "spe", ivSpe }, { "spc", ivSpc }, { "hp", ivHp },
}) },
}
if isParty then
pairs_[#pairs_ + 1] = { "stats", M.omap({
{ "hp_max", read_u16be(buf, ofs + 0x22) }, { "atk", read_u16be(buf, ofs + 0x24) },
{ "def", read_u16be(buf, ofs + 0x26) }, { "spe", read_u16be(buf, ofs + 0x28) },
{ "spc", read_u16be(buf, ofs + 0x2A) },
}) }
end
return M.omap(pairs_)
end
local function write_pk1_body(buf, ofs, sizeBody, isParty, mon)
for i = 0, sizeBody - 1 do set(buf, ofs + i, 0) end
set(buf, ofs + 0x00, M.national_to_internal(mon.species))
write_u16be(buf, ofs + 0x01, mon.current_hp or 0)
set(buf, ofs + 0x03, mon.level or 1)
set(buf, ofs + 0x04, mon.status_condition or 0)
set(buf, ofs + 0x05, mon.type1 or 0)
set(buf, ofs + 0x06, mon.type2 or 0)
set(buf, ofs + 0x07, mon.catch_rate or 0)
local moves = mon.moves or {0, 0, 0, 0}
set(buf, ofs + 0x08, moves[1] or 0)
set(buf, ofs + 0x09, moves[2] or 0)
set(buf, ofs + 0x0A, moves[3] or 0)
set(buf, ofs + 0x0B, moves[4] or 0)
write_u16be(buf, ofs + 0x0C, mon.ot_id or 0)
write_u24be(buf, ofs + 0x0E, mon.exp or 0)
local evs = mon.evs or {}
write_u16be(buf, ofs + 0x11, evs.hp or 0)
write_u16be(buf, ofs + 0x13, evs.atk or 0)
write_u16be(buf, ofs + 0x15, evs.def or 0)
write_u16be(buf, ofs + 0x17, evs.spe or 0)
write_u16be(buf, ofs + 0x19, evs.spc or 0)
local ivs = mon.ivs or {}
local dv16 = ((ivs.atk or 0) & 0xF) << 12 | ((ivs.def or 0) & 0xF) << 8 | ((ivs.spe or 0) & 0xF) << 4 | ((ivs.spc or 0) & 0xF)
write_u16be(buf, ofs + 0x1B, dv16)
local pp = mon.pp or {0, 0, 0, 0}
local ppUps = mon.pp_ups or {0, 0, 0, 0}
set(buf, ofs + 0x1D, ((ppUps[1] or 0) & 0x3) << 6 | ((pp[1] or 0) & 0x3F))
set(buf, ofs + 0x1E, ((ppUps[2] or 0) & 0x3) << 6 | ((pp[2] or 0) & 0x3F))
set(buf, ofs + 0x1F, ((ppUps[3] or 0) & 0x3) << 6 | ((pp[3] or 0) & 0x3F))
set(buf, ofs + 0x20, ((ppUps[4] or 0) & 0x3) << 6 | ((pp[4] or 0) & 0x3F))
if isParty then
set(buf, ofs + 0x21, mon.level or 1)
local stats = mon.stats or {}
write_u16be(buf, ofs + 0x22, stats.hp_max or 0)
write_u16be(buf, ofs + 0x24, stats.atk or 0)
write_u16be(buf, ofs + 0x26, stats.def or 0)
write_u16be(buf, ofs + 0x28, stats.spe or 0)
write_u16be(buf, ofs + 0x2A, stats.spc or 0)
end
end
------------------------------------------------------------------------
-- Packed box/party lists (PKHeX.Core/Saves/Storage/PokeList1.cs)
-- u8 count of occupied slots
-- u8[capacity+1] per-slot species marker (0xFF = empty); last byte always 0xFF
-- pk1[capacity] PK1 struct data (no strings), `sizeBody` bytes each
-- str[capacity] Original Trainer name table, STRING_LENGTH bytes each
-- str[capacity] Nickname table, STRING_LENGTH bytes each
------------------------------------------------------------------------
local function parse_mon_list(buf, absBase, capacity, sizeBody, isParty)
local count = get(buf, absBase)
if count > capacity then count = capacity end
local start = 1 + (capacity + 1)
local bodyBase = absBase + start
local otBase = bodyBase + sizeBody * capacity
local nickBase = otBase + capacity * M.STRING_LENGTH
local list = {}
for i = 0, count - 1 do
local mon = parse_pk1_body(buf, bodyBase + sizeBody * i, isParty)
mon.ot_name = M.decode_string(buf, otBase + M.STRING_LENGTH * i, M.STRING_LENGTH)
mon.nickname = M.decode_string(buf, nickBase + M.STRING_LENGTH * i, M.STRING_LENGTH)
list[#list + 1] = mon
end
return list
end
local function write_mon_list(buf, absBase, capacity, sizeBody, isParty, monList)
local count = #monList
if count > capacity then
error(string.format("list has %d Pokemon, but capacity is only %d", count, capacity))
end
set(buf, absBase, count)
local start = 1 + (capacity + 1)
for i = 0, capacity - 1 do
local mon = monList[i + 1]
set(buf, absBase + 1 + i, mon and M.national_to_internal(mon.species) or 0xFF)
end
set(buf, absBase + 1 + capacity, 0xFF) -- list terminator, always present
local bodyBase = absBase + start
local otBase = bodyBase + sizeBody * capacity
local nickBase = otBase + capacity * M.STRING_LENGTH
-- Only touch bytes for occupied slots (i < count). Slots beyond `count`
-- are inert as far as the game is concerned (the marker byte above
-- already flags them 0xFF/empty), so their body/name bytes are left
-- exactly as they were in the original save instead of being normalized.
for i = 0, count - 1 do
local mon = monList[i + 1]
local bodyOfs = bodyBase + sizeBody * i
local otOfs = otBase + M.STRING_LENGTH * i
local nickOfs = nickBase + M.STRING_LENGTH * i
write_pk1_body(buf, bodyOfs, sizeBody, isParty, mon)
M.encode_string(buf, otOfs, M.STRING_LENGTH, mon.ot_name)
M.encode_string(buf, nickOfs, M.STRING_LENGTH, mon.nickname)
end
end
------------------------------------------------------------------------
-- Checksum (SAV1.cs: GetRBYChecksum / SetChecksums)
-- One's complement of the byte-sum over [OFS.OT, OFS.ChecksumOfs).
------------------------------------------------------------------------
local function compute_checksum(buf)
local sum = 0
for i = M.OFS.OT, M.OFS.ChecksumOfs - 1 do
sum = sum + get(buf, i)
end
return (255 - (sum % 256)) & 0xFF
end
------------------------------------------------------------------------
-- Bytes <-> Lua string, base64
------------------------------------------------------------------------
function M.bytes_to_string(buf, len)
len = len or #buf
local chars = {}
for i = 1, len do chars[i] = string.char(buf[i] & 0xFF) end
return table.concat(chars)
end
function M.string_to_bytes(s)
local buf = {}
for i = 1, #s do buf[i] = string.byte(s, i) end
return buf
end
local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64_REV = {}
for idx = 1, #B64_CHARS do B64_REV[B64_CHARS:sub(idx, idx)] = idx - 1 end
function M.base64_encode(data)
local out = {}
local len = #data
local i = 1
while i <= len do
local b1 = string.byte(data, i)
local b2 = string.byte(data, i + 1)
local b3 = string.byte(data, i + 2)
local n = b1 << 16
if b2 then n = n | (b2 << 8) end
if b3 then n = n | b3 end
out[#out + 1] = B64_CHARS:sub((n >> 18 & 0x3F) + 1, (n >> 18 & 0x3F) + 1)
out[#out + 1] = B64_CHARS:sub((n >> 12 & 0x3F) + 1, (n >> 12 & 0x3F) + 1)
out[#out + 1] = b2 and B64_CHARS:sub((n >> 6 & 0x3F) + 1, (n >> 6 & 0x3F) + 1) or "="
out[#out + 1] = b3 and B64_CHARS:sub((n & 0x3F) + 1, (n & 0x3F) + 1) or "="
i = i + 3
end
return table.concat(out)
end
function M.base64_decode(s)
s = s:gsub("[^A-Za-z0-9+/=]", "")
local out = {}
local i = 1
local len = #s
while i <= len do
local c1 = B64_REV[s:sub(i, i)]
local c2 = B64_REV[s:sub(i + 1, i + 1)]
local s3, s4 = s:sub(i + 2, i + 2), s:sub(i + 3, i + 3)
local c3, c4 = B64_REV[s3], B64_REV[s4]
local n = (c1 << 18) | (c2 << 12) | ((c3 or 0) << 6) | (c4 or 0)
out[#out + 1] = string.char((n >> 16) & 0xFF)
if s3 ~= "=" and s3 ~= "" then out[#out + 1] = string.char((n >> 8) & 0xFF) end
if s4 ~= "=" and s4 ~= "" then out[#out + 1] = string.char(n & 0xFF) end
i = i + 4
end
return table.concat(out)
end
------------------------------------------------------------------------
-- Minimal pure-Lua JSON codec (no external dependencies).
-- M.omap(list) builds an object that encodes with a fixed, readable key
-- order instead of falling back to alphabetical sorting.
------------------------------------------------------------------------
local ORDER_KEY = "__order__"
function M.omap(pairsList)
local t = { [ORDER_KEY] = {} }
for _, kv in ipairs(pairsList) do
t[kv[1]] = kv[2]
table.insert(t[ORDER_KEY], kv[1])
end
return t
end
local function escape_str(s)
local out = {}
for i = 1, #s do
local b = string.byte(s, i)
if b == 34 then out[#out + 1] = '\\"'
elseif b == 92 then out[#out + 1] = "\\\\"
elseif b == 8 then out[#out + 1] = "\\b"
elseif b == 9 then out[#out + 1] = "\\t"
elseif b == 10 then out[#out + 1] = "\\n"
elseif b == 12 then out[#out + 1] = "\\f"
elseif b == 13 then out[#out + 1] = "\\r"
elseif b < 0x20 then out[#out + 1] = string.format("\\u%04x", b)
else out[#out + 1] = s:sub(i, i)
end
end
return table.concat(out)
end
local function array_len(t)
local n = 0
for k, _ in pairs(t) do
if k == ORDER_KEY then goto continue end
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return nil end
if k > n then n = k end
::continue::
end
for i = 1, n do if t[i] == nil then return nil end end
return n
end
local function encode_value(value, ind)
local t = type(value)
if value == nil then return "null" end
if t == "boolean" then return tostring(value) end
if t == "number" then
if value == math.floor(value) and math.abs(value) < 1e15 then
return string.format("%d", value)
end
return tostring(value)
end
if t == "string" then return '"' .. escape_str(value) .. '"' end
if t == "table" then
local nextIndent = ind .. " "
if value[ORDER_KEY] then
local keys = value[ORDER_KEY]
if #keys == 0 then return "{}" end
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = nextIndent .. '"' .. escape_str(k) .. '": ' .. encode_value(value[k], nextIndent)
end
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "}"
end
if next(value) == nil then return "[]" end
local n = array_len(value)
if n then
local parts = {}
for i = 1, n do parts[#parts + 1] = nextIndent .. encode_value(value[i], nextIndent) end
return "[\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "]"
end
local keys = {}
for k, _ in pairs(value) do keys[#keys + 1] = k end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = nextIndent .. '"' .. escape_str(tostring(k)) .. '": ' .. encode_value(value[k], nextIndent)
end
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. ind .. "}"
end
error("cannot JSON-encode a value of type " .. t)
end
function M.json_encode(value)
return encode_value(value, "")
end
local function skip_ws(s, i)
while i <= #s do
local c = s:sub(i, i)
if c == " " or c == "\t" or c == "\n" or c == "\r" then i = i + 1 else break end
end
return i
end
local parse_value
local function parse_string(s, i)
i = i + 1 -- opening quote
local out = {}
while true do
local c = s:sub(i, i)
if c == "" then error("unterminated string in JSON input") end
if c == '"' then i = i + 1; break end
if c == "\\" then
local e = s:sub(i + 1, i + 1)
if e == '"' then out[#out + 1] = '"'; i = i + 2
elseif e == "\\" then out[#out + 1] = "\\"; i = i + 2
elseif e == "/" then out[#out + 1] = "/"; i = i + 2
elseif e == "b" then out[#out + 1] = string.char(8); i = i + 2
elseif e == "f" then out[#out + 1] = string.char(12); i = i + 2
elseif e == "n" then out[#out + 1] = string.char(10); i = i + 2
elseif e == "r" then out[#out + 1] = string.char(13); i = i + 2
elseif e == "t" then out[#out + 1] = string.char(9); i = i + 2
elseif e == "u" then
local cp = tonumber(s:sub(i + 2, i + 5), 16)
i = i + 6
if cp >= 0xD800 and cp <= 0xDBFF and s:sub(i, i + 1) == "\\u" then
local cp2 = tonumber(s:sub(i + 2, i + 5), 16)
if cp2 and cp2 >= 0xDC00 and cp2 <= 0xDFFF then
cp = 0x10000 + (cp - 0xD800) * 0x400 + (cp2 - 0xDC00)
i = i + 6
end
end
out[#out + 1] = utf8.char(cp)
else
error("invalid escape sequence in JSON string: \\" .. e)
end
else
out[#out + 1] = c
i = i + 1
end
end
return table.concat(out), i
end
local function parse_number(s, i)
local j = i
if s:sub(j, j) == "-" then j = j + 1 end
while s:sub(j, j):match("%d") do j = j + 1 end
if s:sub(j, j) == "." then
j = j + 1
while s:sub(j, j):match("%d") do j = j + 1 end
end
if s:sub(j, j) == "e" or s:sub(j, j) == "E" then
j = j + 1
if s:sub(j, j) == "+" or s:sub(j, j) == "-" then j = j + 1 end
while s:sub(j, j):match("%d") do j = j + 1 end
end
return tonumber(s:sub(i, j - 1)), j
end
parse_value = function(s, i)
i = skip_ws(s, i)
local c = s:sub(i, i)
if c == '"' then return parse_string(s, i) end
if c == "{" then
i = skip_ws(s, i + 1)
local t = {}
if s:sub(i, i) == "}" then return t, i + 1 end
while true do
i = skip_ws(s, i)
if s:sub(i, i) ~= '"' then error("expected string key in JSON object at position " .. i) end
local key, ni = parse_string(s, i)
i = skip_ws(s, ni)
if s:sub(i, i) ~= ":" then error("expected ':' in JSON object at position " .. i) end
local val, ni2 = parse_value(s, i + 1)
t[key] = val
i = skip_ws(s, ni2)
local cc = s:sub(i, i)
if cc == "," then i = i + 1
elseif cc == "}" then i = i + 1; break
else error("expected ',' or '}' in JSON object at position " .. i) end
end
return t, i
end
if c == "[" then
i = skip_ws(s, i + 1)
local t = {}
if s:sub(i, i) == "]" then return t, i + 1 end
local n = 0
while true do
local val, ni = parse_value(s, i)
n = n + 1
t[n] = val
i = skip_ws(s, ni)
local cc = s:sub(i, i)
if cc == "," then i = i + 1
elseif cc == "]" then i = i + 1; break
else error("expected ',' or ']' in JSON array at position " .. i) end
end
return t, i
end
if s:sub(i, i + 3) == "true" then return true, i + 4 end
if s:sub(i, i + 4) == "false" then return false, i + 5 end
if s:sub(i, i + 3) == "null" then return nil, i + 4 end
local num, ni = parse_number(s, i)
if num == nil then error("invalid JSON at position " .. i .. ": " .. s:sub(i, i + 10)) end
return num, ni
end
function M.json_decode(s)
return (parse_value(s, 1))
end
------------------------------------------------------------------------
-- Top-level save <-> table conversion
------------------------------------------------------------------------
local function is_yellow(buf)
local starter = get(buf, M.OFS.Starter)
if starter ~= 0 then return starter == 0x54 end -- 0x54 = internal species ID for Pikachu
return get(buf, M.OFS.PikaFriendship) ~= 0
end
-- Quick structural sanity check equivalent to SaveUtil's HasListAt/IsListValidG12.
function M.looks_like_gen1_international_save(buf)
if #buf ~= M.SIZE_SAVE then return false end
local function has_list_at(ofs, maxCount)
local count = get(buf, ofs)
return count <= maxCount and get(buf, ofs + 1 + count) == 0xFF
end
return has_list_at(M.OFS.Party, 6) and has_list_at(M.OFS.CurrentBox, M.BOX_SLOT_COUNT)
end
-- Parses a raw 32768-byte Gen 1 save (as a byte array) into a JSON-friendly table.
function M.parse_save(buf)
if not M.looks_like_gen1_international_save(buf) then
error("this does not look like an international Gen 1 (Red/Blue/Yellow) save file (expected a 32768-byte file with valid party/box list headers)")
end
local currentBoxZero = get(buf, M.OFS.CurrentBoxIndex) & 0x7F
local boxesInitialized = (get(buf, M.OFS.CurrentBoxIndex) & 0x80) ~= 0
local playTimeOfs = M.OFS.PlayTime
local trainer = M.omap({
{ "name", M.decode_string(buf, M.OFS.OT, M.STRING_LENGTH) },
{ "id", read_u16be(buf, M.OFS.TID16) },
{ "rival_name", M.decode_string(buf, M.OFS.Rival, M.STRING_LENGTH) },
{ "money", bcd_read(buf, M.OFS.Money, 3, false) },
{ "coins", bcd_read(buf, M.OFS.Coin, 2, false) },
{ "badges", get(buf, M.OFS.Badges) },
{ "options", get(buf, M.OFS.Options) },
{ "starter", get(buf, M.OFS.Starter) },
{ "pikachu_friendship", get(buf, M.OFS.PikaFriendship) },
{ "pikachu_beach_score", bcd_read(buf, M.OFS.PikaBeachScore, 2, true) },
{ "play_time", M.omap({
{ "hours", get(buf, playTimeOfs) },
{ "minutes", get(buf, playTimeOfs + 2) },
{ "seconds", get(buf, playTimeOfs + 3) },
{ "frames", get(buf, playTimeOfs + 4) },
{ "maxed_out", get(buf, playTimeOfs + 1) ~= 0 },
}) },
})
local boxes = {}
for boxIndexZero = 0, M.BOX_COUNT - 1 do
local absBase = (boxIndexZero == currentBoxZero) and M.OFS.CurrentBox or M.box_bank_offset(boxIndexZero)
boxes[#boxes + 1] = M.omap({
{ "box", boxIndexZero + 1 },
{ "pokemon", parse_mon_list(buf, absBase, M.BOX_SLOT_COUNT, M.SIZE_STORED, false) },
})
end
return M.omap({
{ "format", "gen1" },
{ "region", "international" },
{ "version_guess", is_yellow(buf) and "yellow" or "red_blue" },
{ "trainer", trainer },
{ "current_box", currentBoxZero + 1 },
{ "boxes_initialized", boxesInitialized },
{ "party", parse_mon_list(buf, M.OFS.Party, 6, M.SIZE_PARTY, true) },
{ "boxes", boxes },
{ "raw_base64", M.base64_encode(M.bytes_to_string(buf)) },
})
end
-- Builds a raw 32768-byte Gen 1 save (as a byte array) from a JSON-decoded table.
-- `data.raw_base64` (as produced by parse_save) is required and used as the base
-- buffer, so that anything not modeled above (items, Pokedex flags, event flags,
-- Hall of Fame, etc.) survives the round trip unmodified.
function M.build_save(data)
if not data.raw_base64 or data.raw_base64 == "" then
error("JSON is missing required field 'raw_base64' (the original save's base data)")
end
local buf = M.string_to_bytes(M.base64_decode(data.raw_base64))
if #buf ~= M.SIZE_SAVE then
error(string.format("decoded raw_base64 is %d bytes, expected %d", #buf, M.SIZE_SAVE))
end
local trainer = data.trainer or {}
M.encode_string(buf, M.OFS.OT, M.STRING_LENGTH, trainer.name)
write_u16be(buf, M.OFS.TID16, trainer.id or 0)
M.encode_string(buf, M.OFS.Rival, M.STRING_LENGTH, trainer.rival_name)
bcd_write(buf, M.OFS.Money, 3, false, trainer.money or 0)
bcd_write(buf, M.OFS.Coin, 2, false, trainer.coins or 0)
set(buf, M.OFS.Badges, trainer.badges or 0)
set(buf, M.OFS.Options, trainer.options or 0)
set(buf, M.OFS.Starter, trainer.starter or 0)
set(buf, M.OFS.PikaFriendship, trainer.pikachu_friendship or 0)
bcd_write(buf, M.OFS.PikaBeachScore, 2, true, trainer.pikachu_beach_score or 0)
local playTime = trainer.play_time or {}
set(buf, M.OFS.PlayTime, playTime.hours or 0)
set(buf, M.OFS.PlayTime + 1, playTime.maxed_out and 1 or 0)
set(buf, M.OFS.PlayTime + 2, playTime.minutes or 0)
set(buf, M.OFS.PlayTime + 3, playTime.seconds or 0)
set(buf, M.OFS.PlayTime + 4, playTime.frames or 0)
write_mon_list(buf, M.OFS.Party, 6, M.SIZE_PARTY, true, data.party or {})
local currentBoxZero = (data.current_box or 1) - 1
if currentBoxZero < 0 or currentBoxZero >= M.BOX_COUNT then
error("current_box must be between 1 and " .. M.BOX_COUNT)
end
local boxes = data.boxes or {}
for boxIndexZero = 0, M.BOX_COUNT - 1 do
local boxEntry = boxes[boxIndexZero + 1]
local pokemon = boxEntry and boxEntry.pokemon or {}
local absBase = M.box_bank_offset(boxIndexZero)
write_mon_list(buf, absBase, M.BOX_SLOT_COUNT, M.SIZE_STORED, false, pokemon)
end
-- Mirror the current box's freshly-written bytes into the "live" buffer
-- (SAV1.cs GetFinalData()), and mark boxes as initialized.
local curBankOfs = M.box_bank_offset(currentBoxZero)
for i = 0, M.SIZE_BOX_LIST - 1 do
set(buf, M.OFS.CurrentBox + i, get(buf, curBankOfs + i))
end
set(buf, M.OFS.CurrentBoxIndex, (currentBoxZero & 0x7F) | 0x80)
set(buf, M.OFS.ChecksumOfs, compute_checksum(buf))
return M.bytes_to_string(buf, M.SIZE_SAVE)
end
return M
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env lua
-- json2sav.lua <input.json> [output.sav]
--
-- Converts a JSON file (as produced by sav2json.lua, optionally hand-edited)
-- back into a binary Pokemon Red/Blue/Yellow save file, recomputing the
-- save's checksum. See README.md for details and limitations.
local scriptDir = arg[0]:match("^(.*)[/\\]") or "."
local gen1 = dofile(scriptDir .. "/gen1lib.lua")
local input = arg[1]
if not input then
io.stderr:write("usage: lua json2sav.lua <input.json> [output.sav]\n")
os.exit(1)
end
local output = arg[2] or (input:gsub("%.[^.]*$", "") .. ".sav")
local f, err = io.open(input, "rb")
if not f then
io.stderr:write("error: could not open '" .. input .. "': " .. tostring(err) .. "\n")
os.exit(1)
end
local text = f:read("a")
f:close()
local ok, data = pcall(gen1.json_decode, text)
if not ok then
io.stderr:write("error: invalid JSON: " .. tostring(data) .. "\n")
os.exit(1)
end
local ok2, savBytes = pcall(gen1.build_save, data)
if not ok2 then
io.stderr:write("error: " .. tostring(savBytes) .. "\n")
os.exit(1)
end
local out, werr = io.open(output, "wb")
if not out then
io.stderr:write("error: could not write '" .. output .. "': " .. tostring(werr) .. "\n")
os.exit(1)
end
out:write(savBytes)
out:close()
print("wrote " .. output .. " (" .. #savBytes .. " bytes)")
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env lua
-- sav2json.lua <input.sav> [output.json]
--
-- Converts an international (non-Japanese) Pokemon Red/Blue/Yellow save
-- file into a human-editable JSON file. See README.md for details and
-- limitations, and json2sav.lua for the reverse direction.
local scriptDir = arg[0]:match("^(.*)[/\\]") or "."
local gen1 = dofile(scriptDir .. "/gen1lib.lua")
local input = arg[1]
if not input then
io.stderr:write("usage: lua sav2json.lua <input.sav> [output.json]\n")
os.exit(1)
end
local output = arg[2] or (input:gsub("%.[^.]*$", "") .. ".json")
local f, err = io.open(input, "rb")
if not f then
io.stderr:write("error: could not open '" .. input .. "': " .. tostring(err) .. "\n")
os.exit(1)
end
local raw = f:read("a")
f:close()
local buf = gen1.string_to_bytes(raw)
local ok, result = pcall(gen1.parse_save, buf)
if not ok then
io.stderr:write("error: " .. tostring(result) .. "\n")
os.exit(1)
end
local json = gen1.json_encode(result)
local out, werr = io.open(output, "wb")
if not out then
io.stderr:write("error: could not write '" .. output .. "': " .. tostring(werr) .. "\n")
os.exit(1)
end
out:write(json)
out:write("\n")
out:close()
print("wrote " .. output)