mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
new launcher and save converts and pipeline
This commit is contained in:
@@ -214,8 +214,21 @@ jobs:
|
|||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
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"
|
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
|
- name: Publish GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -266,7 +279,9 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
"dist/release/gen1recomp-${v}-linux.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"
|
echo "Published release $tag"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout:
|
|||||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
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
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
|
|||||||
+126
@@ -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.
|
||||||
@@ -56,6 +56,12 @@ local function bootGame(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.load(args)
|
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
|
local savePath
|
||||||
for i, a in ipairs(args or {}) do
|
for i, a in ipairs(args or {}) do
|
||||||
if a == "--editor" then
|
if a == "--editor" then
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "BALANCE",
|
"category": "BALANCE",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "TOOL",
|
"category": "TOOL",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "AUDIO",
|
"category": "AUDIO",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"permissions": ["engine_internals"],
|
"permissions": ["engine_internals"],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "QUEST",
|
"category": "QUEST",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"permissions": ["engine_internals"],
|
"permissions": ["engine_internals"],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "total_conversion",
|
"profile": "total_conversion",
|
||||||
"category": "TOTAL_CONVERSION",
|
"category": "TOTAL_CONVERSION",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 900,
|
"priority": 900,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
"category": "GRAPHICS",
|
"category": "GRAPHICS",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"assets_transforms": "transforms.lua",
|
"assets_transforms": "transforms.lua",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "overhaul",
|
"profile": "overhaul",
|
||||||
"category": "MECHANIC",
|
"category": "MECHANIC",
|
||||||
"game_version": ">=1.0.0 <2.0.0",
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"optional_dependencies": [],
|
"optional_dependencies": [],
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \
|
|||||||
fi
|
fi
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
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
|
# --------------------------------------------------------------- macOS
|
||||||
build_mac() {
|
build_mac() {
|
||||||
say "building macOS app"
|
say "building macOS app"
|
||||||
|
|||||||
@@ -145,6 +145,32 @@ pack_game_love() {
|
|||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
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
|
# --------------------------------------------------------------- SDK check
|
||||||
|
|||||||
+105
-20
@@ -173,6 +173,14 @@ local function grayImage(img)
|
|||||||
return getImage(meta.path) or img
|
return getImage(meta.path) or img
|
||||||
end
|
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
|
-- the image a battler pic actually draws with this frame
|
||||||
function BattleState:picImage(img)
|
function BattleState:picImage(img)
|
||||||
if self.grayPics then return grayImage(img) end
|
if self.grayPics then return grayImage(img) end
|
||||||
@@ -3449,12 +3457,15 @@ function BattleState:fxFaintActive(battler)
|
|||||||
and fx.faint.frames > 0 or false
|
and fx.faint.frames > 0 or false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vertical slide offset for a fainting battler (the player's pic is
|
-- vertical slide offset for a fainting battler. The offset is in screen
|
||||||
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
|
-- pixels, so it scales with the pic's draw scale (the player's default 2x
|
||||||
function BattleState:fxFaintOffset(battler)
|
-- 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
|
local fx = self.fx
|
||||||
if self:fxFaintActive(battler) then
|
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
|
end
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
@@ -3550,7 +3561,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self:fxFaintActive(battler) then
|
if self:fxFaintActive(battler) then
|
||||||
local off = self:fxFaintOffset(battler)
|
local off = self:fxFaintOffset(battler, scale)
|
||||||
local visible = img:getHeight() - math.floor(off / scale)
|
local visible = img:getHeight() - math.floor(off / scale)
|
||||||
if visible > 0 then
|
if visible > 0 then
|
||||||
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
||||||
@@ -3847,6 +3858,63 @@ function BattleState:drawAnimLayer(colorized)
|
|||||||
end
|
end
|
||||||
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
|
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
||||||
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
||||||
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
-- 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)
|
local img = self:picImage(self.enemy.sprite)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
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)
|
local gs = self:growInScale(self.enemy)
|
||||||
if gs then
|
if gs then
|
||||||
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
||||||
-- and horizontal center pinned to the mon's slot while it grows
|
-- and horizontal center pinned to the mon's slot while it grows --
|
||||||
if gs > 0 then
|
-- the mod scale composes multiplicatively with the grow stage
|
||||||
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
|
local eff = s * gs
|
||||||
ey + img:getHeight() * (1 - gs), 0, gs, 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
|
end
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3913,9 +3988,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
local img = self:picImage(self.playerBackPic)
|
local img = self:picImage(self.playerBackPic)
|
||||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||||
local padL = imagePadLeft[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.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
|
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
|
||||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
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
|
elseif self.player and self.player.sprite and not hidePlayer
|
||||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||||
local img = self:picImage(self.player.sprite)
|
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
|
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
||||||
local pad = imagePadBottom[self.player.sprite] or 0
|
local pad = imagePadBottom[self.player.sprite] or 0
|
||||||
local padL = imagePadLeft[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)
|
local gs = self:growInScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||||
if gs > 0 then
|
-- pinned, mod scale composed with the grow stage
|
||||||
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
|
local eff = s * gs
|
||||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
if eff > 0 then
|
||||||
0, 2 * gs, 2 * gs)
|
love.graphics.draw(img,
|
||||||
|
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||||
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self:drawBattlerPic(self.player, px,
|
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
||||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
img:getHeight(), pad, padL, s)
|
||||||
|
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if clipped then
|
if clipped then
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function Game:load()
|
|||||||
self.mods = ModLoader.new()
|
self.mods = ModLoader.new()
|
||||||
self.mods:load(Data)
|
self.mods:load(Data)
|
||||||
self.modStatus = self.mods:status()
|
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
|
self.input = Input
|
||||||
Input:init()
|
Input:init()
|
||||||
@@ -198,6 +201,9 @@ function Game:update(dt)
|
|||||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||||
require("src.render.Tilt").update(dt)
|
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)
|
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||||
-- Steady-state memory backstop: advance the incremental collector one
|
-- Steady-state memory backstop: advance the incremental collector one
|
||||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||||
@@ -341,6 +347,17 @@ function Game:keypressed(key)
|
|||||||
self:writeOptions()
|
self:writeOptions()
|
||||||
return
|
return
|
||||||
end
|
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)
|
Input:keypressed(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -457,6 +474,9 @@ function Game:applyOptions(opts)
|
|||||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||||
require("src.render.PaletteFX").applyOptions(opts)
|
require("src.render.PaletteFX").applyOptions(opts)
|
||||||
require("src.render.Tilt").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.Zoom").applyOptions(opts)
|
||||||
require("src.render.TileRenderer").applyOptions(opts)
|
require("src.render.TileRenderer").applyOptions(opts)
|
||||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||||
|
|||||||
+284
-4
@@ -17,6 +17,7 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Boxes = require("src.pokemon.Boxes")
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
|
local Badges = require("src.inventory.Badges")
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
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
|
-- 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;
|
-- 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.
|
-- load promotes either when the main file is missing or fails to parse.
|
||||||
local function saveNames(version)
|
-- Forward-declared here so saveFilename (just below) and every save/load
|
||||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
-- caller share the one upvalue; the body is filled in under "save slots"
|
||||||
return main, main .. ".bak", main .. ".tmp"
|
-- once the options IO it depends on exists, because it now resolves the
|
||||||
end
|
-- 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
|
-- The main save filename for a version -- used by the title screen's
|
||||||
-- CONTINUE gate so it looks for the right game's save.
|
-- CONTINUE gate so it looks for the right game's save.
|
||||||
@@ -91,6 +93,18 @@ local function makePortableFs(dir)
|
|||||||
os.remove(full(name))
|
os.remove(full(name))
|
||||||
return true
|
return true
|
||||||
end,
|
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
|
end
|
||||||
|
|
||||||
@@ -198,6 +212,11 @@ function SaveData.defaultOptions()
|
|||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
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.
|
-- Native mod enablement is an installation option, not save-slot data.
|
||||||
-- Missing entries mean enabled so newly installed mods work by default.
|
-- Missing entries mean enabled so newly installed mods work by default.
|
||||||
mods = {},
|
mods = {},
|
||||||
@@ -283,6 +302,264 @@ function SaveData.loadOptions(fs)
|
|||||||
return SaveData.mergeOptions(data)
|
return SaveData.mergeOptions(data)
|
||||||
end
|
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
|
-- ------- meta
|
||||||
|
|
||||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||||
@@ -525,6 +802,9 @@ function SaveData.save(data, mods)
|
|||||||
end
|
end
|
||||||
local encoded = SaveSerializer.encode(gameOnly)
|
local encoded = SaveSerializer.encode(gameOnly)
|
||||||
local fs = persistFs(nil)
|
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
|
if fs.getInfo(FILENAME) then
|
||||||
local prev = fs.read(FILENAME)
|
local prev = fs.read(FILENAME)
|
||||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||||
|
|||||||
+10
-2
@@ -4,14 +4,22 @@
|
|||||||
-- tests.
|
-- tests.
|
||||||
|
|
||||||
local Version = {
|
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`)
|
modApi = 2, -- mod API major (manifest `api`)
|
||||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||||
saveFormat = 4, -- save.meta.format
|
saveFormat = 4, -- save.meta.format
|
||||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
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)
|
function Version.title(base)
|
||||||
return (base or "gen1recomp")
|
return (base or "gen1recomp")
|
||||||
.. " v" .. Version.engine
|
.. " v" .. Version.engine
|
||||||
|
|||||||
+1476
-270
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
||||||
@@ -40,6 +40,19 @@ function f.int(min, max)
|
|||||||
end }
|
end }
|
||||||
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)
|
function f.enum(values)
|
||||||
local set = {}
|
local set = {}
|
||||||
for _, value in ipairs(values) do set[value] = true end
|
for _, value in ipairs(values) do set[value] = true end
|
||||||
@@ -420,6 +433,12 @@ R.pokemon = {
|
|||||||
frames = f.opt(f.int(1)) } }),
|
frames = f.opt(f.int(1)) } }),
|
||||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||||
trueColor = f.opt(f.bool),
|
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 } })',
|
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 })',
|
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
|
-- ------- progression
|
||||||
|
|
||||||
R.evolution_methods = {
|
R.evolution_methods = {
|
||||||
|
|||||||
@@ -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
|
||||||
+75
-3
@@ -9,12 +9,28 @@
|
|||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
|
|
||||||
Renderer.WIDTH = 160
|
Renderer.WIDTH = 160
|
||||||
Renderer.HEIGHT = 144
|
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
|
-- 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
|
-- 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
|
-- 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.
|
-- the projected ground in endFrame; never touched while tilt is off.
|
||||||
self.uprightCanvas = nil
|
self.uprightCanvas = nil
|
||||||
self.uprightActive = false
|
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
|
end
|
||||||
|
|
||||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||||
@@ -91,6 +123,7 @@ end
|
|||||||
function Renderer:beginFrame(transparent)
|
function Renderer:beginFrame(transparent)
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
|
self.worldOverride = nil
|
||||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||||
-- a popped transition cannot leave a sticky black veil
|
-- a popped transition cannot leave a sticky black veil
|
||||||
self.worldFadeAlpha = nil
|
self.worldFadeAlpha = nil
|
||||||
@@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
zones = withTrueColor(zones, "ui")
|
zones = withTrueColor(zones, "ui")
|
||||||
worldZones = withTrueColor(worldZones, "world")
|
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
|
local present = nil
|
||||||
if needPresent then
|
if needPresent then
|
||||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||||
@@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setShader()
|
love.graphics.setShader()
|
||||||
end
|
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 sp = Zoom.scale(Sp)
|
||||||
local s = sp / dpi
|
local s = sp / dpi
|
||||||
local wvw = self.worldCanvas:getWidth()
|
local wvw = self.worldCanvas:getWidth()
|
||||||
@@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
|
|
||||||
if present then
|
if present then
|
||||||
love.graphics.setCanvas()
|
love.graphics.setCanvas()
|
||||||
|
-- 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
|
-- shader grid/shadow math is in framebuffer pixels
|
||||||
GBCFX.present(present, Sp)
|
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
|
end
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
|
self.worldOverride = nil
|
||||||
PaletteFX.setPass(nil)
|
PaletteFX.setPass(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ end
|
|||||||
|
|
||||||
Assets.register(SpriteRenderer.invalidate)
|
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 STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
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
|
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||||
@@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed)
|
|||||||
return self
|
return self
|
||||||
end
|
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
|
-- 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).
|
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
|
|||||||
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||||
end
|
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 [+
|
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
||||||
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
||||||
local shiftVariants = {}
|
local shiftVariants = {}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
["0"] = 246,
|
||||||
|
["1"] = 247,
|
||||||
|
["2"] = 248,
|
||||||
|
["3"] = 249,
|
||||||
|
["4"] = 250,
|
||||||
|
["5"] = 251,
|
||||||
|
["6"] = 252,
|
||||||
|
["7"] = 253,
|
||||||
|
["8"] = 254,
|
||||||
|
["9"] = 255,
|
||||||
|
["?"] = 230,
|
||||||
|
["゙"] = 229,
|
||||||
|
["゚"] = 228,
|
||||||
|
},
|
||||||
|
source = "pokered constants/charmap.asm",
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
-- bottom line like pokered's.
|
-- bottom line like pokered's.
|
||||||
|
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
@@ -193,6 +194,15 @@ local function buildRows(game)
|
|||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
|
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
|
||||||
Tilt.setLevel(o.tilt)
|
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
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "gbcfx", label = "GBC FX",
|
{ id = "gbcfx", label = "GBC FX",
|
||||||
@@ -295,6 +305,26 @@ local function buildRows(game)
|
|||||||
end
|
end
|
||||||
rows = filtered
|
rows = filtered
|
||||||
end
|
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
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -101,9 +101,18 @@ function NPC:walkPhase()
|
|||||||
return (p >= 4 and p < 12) and 1 or 0
|
return (p >= 4 and p < 12) and 1 or 0
|
||||||
end
|
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)
|
function NPC:draw(camX, camY)
|
||||||
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
|
local sprite, px, py, facing, phase, flip = self:pose()
|
||||||
self:walkPhase(), self.stepFlip)
|
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||||
end
|
end
|
||||||
|
|
||||||
return NPC
|
return NPC
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ local Map = require("src.world.Map")
|
|||||||
local MapLoader = require("src.world.MapLoader")
|
local MapLoader = require("src.world.MapLoader")
|
||||||
local NPC = require("src.world.NPC")
|
local NPC = require("src.world.NPC")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
local Player = require("src.world.Player")
|
local Player = require("src.world.Player")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
@@ -20,6 +21,7 @@ local Tilt = require("src.render.Tilt")
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Transition = require("src.render.Transition")
|
local Transition = require("src.render.Transition")
|
||||||
local Warp = require("src.world.Warp")
|
local Warp = require("src.world.Warp")
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
|
||||||
-- isOverworld marks the live world state for WorldAPI's stack scan
|
-- isOverworld marks the live world state for WorldAPI's stack scan
|
||||||
local OverworldState = { isOpaque = true, isOverworld = true }
|
local OverworldState = { isOpaque = true, isOverworld = true }
|
||||||
@@ -3539,12 +3541,23 @@ function OverworldState:drawWorld()
|
|||||||
-- tilt is active). So the ground draw calls below never change with tilt;
|
-- 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
|
-- only the sprite/FX draw path below them branches. The sorts below only
|
||||||
-- reorder (no draws), so they run once for both paths.
|
-- reorder (no draws), so they run once for both paths.
|
||||||
local tilt = Tilt.active()
|
-- 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:drawBorderFill(cam.x, bgY, vw, vh)
|
||||||
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
||||||
for _, nb in ipairs(self.neighbors) do
|
for _, nb in ipairs(self.neighbors) do
|
||||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
-- per-billboard SGB palette source; only needed (and only paid for) when
|
-- per-billboard SGB palette source; only needed (and only paid for) when
|
||||||
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
||||||
local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil
|
local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil
|
||||||
@@ -3774,7 +3787,114 @@ function OverworldState:drawWorld()
|
|||||||
end
|
end
|
||||||
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 =====
|
-- === FLAT PATH: everything into the one world canvas, as before =====
|
||||||
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
|
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
|
||||||
-- grass feet-overdraw must replay over them too, colorized with the
|
-- grass feet-overdraw must replay over them too, colorized with the
|
||||||
|
|||||||
+34
-16
@@ -140,28 +140,29 @@ end
|
|||||||
|
|
||||||
local SPIN_ORDER = { "down", "left", "up", "right" }
|
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
|
local py = self.py
|
||||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
|
local hopping = false
|
||||||
-- hopFrames counts down in Player:update (fixed step), never here.
|
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
||||||
if self.hopFrames and self.hopFrames > 0 then
|
if self.hopFrames and self.hopFrames > 0 then
|
||||||
local total = self.hopTotal or 32
|
local total = self.hopTotal or 32
|
||||||
-- update runs before draw, so remaining N means N steps already
|
-- update runs before draw, so remaining N means N steps already
|
||||||
-- consumed this hop → t matches the old draw-side post-decrement phase
|
-- consumed this hop → t matches the old draw-side post-decrement phase
|
||||||
local t = 1 - self.hopFrames / total
|
local t = 1 - self.hopFrames / total
|
||||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
hopping = true
|
||||||
-- 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
|
|
||||||
elseif self.surfing then
|
elseif self.surfing then
|
||||||
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
|
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
|
||||||
py = py + (self.bobTimer < 16 and 0 or 1)
|
py = py + (self.bobTimer < 16 and 0 or 1)
|
||||||
@@ -186,7 +187,24 @@ function Player:draw(camX, camY)
|
|||||||
end
|
end
|
||||||
local sprite = (self.surfing and self.surfSprite)
|
local sprite = (self.surfing and self.surfSprite)
|
||||||
or (self.onBike and self.bikeSprite) or self.sprite
|
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
|
end
|
||||||
|
|
||||||
return Player
|
return Player
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -28,6 +28,17 @@ end
|
|||||||
|
|
||||||
local files = {} -- in-memory love.filesystem
|
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 = {
|
stub.graphics = {
|
||||||
newImage = function(path)
|
newImage = function(path)
|
||||||
local w, h = pngSize(path)
|
local w, h = pngSize(path)
|
||||||
@@ -44,13 +55,37 @@ stub.graphics = {
|
|||||||
function batch:setTexture(tex) self.texture = tex end
|
function batch:setTexture(tex) self.texture = tex end
|
||||||
return batch
|
return batch
|
||||||
end,
|
end,
|
||||||
draw = noop, rectangle = noop, setColor = noop, clear = noop,
|
draw = noop, rectangle = noop, clear = noop,
|
||||||
setCanvas = noop, setDefaultFilter = noop, print = 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
|
-- 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)
|
-- in their own recorders, e.g. tests/parity_tilt.lua)
|
||||||
push = noop, pop = noop, translate = noop, scale = noop,
|
push = function()
|
||||||
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
|
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,
|
getDimensions = function() return 640, 576 end,
|
||||||
-- dpi=1 desktop default; issue #87 tests override these for Android density
|
-- dpi=1 desktop default; issue #87 tests override these for Android density
|
||||||
getPixelDimensions = function() return 640, 576 end,
|
getPixelDimensions = function() return 640, 576 end,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -160,7 +160,7 @@ check(not pcall(Manifest.validate, {
|
|||||||
local versionLoader = Loader.new({ fs = memfs({
|
local versionLoader = Loader.new({ fs = memfs({
|
||||||
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
|
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
|
||||||
["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end",
|
["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,
|
["mods/current/main.lua"] = NOOP,
|
||||||
}) })
|
}) })
|
||||||
local versionData = { items = {} }
|
local versionData = { items = {} }
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -4,6 +4,7 @@ local Registry = require("src.mods.Registry")
|
|||||||
local Events = require("src.mods.Events")
|
local Events = require("src.mods.Events")
|
||||||
local Hooks = require("src.mods.Hooks")
|
local Hooks = require("src.mods.Hooks")
|
||||||
local Manifest = require("src.mods.Manifest")
|
local Manifest = require("src.mods.Manifest")
|
||||||
|
local Semver = require("src.mods.Semver")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
@@ -141,8 +142,8 @@ check(manifest.id == "test_mod" and manifest.path == "mods/test_mod",
|
|||||||
"manifest validation")
|
"manifest validation")
|
||||||
|
|
||||||
check(type(Version.engine) == "string"
|
check(type(Version.engine) == "string"
|
||||||
and Version.engine:match("^%d+%.%d+%.%d+$") ~= nil,
|
and Semver.parse(Version.engine) ~= nil,
|
||||||
"engine version is a semver triple")
|
"engine version parses as a semver (triple, optionally with a pre-release)")
|
||||||
check(Version.modApi == 2, "mod api version is 2")
|
check(Version.modApi == 2, "mod api version is 2")
|
||||||
check(Version.title("X") == "X v" .. Version.engine,
|
check(Version.title("X") == "X v" .. Version.engine,
|
||||||
"window title carries the engine version")
|
"window title carries the engine version")
|
||||||
|
|||||||
@@ -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_constants_tests.lua", "tests/mod_catalog_tests.lua",
|
||||||
"tests/mod_audio_tests.lua", "tests/mod_world_tests.lua",
|
"tests/mod_audio_tests.lua", "tests/mod_world_tests.lua",
|
||||||
"tests/mod_battle_tests.lua", "tests/mod_graphics_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_scripting_tests.lua", "tests/mod_ui_tests.lua",
|
||||||
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
|
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -113,7 +113,7 @@ def engine_version(repo):
|
|||||||
src = open(os.path.join(repo, "src", "core", "Version.lua"),
|
src = open(os.path.join(repo, "src", "core", "Version.lua"),
|
||||||
encoding="utf-8").read()
|
encoding="utf-8").read()
|
||||||
match = re.search(r'engine\s*=\s*"([^"]+)"', src)
|
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):
|
def known_permissions(repo):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
Reference in New Issue
Block a user