Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e7b9c0848 | |||
| 116a6ba450 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| ea2cd63395 | |||
| 70d7b6383e | |||
| 5ec9ce5f88 | |||
| 087a275189 | |||
| 63b31d234c | |||
| 44f4680b24 | |||
| 06e06e305b | |||
| 464fb47756 | |||
| fe3746fd1a | |||
| 0e407dca7a | |||
| b0ff1552f2 | |||
| d191aaa34d | |||
| a1a70540b8 | |||
| 5c1837b1eb | |||
| bb0f156497 | |||
| 4b0496bad1 | |||
| a56add6d17 | |||
| 48b39c8519 | |||
| decaa006b2 | |||
| c0bd4a35be | |||
| 8c0d0ace4d | |||
| c11c762f15 | |||
| c465f58006 | |||
| 780246c4f6 | |||
| 5714555847 | |||
| 738f7317d7 | |||
| 5e514335c1 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| f63707c45b | |||
| 7756fdc3cb | |||
| 4bdb9435a4 | |||
| 5cbde96177 | |||
| 03c5a1eddf | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 911e11a372 | |||
| 2d28d18bf6 | |||
| 0b70d6c535 | |||
| 1e6613e2de | |||
| debfaf28e6 | |||
| 83463a5a59 | |||
| d66a72ac95 | |||
| 1b659dab01 | |||
| 9e01fe2c2c | |||
| 5fa5005786 | |||
| 1ac5b867bb | |||
| 69ef1bfc77 | |||
| 9ed7e05dc1 | |||
| 25166ff3a1 | |||
| c777e85641 | |||
| 06299328f5 | |||
| 5b19259928 | |||
| c2b6a7b937 | |||
| 7c9c2380d2 | |||
| 2468d5042d | |||
| 51c4766ead | |||
| ec9dc29646 | |||
| 934a4c55ca | |||
| 72592665d7 | |||
| 8f88d01cf2 | |||
| 2279617b29 | |||
| 17fbf6cec4 | |||
| 34c4481f96 | |||
| bff40a5d90 | |||
| 354a8b476d | |||
| f0d3c014a7 | |||
| 0dd889b35b | |||
| 032f894f7f | |||
| 9922e235c6 | |||
| 667267d9bb | |||
| ecdea61cfd | |||
| 872d6b4516 | |||
| def270f7c7 | |||
| 93e336b7cb | |||
| 4c8c1cf36b | |||
| 7d9e99ea18 | |||
| b27e5ab017 | |||
| fd9f3da91a | |||
| a7c19be88f | |||
| 9ab80adaca | |||
| 518d61e039 | |||
| 4349a1142f | |||
| 9713977755 | |||
| 63448ca640 | |||
| b36d38815f | |||
| e24f812475 | |||
| fddf619ed2 | |||
| bf83509ef2 | |||
| 6c05b854c4 | |||
| 2baafab027 | |||
| 813f9d959b | |||
| fba87f028c | |||
| cb4647daf0 | |||
| 93374fbbbb | |||
| abe176b26c | |||
| 085180992d | |||
| 9984958193 | |||
| 5871469002 | |||
| 302b2c9591 | |||
| 67a170fd6e | |||
| 66079686fc | |||
| cc5ff987ac | |||
| f8ba51636b | |||
| fb97318e87 | |||
| 580449b8df | |||
| fd73ab2a11 | |||
| 48f710c3d6 | |||
| 794a5fc6cb | |||
| 286988a1e3 | |||
| 48a3140a28 | |||
| f7bdaa81f8 | |||
| a1ab5e2cff | |||
| 99806ead25 | |||
| def967a8f8 | |||
| 7583ba8729 | |||
| fc83ecd52f | |||
| 5ba49bdf3f | |||
| abf95ce1c4 | |||
| bd1046f398 | |||
| d5ad830fb8 | |||
| c2c7fdafcf | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 25ec896545 | |||
| 8240205254 | |||
| 55616fc03d | |||
| 675971068e | |||
| cfa8406306 | |||
| 6588901e9a | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 7b1e796c48 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 4e1ab1879b | |||
| 917735a41c | |||
| c877ea80a7 | |||
| 7d1ddf9b7c | |||
| 5bc6036735 | |||
| 809628f0fa | |||
| 4817ff8bf9 | |||
| 4f8739c029 | |||
| 142d1358dd | |||
| c655217120 | |||
| fbdfc1c053 | |||
| 72c244b433 |
@@ -0,0 +1 @@
|
||||
* @bryanthaboi
|
||||
@@ -5,6 +5,10 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Turn off all mods before filing.** Disable everything in the launcher's MODS
|
||||
tab, confirm the bug still happens, then open this. Bugs that only show up with
|
||||
mods on belong with the mod author, not here.
|
||||
|
||||
A screenshot is worth more than any description. If you can grab one, grab one.
|
||||
If you genuinely can't, that's fine, but then the details below need to be thorough
|
||||
enough that someone can find the bug without ever seeing your screen.
|
||||
@@ -51,22 +55,24 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: mods_enabled
|
||||
- type: checkboxes
|
||||
id: mods_off
|
||||
attributes:
|
||||
label: Were any mods on
|
||||
description: Check the MODS tab in the launcher if you're not sure.
|
||||
label: Mods off
|
||||
description: >
|
||||
Turn off every mod in the launcher's MODS tab and reproduce the bug
|
||||
before submitting. Do not file vanilla bugs with mods still enabled.
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
- label: I turned off all mods and can still reproduce this
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: mods_which
|
||||
attributes:
|
||||
label: Which mods (if any were on)
|
||||
description: List the enabled mods. Leave blank if none were on.
|
||||
label: Which mods (if you first noticed this with any on)
|
||||
description: >
|
||||
Optional. If you originally hit this with mods enabled, list them —
|
||||
but only after you've confirmed it still happens with all of them off.
|
||||
placeholder: nuzlocke 1.0.0, running-shoes 0.3
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
name: Feature request
|
||||
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this for **engine / launcher / platform** work (ports, video options, save
|
||||
tooling, networking, mod API seams, docs).
|
||||
|
||||
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||
Gen 2-like battle toggles, soundtrack packs — open a
|
||||
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
|
||||
instead.
|
||||
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this about
|
||||
description: Pick every version it applies to. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live (launcher, options,
|
||||
engine), what does the player see or do. If it changes something that already
|
||||
exists, say what it does today and what it should do instead.
|
||||
placeholder: |
|
||||
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||
with the same save folder layout and mod discovery path.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing
|
||||
description: >
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||
it themselves and miss release notes / update checks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- GitHub Releases asset next to the .dmg / .exe
|
||||
- Same options.lua / mods/ layout as desktop
|
||||
- Documented in the README install section
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Does this change how the original game plays
|
||||
description: >
|
||||
Some requests are quality of life, some change the actual game. Both are fine,
|
||||
it just helps to know which one you're asking for.
|
||||
options:
|
||||
- Quality of life, original game is untouched
|
||||
- Changes how the game plays
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, how another game does it, related issues. Leave blank
|
||||
if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,130 +0,0 @@
|
||||
name: Mod request
|
||||
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
|
||||
labels: ["mod request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This tracker is for ideas that should ship as **mods**, not as core engine
|
||||
features — alternate sprites, running shoes, day/night, shiny indicators,
|
||||
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||
|
||||
The engine already exposes a lot of this through registries and hooks
|
||||
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
|
||||
Use a **Feature request** instead for launcher / ports / video options /
|
||||
networking / save tooling / new API seams.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What the mod should do, in a sentence.
|
||||
placeholder: Hold B to run at bike speed on the overworld
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this for
|
||||
description: Pick every version the mod should cover. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers or mod authors can ping you on Discord if they pick this up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What should the mod do
|
||||
description: >
|
||||
Describe the player-facing behavior. What changes, where, what does the
|
||||
player see or press. If it toggles from Options or a START-menu entry, say so.
|
||||
placeholder: |
|
||||
Hold B while walking outdoors to move at bike speed. Release to walk again.
|
||||
Same places the bike is allowed; no effect in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing as a mod
|
||||
description: >
|
||||
Why optional/modded rather than a core option. Who wants it on, who wants
|
||||
vanilla left alone.
|
||||
placeholder: |
|
||||
Great for replaying and backtracking, but some people want a strict Gen 1
|
||||
pace. A mod (or an opt-in mod option) keeps both camps happy.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
|
||||
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
|
||||
mention it — otherwise leave it open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld
|
||||
- Same step timing as the bike
|
||||
- Disabled where the bike is disabled
|
||||
- Prefer hooks:wrap("movement.speed") if that still fits
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: vanilla
|
||||
attributes:
|
||||
label: With the mod off, is vanilla unchanged
|
||||
options:
|
||||
- Yes — parity when disabled
|
||||
- No — it would replace something always-on
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Best-fit mod category
|
||||
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
|
||||
options:
|
||||
- GAMEPLAY / QoL
|
||||
- GRAPHICS
|
||||
- AUDIO
|
||||
- BALANCE / ruleset
|
||||
- CONTENT (maps, encounters, trainers)
|
||||
- UI / TOOL
|
||||
- TOTAL_CONVERSION-ish
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, other games/hacks that do it, related issues, or
|
||||
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -12,10 +12,11 @@ name: ci
|
||||
#
|
||||
on:
|
||||
push:
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
# (any base); this list is only for post-merge push runs.
|
||||
branches: [dev, main]
|
||||
# PRs into dev only: a dev -> main ship PR reuses the required checks the
|
||||
# dev push already put on the same head SHA, so it needs no second run.
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
|
||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||
concurrency:
|
||||
@@ -120,7 +121,7 @@ jobs:
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -151,6 +152,9 @@ jobs:
|
||||
luajit tests/engine/assets_version_fallback_test.lua
|
||||
luajit tests/engine/nx_generated_guard_test.lua
|
||||
luajit tests/engine/nx_yellow_boot_test.lua
|
||||
luajit tests/engine/cache_fs_gold_nx_load_test.lua
|
||||
luajit tests/engine/cache_fs_blue_mount_test.lua
|
||||
luajit tests/engine/switch_diagnostics_test.lua
|
||||
|
||||
switch-build:
|
||||
name: Switch fused build
|
||||
@@ -315,52 +319,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh --version 0.0.0
|
||||
# Shared with the release workflow so shipped images get the same
|
||||
# self-contained / glibc-floor checks as PR builds.
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -397,7 +359,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
||||
@@ -418,13 +379,8 @@ jobs:
|
||||
print(f"\n{len(paths)} fixture assets valid")
|
||||
PY
|
||||
|
||||
# the fingerprint golden is the parity tripwire; prove it still
|
||||
# matches the dataset on a clean checkout
|
||||
- name: fingerprint gate
|
||||
run: luajit tests/engine/gate_fingerprint.lua
|
||||
|
||||
- name: parity-guarantee meta-test
|
||||
run: luajit tests/engine/gate_meta_coverage.lua
|
||||
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
|
||||
# run in the headless job via run_engine; this job only guards the PNGs
|
||||
|
||||
# Only the differ is under test here, and the job is named for that. The
|
||||
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
||||
@@ -512,3 +468,24 @@ jobs:
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "no committed mods to lint"
|
||||
fi
|
||||
|
||||
luacheck:
|
||||
name: engine lint (luacheck)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: install luacheck
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||
luacheck --version
|
||||
|
||||
- name: luacheck gate (undefined globals, unreachable code)
|
||||
run: ./scripts/lint.sh --gate
|
||||
|
||||
- name: luacheck full report (advisory)
|
||||
continue-on-error: true
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
@@ -161,6 +161,8 @@ jobs:
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -285,7 +287,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -307,6 +309,15 @@ jobs:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
|
||||
# every release asset ships one identical payload (build.sh's own pack
|
||||
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: dist/payload
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -357,14 +368,36 @@ jobs:
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
|
||||
--game-love dist/payload/game.love
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
- name: Materialize Android release signing key
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||
[ -n "$KEYSTORE_B64" ] || {
|
||||
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
|
||||
exit 1
|
||||
}
|
||||
python3 - <<'PY'
|
||||
import base64, os, pathlib
|
||||
encoded = os.environ["KEYSTORE_B64"]
|
||||
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
|
||||
path.write_bytes(base64.b64decode(encoded, validate=True))
|
||||
PY
|
||||
|
||||
- name: Build Android
|
||||
env:
|
||||
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
|
||||
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
||||
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
@@ -486,8 +519,8 @@ jobs:
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
|
||||
@@ -23,6 +23,10 @@ read_globals = {
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
"POKEPORT_DISPLAY_COMPANION",
|
||||
"POKEPORT_EDITOR_MODE",
|
||||
"rawlen",
|
||||
package = { fields = { "searchers" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
@@ -30,6 +34,7 @@ exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
"tools/save_convert/vendor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
|
||||
@@ -140,17 +140,17 @@ ship text.
|
||||
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
||||
VM and save format. The mod API is shared across both generations (same hook
|
||||
names, same event names, same registry names) but Gold cannot serve all of it
|
||||
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
||||
overworld, script VM and save format. The mod API is shared across both
|
||||
generations (same hook names, same event names, same registry names) but Gen 2
|
||||
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||
|
||||
```json
|
||||
"games": ["gen1", "gen2"]
|
||||
```
|
||||
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
||||
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
@@ -159,10 +159,10 @@ gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold.
|
||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||
have actually run your mod on Gold or Silver.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
|
||||
when you mean everywhere.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||
|
||||
@@ -9,7 +9,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
||||
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
@@ -53,18 +53,17 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
|
||||
is the only game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
||||
Silver ROM is the only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -72,13 +71,14 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
||||
accepted. The importer verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -219,7 +219,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
@@ -332,7 +332,7 @@ Maps can be edited in our own build of [Tiled](https://www.mapeditor.org),
|
||||
and exported back out as a mod; see
|
||||
[docs/tiled-map-editing.md](docs/tiled-map-editing.md).
|
||||
|
||||
## Bugs and Ideas
|
||||
## Bugs
|
||||
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
@@ -341,12 +341,6 @@ original game.
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## More
|
||||
|
||||
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||
@@ -354,7 +348,8 @@ request with real detail is one that can actually get built.
|
||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
`docs/behavior-porting-notes.md` — formula provenance;
|
||||
`docs/link-security.md` — what link play defends against, and what it doesn't.
|
||||
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -133,6 +133,8 @@ mkdir -p "$GAME_SRC"
|
||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
|
||||
@@ -92,6 +92,7 @@ mkdir -p "$GAME_SRC"
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
@@ -99,6 +100,8 @@ printf '%s\n' "$payload_list" \
|
||||
&& fail "payload unexpectedly contains generated ROM data"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_silver.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
@@ -193,6 +196,26 @@ get_controls
|
||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp"
|
||||
# Anbernic stock keeps the launcher and the game folder side by side, so the
|
||||
# SHDIR-relative path above is correct there and is tried first.
|
||||
#
|
||||
# Other firmwares (muOS, and PortMaster's layout on several devices) keep
|
||||
# launcher scripts and port data in SEPARATE trees -- scripts under roms/ports,
|
||||
# data under ports -- so the sibling folder holds no game.
|
||||
#
|
||||
# Probe for the BINARY, not the directory: on a split layout this script has
|
||||
# usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
|
||||
# failed run (see mkdir/tee below), so an existence test matches a decoy of our
|
||||
# own making. Stock is unaffected -- its sibling holds the real binary and wins
|
||||
# on the first test.
|
||||
if [ ! -f "$GAMEDIR/bin/love.aarch64" ]; then
|
||||
for candidate in "/$directory/ports/gen1recomp" \
|
||||
"/mnt/sdcard/ports/gen1recomp" \
|
||||
"/mnt/mmc/ports/gen1recomp" \
|
||||
"/roms/ports/gen1recomp"; do
|
||||
if [ -f "$candidate/bin/love.aarch64" ]; then GAMEDIR="$candidate"; break; fi
|
||||
done
|
||||
fi
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||
-- extractor never collects it and the pamphlet's text is inlined.
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
|
||||
-- extractor now collects any top-level label in a dedicated text file
|
||||
-- regardless (tools/extract/text.py), so this is the real ROM label --
|
||||
-- the literal below is only the fallback for a catalog without it.
|
||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||
.. "There are 50 TMs\nin all.\f"
|
||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||
@@ -70,7 +72,8 @@ return {
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||
local text = game.data.text or {}
|
||||
game.stack:push(TextBox.new(game, text.TMNotebookText or TM_NOTEBOOK_TEXT))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
|
||||
@@ -5,8 +5,27 @@
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- data/events/hidden_events.asm:542
|
||||
local BIKE_DISPLAYS = {
|
||||
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
|
||||
}
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
-- engine/events/hidden_events/new_bike.asm:1
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
for _, c in ipairs(BIKE_DISPLAYS) do
|
||||
if c[1] == fx and c[2] == fy then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
|
||||
talk = {
|
||||
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
||||
-- always shows the same flavor line, no branching.
|
||||
|
||||
@@ -15,10 +15,10 @@ return {
|
||||
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
||||
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
||||
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
|
||||
-- into data/generated/text.lua (no leading underscore in
|
||||
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
|
||||
-- ported here verbatim.
|
||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
|
||||
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
|
||||
-- collects them regardless (tools/extract/text.py); the literals
|
||||
-- below are only the fallback for a catalog without them.
|
||||
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
||||
@@ -27,13 +27,16 @@ return {
|
||||
local dish
|
||||
if roll <= 2 then
|
||||
-- bit 7 of hRandomAdd set (~50%)
|
||||
dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
||||
dish = t.SSAnneKitchenCook7SalmonDuSaladText
|
||||
or "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
||||
elseif roll == 3 then
|
||||
-- bit 4 set, bit 7 clear (~25%)
|
||||
dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
||||
dish = t.SSAnneKitchenCook7EelsAuBarbecueText
|
||||
or "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
||||
else
|
||||
-- neither bit set (~25%)
|
||||
dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
||||
dish = t.SSAnneKitchenCook7PrimeBeefSteakText
|
||||
or "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
||||
end
|
||||
push(game, dish, done)
|
||||
end)
|
||||
|
||||
@@ -60,22 +60,23 @@ M.VIRIDIAN_CITY = {
|
||||
-- you want to know about the two kinds of caterpillar Pokemon;
|
||||
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
||||
-- ViridianCityYoungster2OkThenText and
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||
-- without a button press (#250).
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
|
||||
-- without a leading underscore in pokered/text/ViridianCity.asm, but
|
||||
-- tools/extract/text.py now collects them regardless -- the literal
|
||||
-- strings below are only the fallback for a catalog without them.
|
||||
-- Those fallbacks have to carry the extractor's markers, not plain
|
||||
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
|
||||
-- \n and \n\n put all six lines on one page with nothing to wait on,
|
||||
-- so the whole speech scrolled past without a button press (#250).
|
||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||
if yes then
|
||||
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||
push(game, t.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
|
||||
or "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||
else
|
||||
push(game, "Oh, OK then!", done)
|
||||
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- The badge line + its jingle, armed for the battle screen the way
|
||||
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
|
||||
local function badgeEndBattleText(game, victoryKey)
|
||||
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.dialogue) then return nil end
|
||||
local text = game.data.text or {}
|
||||
local pages = {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
pages[#pages + 1] = text[label]
|
||||
end
|
||||
end
|
||||
if #pages == 0 then return nil end
|
||||
return table.concat(pages, "\f"), reward.badgeSound
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end,
|
||||
}
|
||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[adviceLabel] or fallback, finish))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -168,6 +168,7 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
||||
local t = game.data.text
|
||||
local back = function(text)
|
||||
game.stack:push(TextBox.new(game, text, function()
|
||||
ow:scriptMove(ow.player, "down", 1, done)
|
||||
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||
end))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
-- queueScript starts it on the first idle overworld frame (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end,
|
||||
-- "Arrgh!!" is armed for the battle screen, not the map
|
||||
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
|
||||
game.data.text._SilphCo10FGiovanniILostAgainText, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
|
||||
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -733,7 +733,8 @@ local function museumClerk(game, ow, done, onDecline)
|
||||
local t = game.data.text or {}
|
||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Take your time,\nand enjoy it all!", done))
|
||||
t._Museum1FScientist1TakePlentyOfTimeText
|
||||
or "Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:72
|
||||
@@ -751,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline)
|
||||
{ money = money }))
|
||||
elseif yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"You don't have\nenough money.", onDecline or done, { money = money }))
|
||||
t._Museum1FScientist1DontHaveEnoughMoneyText
|
||||
or "You don't have\nenough money.", onDecline or done, { money = money }))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Come again!", onDecline or done, { money = money }))
|
||||
t._Museum1FScientist1ComeAgainText
|
||||
or "Come again!", onDecline or done, { money = money }))
|
||||
end
|
||||
end }))
|
||||
end
|
||||
@@ -766,7 +769,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
|
||||
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
||||
if not master or ow:trainerDefeated(master) then return false end
|
||||
ow.player.facing = "right"
|
||||
master:facePlayer(ow.player)
|
||||
ow:engageTrainer(master)
|
||||
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
|
||||
ow:engageTrainer(master, nil,
|
||||
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
|
||||
nil, nil, false)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -516,7 +519,17 @@ M.ROUTE_24 = {
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
-- scripts/Route24.asm:125
|
||||
ow:engageTrainer(npc, function()
|
||||
if ow:trainerDefeated(npc) then
|
||||
-- scripts/Route24.asm:62
|
||||
push(game,
|
||||
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end, text(game)._Route24CooltrainerM1DefeatedText, true)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
@@ -721,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[dontRunText] or "Don't run away!", function()
|
||||
ow:scriptMove(ow.player, "up", 1)
|
||||
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
|
||||
@@ -122,9 +122,8 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||
-- fallback; Yellow resolves the ROM string instead.
|
||||
-- leading underscore; tools/extract/text.py now collects it regardless,
|
||||
-- so preFallback below is just the safety net for a catalog without it.
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||
@@ -146,9 +145,11 @@ M.SILPH_CO_2F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||
-- the label carries no leading underscore: pokered keeps this one in
|
||||
-- the script bank, not the far-text bank (#393)
|
||||
-- the label carries no leading underscore (#393); collected like any
|
||||
-- other text/*.asm label now, preFallback is just the safety net
|
||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
preFallback = "Eeek!\nNo! Stop! Help!\fOh, you're not\nwith TEAM ROCKET."
|
||||
.. "\vI thought...\vI'm sorry. Here,\vplease take this!",
|
||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||
@@ -239,7 +240,7 @@ local function stepGate(opts)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||
end
|
||||
end)
|
||||
return true
|
||||
@@ -646,27 +647,29 @@ end
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
||||
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||
{ "jump_if_false", "end" }, -- 9
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
@@ -817,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
||||
-- on the west-side cells and walks you back
|
||||
local function bikeGateGuard(coords, stopText, explainText)
|
||||
return function(game, ow, x, y)
|
||||
if game.save.inventory.BICYCLE then return false end
|
||||
local bike = game.save.inventory.BICYCLE
|
||||
if bike and bike ~= 0 then return false end
|
||||
if not inCoords(coords, x, y) then return false end
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||
@@ -839,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
-- (PlayerMovingRightScript). Without it the player was left
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
|
||||
@@ -7,9 +7,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
|
||||
if yes == machine.yes then
|
||||
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
||||
-- slides open (SFX_GO_INSIDE) if it was still locked
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
push(game, t._CinnabarGymQuizCorrectText
|
||||
or "You're absolutely\ncorrect!\fGo on through!", function()
|
||||
if not game.save.flags[gymGateFlag(index)] then
|
||||
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
|
||||
Sound.play(game.data, "Go_Inside")
|
||||
end
|
||||
applyGymGates(game, ow)
|
||||
end)
|
||||
end, { preSound = function()
|
||||
return Sound.play(game.data, "Get_Item1")
|
||||
end })
|
||||
return
|
||||
end
|
||||
Sound.play(game.data, "Denied")
|
||||
|
||||
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function push(game, text, done)
|
||||
local function push(game, text, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
game.stack:push(TextBox.new(game, text, done, opts))
|
||||
end
|
||||
|
||||
-- the two-variant posters: the surf-capable line once a surfing
|
||||
@@ -69,11 +69,11 @@ return {
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
-- scripts/SummerBeachHouse.asm:68
|
||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||
function()
|
||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
done()
|
||||
end)
|
||||
done, { auto = { wait = true, delay = 0, sound = function()
|
||||
return require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
end } })
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Link play: threat model and what the code actually guarantees
|
||||
|
||||
Link play is the only part of this game that reads bytes written by
|
||||
somebody else. This is what it defends against, what it does not, and
|
||||
where each guarantee lives.
|
||||
|
||||
## The boundary
|
||||
|
||||
Everything a peer or the relay sends arrives as one JSON object per line.
|
||||
There is exactly one place it becomes a message:
|
||||
|
||||
src/link/Net.lua reads bytes, frames lines, decodes JSON
|
||||
src/link/Wire.lua rebuilds each line as a typed message
|
||||
src/link/Session.lua the only path from a transport into a mode
|
||||
|
||||
`Session:update` runs `Wire.sanitize` on every message before anything
|
||||
else sees it. A schema returns a **new** table holding only the fields it
|
||||
names, at the Lua types it names, so the rest of `src/link/` can read
|
||||
`msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be
|
||||
right by construction. A message with no schema (a mod's, or a future
|
||||
build's) keeps a bounded, scalar-only copy of its payload instead of
|
||||
being dropped.
|
||||
|
||||
A message that fails its schema is **dropped and logged**, never fatal.
|
||||
Latching a terminal failure would hand a hostile peer a cheaper
|
||||
disconnect than sending nothing at all.
|
||||
|
||||
### Why the bounds are loose
|
||||
|
||||
Wire's numeric bounds are deliberately wider than the game's own clamps in
|
||||
`Protocol.unpackMon`. Both peers run identical clamps over identical
|
||||
packets; a bound that bit an honest value would change one side's copy of
|
||||
a mon and desync the lockstep. Wire's job is types and sizes. Rules are
|
||||
`Protocol`'s job, and it keeps its own clamps for the callers that reach
|
||||
it without a Session (the mod API, `tests/`).
|
||||
|
||||
### Containment behind it
|
||||
|
||||
Assume something still gets through:
|
||||
|
||||
- `Game:step` pcalls the link pump, and pcalls `stack:update` **only
|
||||
while a link session is active**. On a throw, `Game:breakLink` closes
|
||||
the connection, unwinds to the overworld and says "The link was
|
||||
broken." Outside link play the stack is unguarded on purpose: a blanket
|
||||
pcall would swallow real engine bugs and leave the game silently wrong
|
||||
instead of loudly broken.
|
||||
- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer
|
||||
that never sends a newline ends as a clean disconnect.
|
||||
- `Json.decode` refuses documents nested past 64 levels, and takes an
|
||||
optional length cap that the link path passes and the mod-manifest path
|
||||
does not.
|
||||
|
||||
## The relay (`../pokeserver`)
|
||||
|
||||
- A line that is not a JSON **object** with a string `type` is dropped
|
||||
before any handler runs, and `onLine` is wrapped in try/catch.
|
||||
`server.js` installs `uncaughtException`/`unhandledRejection` handlers:
|
||||
one bad packet must never take every live match down with the process.
|
||||
- Line buffers are capped, lines per second are capped, connections per
|
||||
IP and in total are capped, and an unbound connection that never hosts
|
||||
or joins is swept after 30s.
|
||||
- `SERVER_ONLY` is the set of message types the server is the only
|
||||
legitimate author of (`peer_gone`, `bracket_update`, `match_start`,
|
||||
`tournament_over`, `spectate`, ...). A peer that sends one has them
|
||||
dropped rather than forwarded, so a bracket opponent cannot forge a
|
||||
tournament result or fake "your opponent left".
|
||||
- Trainer names are reduced to a printable subset and capped at the same
|
||||
10 characters the game enforces, on the way in, because they are
|
||||
rendered by the dashboard and broadcast to every participant.
|
||||
|
||||
`pokeserver/test/hostile.js` is the regression net for all of that.
|
||||
|
||||
## What is NOT defended
|
||||
|
||||
**Party legality is trust-the-client.** Online play meets strangers, and
|
||||
`Handshake.onlineAllowed` is a Lua function in the same VM the mods load
|
||||
into. It cannot be made tamper-proof in-process, and pretending otherwise
|
||||
would only cost honest mod authors. What lockstep and
|
||||
`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that
|
||||
a cheater cannot invent stats, moves, or a shiny: every derived value is
|
||||
rebuilt locally from real species data. They can send a legal party they
|
||||
farmed or edited. That is the honest boundary.
|
||||
|
||||
What the relay does instead is **observe and record**. It already sees
|
||||
every `hello`, so it keeps each connection's self-reported
|
||||
`engineVersion`, `fingerprint` and `linkModified`, compares the two sides
|
||||
of a room or a live tournament match, and logs and surfaces a
|
||||
`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard.
|
||||
A patched client can still lie; what it cannot do is lie without the
|
||||
tournament organizer having a record of it.
|
||||
|
||||
Client-side attestation is deliberately not built. This is an
|
||||
open-source Lua game: it would be theater, and it would break honest
|
||||
mods.
|
||||
|
||||
**The relay has no TLS.** Port 7778 is plaintext, so party contents,
|
||||
trades and trainer names are visible to anyone on the network path. There
|
||||
is nothing secret in a Pokemon party, but it is a real property of the
|
||||
system and not an oversight. Fixing it means a TLS terminator in front of
|
||||
the relay and a client that speaks it, which is a version break for every
|
||||
shipped build.
|
||||
|
||||
**The dashboard has no default password.** `DASHBOARD_PASSWORD` is
|
||||
required; with it unset the relay runs and the dashboard simply does not
|
||||
start. It is still Basic Auth over plain HTTP, so it belongs behind an
|
||||
IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`).
|
||||
|
||||
## Tests
|
||||
|
||||
luajit tests/link_hostile.lua every message type x every wrong type
|
||||
luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode
|
||||
luajit tests/run_link_tests.lua both of the above, plus the rest
|
||||
cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile
|
||||
|
||||
`tests/link_hostile.lua` builds its corpus from a template per message
|
||||
type, replaces each field (and several nested ones) with every wrong Lua
|
||||
type, and drives the survivors through the real trade session, a real
|
||||
lockstep battle, a real spectator battle, and the tournament screen
|
||||
**including its draw** -- because the two nastiest payloads are
|
||||
delayed-fuse ones that crash on render rather than on receipt.
|
||||
@@ -105,8 +105,9 @@ trixie.
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
|
||||
both CI (`linux-arm64-build`) and the release workflow: the build fails if
|
||||
the highest required glibc symbol version climbs above 2.31.
|
||||
|
||||
### Why five libraries are built from source
|
||||
|
||||
@@ -172,13 +173,15 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
|
||||
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
|
||||
the layout, that every bundled object resolves under AppRun's
|
||||
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
|
||||
AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
`game.love` from the `love-payload` job, runs the same
|
||||
`verify_appimage.sh` checks on the shipped image, and the AppImage is
|
||||
staged and published like every other release asset.
|
||||
|
||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||
it runs on fork PRs too.
|
||||
|
||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
||||
merged.** The write is taken, dropped, and named once per mod in the same
|
||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||
- **40 event names and 44 hook names have a call site in both generations**, so
|
||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||
reads those names back out of the source and fails if a site is renamed or
|
||||
deleted on either side, and fails again if a new shared site appears without
|
||||
@@ -55,9 +55,10 @@ The short version, for an author deciding what to write:
|
||||
```
|
||||
|
||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gen2"` now expands to both Gold and Silver.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
loader's gate reads.
|
||||
@@ -512,8 +513,9 @@ gains a field instead of the name gaining a prefix.
|
||||
id under Gen 1's `name` key, which is the one payload difference the
|
||||
numeric flag space forces.
|
||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
|
||||
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
|
||||
`ui.list_menu` covers Gold's script
|
||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||
menus draw with does not raise it yet, so those two are composed through
|
||||
their own hooks only.
|
||||
@@ -537,7 +539,8 @@ gains a field instead of the name gaining a prefix.
|
||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
|
||||
`battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
@@ -547,6 +550,17 @@ gains a field instead of the name gaining a prefix.
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`ctx.battle.data` instead gets nil on Gold.
|
||||
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||
it silently, so one mod source can print a single summary line for a
|
||||
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||
against a seam that always announced on Gold and keeps announcing there,
|
||||
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||
happen either way.
|
||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
|
||||
@@ -81,7 +81,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
||||
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
||||
use the launcher's streaming large-file path rather than being materialized as
|
||||
one Lua string.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
because its manifest names the same digest. Small sources can still be read
|
||||
with the existing scoped `mod:read` API, for example
|
||||
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
||||
`mod.imports` facade described below; no host path or new general filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
#### Bounded access to validated imports
|
||||
|
||||
A loaded mod can address only ids declared by its own `required_imports` or
|
||||
`optional_imports` arrays:
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("stadium2")
|
||||
local header, err = mod.imports:read("stadium2", 0, 4096)
|
||||
```
|
||||
|
||||
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
||||
rechecks the stored import before exposing it, seeks into the engine-owned
|
||||
copy, and never gives the mod a host path or file handle. This is intended for
|
||||
large source formats whose table/index can be parsed with small reads before
|
||||
selectively reading the payloads a transform actually needs.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
@@ -268,10 +287,10 @@ an optional stock `catchChance` percentage.
|
||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
|
||||
Gold currently returns an empty `items` list rather than guessing at its
|
||||
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
|
||||
optional ones.
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
|
||||
exposes balls and their exact stock catch previews; targeted medicine remains
|
||||
screen-owned and is omitted rather than guessing at its pocketed PACK flow.
|
||||
Callers should ignore unknown fields and tolerate absent optional ones.
|
||||
|
||||
## Battle menu intents
|
||||
|
||||
@@ -441,6 +460,28 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Installation-scoped generated cache
|
||||
|
||||
Generated data derived from a validated user source often belongs to the mod
|
||||
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
||||
|
||||
```lua
|
||||
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
||||
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
||||
local info = mod.cache:info("extract/v1/arena.bin")
|
||||
mod.cache:delete("extract/v1/arena.bin")
|
||||
```
|
||||
|
||||
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
|
||||
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
|
||||
The cache does not rewind with checkpoints and is not scoped to game version,
|
||||
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
|
||||
policy, and completion marker; the engine treats the bytes as opaque data.
|
||||
|
||||
Use `mod.storage` instead when the data belongs to one playthrough. Use
|
||||
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
|
||||
from a declared user source.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
@@ -633,6 +674,17 @@ the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
Both battle engines expose the guarded `battle.charge_required` hook when a
|
||||
charge-capable move is selected for its initial turn and the active ruleset
|
||||
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
||||
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
||||
skip only that initial charge and continue through the ordinary move pipeline;
|
||||
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
||||
the active ruleset already skips charging (for example, Gold Solarbeam in
|
||||
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
||||
by the engine. With no subscriber, the vanilla decision runs without building
|
||||
the hook context.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
|
||||
@@ -11,10 +11,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
||||
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
|
||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
||||
|
||||
| token | means |
|
||||
| --- | --- |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||
| `"all"` | every game this engine has |
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# What This Port Requires
|
||||
|
||||
The packaged desktop app requires one user-supplied input on first boot: a
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM, or a canonical 2 MiB US
|
||||
Pokemon Gold or Silver ROM.
|
||||
|
||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||
@@ -15,7 +16,8 @@ Python and Pillow are not required by the packaged app.
|
||||
|
||||
Assembly removes high-level names and some relationships that the Lua port
|
||||
needs. The version-specific files `tools/rom_manifest.json`,
|
||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||
`tools/rom_manifest_blue.json`, `tools/rom_manifest_yellow.json`,
|
||||
`tools/rom_manifest_gold.json`, and `tools/rom_manifest_silver.json` therefore
|
||||
contain:
|
||||
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
|
||||
|
||||
## Motivation
|
||||
|
||||
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
|
||||
the existing launcher and public mod API still assume imported bytes are small:
|
||||
|
||||
* the Windows desktop picker stages a selected required import through a fixed
|
||||
`%TEMP%/pokeport_required_import.bin` path before validation;
|
||||
* the fallback import path materializes the selected file as one Lua string;
|
||||
* after validation a mod can only use `mod:read("baseroms/...")`, which also
|
||||
materializes the whole file;
|
||||
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
|
||||
not an appropriate home for a one-time generated asset cache shared by every
|
||||
save using the same installed mod.
|
||||
|
||||
This makes optical-disc-sized user sources impractical even though the manifest
|
||||
schema already accepts them. A failed temporary staging copy can also turn a
|
||||
valid large source into a smaller temporary file and produce a misleading
|
||||
"wrong file size" rejection.
|
||||
|
||||
A mod should be able to consume its own already-validated source incrementally
|
||||
and compile derived runtime data once, without receiving a host path or general
|
||||
filesystem access.
|
||||
|
||||
## Decision being extended
|
||||
|
||||
This extends the same legal/sandbox direction as **D11 asset transforms**
|
||||
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
|
||||
user-owned sources rather than shipping ROM-derived data. It also follows the
|
||||
**D14 parity-gate** contract referenced by `tests/harness.lua` and
|
||||
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
|
||||
extension points ship public-API coverage, no-mod parity coverage, and docs in
|
||||
the same change.
|
||||
|
||||
The historical D11 plan document is referenced by source comments but is not
|
||||
present in the current repository tree; this RFC is the checked-in design
|
||||
record for the new surface.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
No manifest field changes. Existing `required_imports` and `optional_imports`
|
||||
remain the declaration/validation authority.
|
||||
|
||||
Two additive facades are added to the `mod` object.
|
||||
|
||||
### `mod.imports`
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("source_id")
|
||||
local bytes, err = mod.imports:read("source_id", offset, length)
|
||||
```
|
||||
|
||||
* `source_id` must name an import declared by the calling mod.
|
||||
* the import is rechecked through `RequiredImports.validateStored` before it is
|
||||
exposed, so missing, replaced, or invalid optional imports are not readable;
|
||||
* `offset` and `length` are zero-based byte coordinates;
|
||||
* one read is capped at 8 MiB;
|
||||
* no host path or file handle is returned;
|
||||
* production reads seek into the engine-owned stored copy instead of reading
|
||||
the whole source.
|
||||
|
||||
`info()` returns declaration metadata plus stored size. It does not expose a
|
||||
host path.
|
||||
|
||||
### `mod.cache`
|
||||
|
||||
```lua
|
||||
mod.cache:write("extract/v1/model.bin", bytes)
|
||||
local bytes = mod.cache:read("extract/v1/model.bin")
|
||||
local info = mod.cache:info("extract/v1/model.bin")
|
||||
mod.cache:delete("extract/v1/model.bin")
|
||||
```
|
||||
|
||||
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
|
||||
backend, and is independent of game version, launcher slot, and playthrough.
|
||||
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
|
||||
escapes remain unavailable. A single cache write is capped at 64 MiB so large
|
||||
generated datasets are naturally split into independently replaceable files.
|
||||
|
||||
The engine does not interpret cache bytes. Mods own generated-format versioning,
|
||||
fingerprints, transactional completion markers, and rebuild policy.
|
||||
|
||||
## Launcher/import transport delta
|
||||
|
||||
For large raw required imports:
|
||||
|
||||
1. desktop pickers return the original selected path instead of staging it
|
||||
through a fixed temporary file;
|
||||
2. the engine opens that source itself;
|
||||
3. bytes are copied directly to the existing engine-owned
|
||||
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
|
||||
4. MD5 is updated incrementally during the copy;
|
||||
5. the normal size/MD5 validation receipt is written only after the complete
|
||||
destination passes validation;
|
||||
6. partial destinations are removed on short reads, write failure, size
|
||||
mismatch, or digest mismatch.
|
||||
|
||||
N64 imports stay on the existing canonicalization path because byte-order and
|
||||
copier-header normalization require transformation rather than a raw copy.
|
||||
|
||||
If a validation receipt for an already-stored large raw import is missing, the
|
||||
engine rebuilds it with streaming MD5 rather than a whole-file read.
|
||||
|
||||
## Backward compatibility / migration
|
||||
|
||||
**Existing mods do nothing.** This is additive.
|
||||
|
||||
* manifest v1/v2 fields are unchanged;
|
||||
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
|
||||
retain their existing behavior;
|
||||
* small required imports retain the existing in-memory validation path;
|
||||
* N64 imports retain canonicalization and existing accepted byte orders;
|
||||
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
|
||||
files and observes no new behavior.
|
||||
|
||||
The mod API integer is not bumped because no existing member changes meaning or
|
||||
shape.
|
||||
|
||||
## Security and legal posture
|
||||
|
||||
The launcher remains the authority that validates user-supplied bytes. The new
|
||||
facade narrows access rather than widening it: a mod can read only ids declared
|
||||
in its own manifest, only after validation, and only in bounded ranges. It does
|
||||
not receive host paths, `io`, or a raw filesystem handle.
|
||||
|
||||
`mod.cache` is writable only beneath the calling mod's generated-cache root.
|
||||
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
|
||||
continue to enforce the existing legal posture.
|
||||
|
||||
## Parity guarantee
|
||||
|
||||
The change ships with:
|
||||
|
||||
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
|
||||
`mod:read` load do not create cache data or change the old surface;
|
||||
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
|
||||
real `Loader` load, including bounded reads, undeclared/missing imports,
|
||||
cache isolation, and traversal rejection;
|
||||
* incremental MD5 vectors and a large-import streaming regression test;
|
||||
* the existing engine suite, required-import suite, and mod lint gates.
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC 0011: Charge-required battle hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A battle-mechanics mod can change damage through `battle.damage` and register
|
||||
move effects, but it cannot conditionally skip the first turn of an existing
|
||||
charge move. In Gen 1, the engine decides and stores the charge continuation
|
||||
before any public effect callback can run. Reaching into `user.charging`,
|
||||
`user.chargeReady`, or generation-specific volatile state is private,
|
||||
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
|
||||
|
||||
Weather is the immediate example: a portable sun rule needs Solarbeam to
|
||||
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
|
||||
and secondary effects to the engine. The capability is generic and useful to
|
||||
other ruleset and move-mechanics mods.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-002: charge-stage policy remains mod authority through a
|
||||
generic guarded engine decision seam**. The consuming design is tracked in the
|
||||
Adaptive Trainers implementation plan,
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 8. The delta follows the additive, guarded hook convention documented by
|
||||
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
|
||||
Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.charge_required", function(next, ctx)
|
||||
-- ctx = {
|
||||
-- battle = live battle controller,
|
||||
-- user = attacking battler,
|
||||
-- target = defending battler,
|
||||
-- move = merged move record,
|
||||
-- charge = true,
|
||||
-- isCalled = false,
|
||||
-- }
|
||||
if should_resolve_now(ctx) then return false end
|
||||
return next(ctx)
|
||||
end)
|
||||
```
|
||||
|
||||
The call site is the initial-use charge decision, after announcement and PP
|
||||
handling but before charge state, invulnerability, charge animation, or charge
|
||||
text is created. It runs only when the active engine rules would otherwise
|
||||
require a charge. It does not run on the release turn. Returning exactly
|
||||
`false` skips that initial charge and continues through the engine-owned move
|
||||
pipeline. Any other downstream return preserves the charge. `isCalled` is true
|
||||
when Metronome or Mirror Move selected the move.
|
||||
|
||||
Gold keeps its native sun decision first, so Solarbeam in native sun already
|
||||
requires no charge and does not invoke the hook. Gen 1 link battles use the
|
||||
shared Gen 1 move pipeline and therefore receive the same seam; normal link
|
||||
mod-compatibility rules continue to govern deterministic peers.
|
||||
|
||||
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
|
||||
subscriber, no hook payload table is allocated and the existing branch runs
|
||||
unchanged.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. The hook name and payload are additive. With no
|
||||
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
|
||||
charge state, PP use, text, animation, accuracy, damage, and native weather
|
||||
behavior. Existing charge-move data and effect records require no migration.
|
||||
|
||||
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
|
||||
skip this charge. It should not mutate private charge fields or re-run the move.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
|
||||
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
|
||||
release-turn behavior, called-move PP semantics, shared payload shape, and
|
||||
native Gold sun behavior.
|
||||
- The same test proves no-mod charge/release parity and replaces
|
||||
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
|
||||
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
|
||||
chains preserve vanilla values and allocation behavior.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
|
||||
generations and keeps the compatibility reference list complete.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded, or deprecated.
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||
a mod that pays the whole party prints **one** summary line instead of a box
|
||||
per recipient.
|
||||
|
||||
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||
call says. So the same mod source, running the same code, prints one line on
|
||||
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||
paid.
|
||||
|
||||
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||
into six boxes to click through.
|
||||
|
||||
There is no mod-side fix. The announcement is emitted inside
|
||||
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||
because the argument that means "quietly" is discarded. The only workaround is
|
||||
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||
what a mod written for this had to do — a mod reaching into engine internals to
|
||||
undo something the public seam should never have done.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||
not survive the crossing, so the promise the catalog already makes is what this
|
||||
change delivers.
|
||||
|
||||
The delta follows Route B's additive, guarded convention in
|
||||
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||
exists today changes behaviour.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||
|
||||
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||
|---|---|---|---|
|
||||
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||
|
||||
The argument is honoured **only when it is actually passed**, decided by
|
||||
argument count rather than by value:
|
||||
|
||||
```lua
|
||||
local function applyShare(mon, split, ...)
|
||||
local announce = ...
|
||||
local silent = select("#", ...) > 0 and not announce
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||
the first is a Gen 2-era call written against a seam that always announced, and
|
||||
the second is a deliberate "pay this one quietly".
|
||||
|
||||
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||
happen exactly as before, in the same order.
|
||||
|
||||
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||
defaults to announcing, so both of the cart's own passes are untouched.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||
calls this seam nor loads differently.
|
||||
|
||||
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||
seam has always documented. Exp Share already does, and needs no edit to get
|
||||
its own README's behaviour on Gold.
|
||||
|
||||
One residual difference is deliberate and now documented rather than silent: an
|
||||
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||
author needs is one sentence: *say what you mean and both games do the same
|
||||
thing.*
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||
checks it already had are unchanged — which is itself the vanilla-parity
|
||||
evidence for this file.
|
||||
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||
participant still prints one line and the EXP.SHARE double pass still
|
||||
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||
are identical either way, and no `experience` event leaks into the queue on
|
||||
a silent pass.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||
gate list moves.
|
||||
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||
`battle.exp_award` as shared — including the argument-count rule and the
|
||||
residual difference above.
|
||||
|
||||
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||
docs name the explicit form as the one that behaves the same on both games.
|
||||
@@ -1,19 +1,28 @@
|
||||
# Touch skins and the Skin Studio
|
||||
|
||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
control layout, and a screen-placement anchor. Engine:
|
||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||
(draw and input), `src/render/Renderer.lua` (screen placement),
|
||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||
`tests/engine/launcher_skins_tab.lua`.
|
||||
`tests/engine/skin_studio_ux.lua`,
|
||||
`tests/engine/skin_studio_image_import.lua`,
|
||||
`tests/engine/skin_format_import_test.lua`,
|
||||
`tests/engine/launcher_skins_tab.lua`,
|
||||
`tests/engine/launcher_skins_ux.lua`.
|
||||
|
||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
||||
and is the one place that turns skin use off. **My Skins** holds the visual
|
||||
grid, pagination, edit, delete and per-skin export actions.
|
||||
`options.touchControls.skin` holds the folder name.
|
||||
|
||||
## Formats
|
||||
|
||||
Two load. `skin.lua` wins when a folder has both.
|
||||
Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta
|
||||
`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher
|
||||
badges each installed skin with the format it was read from.
|
||||
|
||||
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
||||
as-is. Supported keys:
|
||||
@@ -23,9 +32,9 @@ as-is. Supported keys:
|
||||
| `overlays` | page count |
|
||||
| `overlayN_name` | page name, the target of `next_target` |
|
||||
| `overlayN_overlay` | bezel image |
|
||||
| `overlayN_full_screen` | stretch the page to the window |
|
||||
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
|
||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
|
||||
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
|
||||
@@ -40,6 +49,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
|
||||
control that holds both. A `nul` desc is decoration: it draws and never
|
||||
captures a touch.
|
||||
|
||||
The area desc types are expanded rather than ignored: `dpad_area`,
|
||||
`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over
|
||||
the same area, one per 45 degree sector measured from its centre, the way
|
||||
RetroArch resolves them: there is no neutral middle, and the four corner
|
||||
sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and
|
||||
the per-side reach are honoured, and the desc's own art is kept as decoration
|
||||
over the top. Exporting a cfg folds the eight back into the one area desc they
|
||||
came from. `retrok_<key>` is a keyboard bind.
|
||||
|
||||
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
||||
image sits at the overlay opacity, and a pressed control's image swaps to
|
||||
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
||||
@@ -70,6 +88,32 @@ return {
|
||||
}
|
||||
```
|
||||
|
||||
**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an
|
||||
`info.json` plus its art. The `representations` tree is walked
|
||||
device / display type / orientation, and every orientation that exists becomes
|
||||
a page; `page.orient` is the orientation key, so a portrait/landscape pair
|
||||
auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
|
||||
`mappingSize` points and are converted to the native centre plus half extent;
|
||||
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
||||
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
||||
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||
window, and puts the Game Boy picture in the leftover space above -- the
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
||||
game into it. Host functions map to
|
||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
||||
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
||||
|
||||
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
|
||||
Image-to-PDF skins, Preview exports, and the like). Import extracts that
|
||||
JPEG and draws it; a true vector PDF with no embedded image is still refused,
|
||||
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
|
||||
are an older, incompatible schema and are refused by name.
|
||||
|
||||
## Bindable actions
|
||||
|
||||
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
||||
@@ -93,7 +137,7 @@ to decoration and never captures a touch.
|
||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||
how a skin button reaches a mod hotkey.
|
||||
|
||||
## The screen viewport
|
||||
## Screen placement
|
||||
|
||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||
@@ -104,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||
|
||||
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
||||
the world, and the start menu stays at the hole's fit scale instead of
|
||||
shrinking with the map.
|
||||
|
||||
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
||||
as a controller deck: it is contained without deformation and pinned to the
|
||||
bottom on taller screens. The spare space belongs to the game above it.
|
||||
|
||||
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
||||
skin rather than the normal Center / Upper / Top setting.
|
||||
|
||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||
channel and writes the rect.
|
||||
@@ -111,17 +166,27 @@ channel and writes the rect.
|
||||
## Bezels versus pads
|
||||
|
||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||
them. Anything that binds a button still follows the usual mobile /
|
||||
`POKEPORT_TOUCH` rule.
|
||||
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
||||
**desktop** as well as mobile; a gamepad does not hide them.
|
||||
|
||||
## Installing
|
||||
|
||||
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
|
||||
the launcher window while the Skins tab is open. A zip is mounted in place, so
|
||||
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
|
||||
(`overlay.cfg` is preferred when there are several) and the images it names.
|
||||
Four roads, all of them landing in `skins/` in the save directory:
|
||||
|
||||
* **Import** on the Skins tab opens the host file picker for a `.zip` or a
|
||||
`.deltaskin`.
|
||||
* **Paste a skin link** in the tab's URL row, then **Add**. The download runs
|
||||
on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the
|
||||
row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped
|
||||
into an archive on the way in. This is the road that works on a phone, where
|
||||
there is no file picker to speak of.
|
||||
* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is
|
||||
open.
|
||||
* Copy a folder or archive into `skins/` by hand.
|
||||
|
||||
An archive is mounted in place, so there is nothing to unpack. It needs one
|
||||
`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or
|
||||
`info.json`, plus the images it names.
|
||||
|
||||
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
||||
|
||||
@@ -136,9 +201,32 @@ shipping branding.
|
||||
|
||||
## The studio
|
||||
|
||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||
because it wants a mouse, typed coordinates and room for an inspector.
|
||||
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
||||
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
||||
and import actions, with each card owning Edit, Export and (for installed
|
||||
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
||||
New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||
|
||||
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
||||
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
||||
**Detect this screen** (also on the tray) sets the mock device to the live
|
||||
window size so a phone skin is authored at that phone's form factor rather
|
||||
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
||||
workspace so the screen hole can be dragged larger than the bezel while the
|
||||
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
||||
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
||||
same controls that a mouse edits on desktop.
|
||||
|
||||
My Skins and the editor chrome sit inside the platform safe area (notch,
|
||||
status bar, home indicator), the same inset the launcher uses. The mock
|
||||
device still represents the full window, because a skin covers the whole
|
||||
screen at play time.
|
||||
|
||||
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||
is not itself a skin card.
|
||||
|
||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||
phone proportions on a desktop monitor.
|
||||
@@ -146,6 +234,7 @@ phone proportions on a desktop monitor.
|
||||
| Preset | Size |
|
||||
| --- | --- |
|
||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||
| This screen | the live window, so a phone is authored at its own height |
|
||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||
| Steam Deck | 1280x800 |
|
||||
| Desktop 1080p | 1920x1080 |
|
||||
@@ -156,24 +245,73 @@ The Super Game Boy preset locks the viewport to the real screen window,
|
||||
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
||||
|
||||
**Editing.** Click a control to select it, drag to move, eight handles to
|
||||
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
|
||||
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||
per page. The cutout is itself a draggable element with a 10:9 lock. Drop a PNG
|
||||
or JPG on the window to import art into the skin.
|
||||
resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
|
||||
a control is dragged it snaps to the centres and edges of the other controls
|
||||
and of the page itself when it comes within a few pixels, and the guide it
|
||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen anchor are
|
||||
per page. The SCREEN anchor is itself draggable and resizable; its default
|
||||
shape is freeform, with an optional 10:9 lock.
|
||||
|
||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||
it, and boots the game with it.
|
||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
||||
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
||||
as `key:` controls, so a mobile button invokes the exact same game path as
|
||||
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
||||
which is how a pipe bind like `left|down` is built without typing it.
|
||||
|
||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||
actions. `L` toggles the bind captions drawn on the canvas.
|
||||
|
||||
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
||||
(the default), the page list picks a matching mock device and the canvas preset
|
||||
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
||||
landscape device. **Pages** opens the page list, where a page is selected,
|
||||
renamed or deleted.
|
||||
|
||||
Starting a new skin, opening another one or closing the studio with unsaved
|
||||
edits prompts first, with Save first / Discard / Cancel.
|
||||
|
||||
A RetroArch overlay whose pages are already named portrait / landscape
|
||||
(the auto-rotate convention) locks those pages and turns Match canvas on
|
||||
when you open it. You do not have to click Lock first.
|
||||
|
||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
|
||||
thumbnail grid of the images already in the skin folder, with `(none)` first;
|
||||
the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||
window does the same for whichever slot was last touched. A new bezel does not
|
||||
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
||||
the art's alpha.
|
||||
|
||||
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
||||
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
||||
Clicking presses real Game Boy buttons and the footer reports what is held.
|
||||
**Play** saves the skin, selects it, and boots the game with it.
|
||||
|
||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** packs it as one zip
|
||||
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
|
||||
images, and the original `.cfg` when it came from one. An exported skin drops
|
||||
straight back into `skins/` and still opens in RetroArch.
|
||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||
the Skins tab's gear offers the same three for any installed skin:
|
||||
|
||||
| Export | Contents |
|
||||
| --- | --- |
|
||||
| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one |
|
||||
| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images |
|
||||
| Delta `.deltaskin` | an `info.json` generated from the model, plus the images |
|
||||
|
||||
All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/`
|
||||
in the save directory, which is outside the folder the skin list scans, so an
|
||||
export can never shadow the skin it came from. The notice names the full path
|
||||
so a phone can find the file in its own file manager. On desktop **Show the
|
||||
exported file** opens that folder.
|
||||
|
||||
## Not implemented
|
||||
|
||||
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
|
||||
Image assignment cycles through art already in the skin folder; there is no
|
||||
file browser, so new art arrives by drag and drop.
|
||||
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
|
||||
a PDF renderer this engine does not carry, so they are refused with a message
|
||||
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
|
||||
skin case, extract on import.
|
||||
|
||||
@@ -169,6 +169,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`tests/engine/assets_version_fallback_test.lua`,
|
||||
`tests/engine/nx_generated_guard_test.lua`,
|
||||
`tests/engine/nx_yellow_boot_test.lua`,
|
||||
`tests/engine/cache_fs_gold_nx_load_test.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
@@ -179,7 +180,8 @@ or the Switch-related workflow YAML), CI runs:
|
||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`,
|
||||
`luajit tests/engine/cache_fs_gold_nx_load_test.lua`).
|
||||
2. **Fused NRO build** only on the **main** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||
or update, same steps), launch with **title override**, then import your
|
||||
own legal `.gb` ROM.
|
||||
own legal `.gb` / `.gbc` ROM.
|
||||
|
||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||
> This project does not help you set that up.
|
||||
@@ -91,13 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
||||
|
||||
This project ships **no** game data. On first launch:
|
||||
|
||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
|
||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All three can sit in the
|
||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
|
||||
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All five can sit in the
|
||||
same folder.
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
||||
from the Yellow tab (and vice versa).
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
|
||||
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
|
||||
never imports from the Yellow tab (and vice versa). Gold and Silver are
|
||||
Beta in the launcher; a clean US dump of either is enough to Play.
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
@@ -109,10 +110,15 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
||||
| Red | `imports/saves/red/` | `exports/red/` |
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||
| Silver | `imports/saves/silver/` | `exports/silver/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
|
||||
exist so MTP browsing matches the other games. Gold and Silver progress still
|
||||
saves in-engine.)
|
||||
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||
([switch-transfer.md](switch-transfer.md)).
|
||||
2. With the game's ROM already imported, open **that game's tab** →
|
||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||
|
||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
|
||||
@@ -54,8 +54,9 @@ macOS, not a Mac-only requirement.
|
||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||
(or copy NRO / `game.love` for loose).
|
||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
||||
launcher prints.
|
||||
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver>/`
|
||||
path the launcher prints.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a
|
||||
[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a
|
||||
real map editor and exported back out as a mod. The original had no map
|
||||
editor at all; the port's own map data is plain Lua, which is what makes
|
||||
this a data path rather than an asset path.
|
||||
|
||||
Editing is done in our own Tiled build,
|
||||
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||
which ships the `gen1-mod-export` extension the workspace relies on. Grab it
|
||||
from that repo's releases; upstream Tiled opens the workspace but cannot
|
||||
export a mod out of it.
|
||||
|
||||
```sh
|
||||
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||
```
|
||||
|
||||
Then open `build/tiled/gen1.tiled-project` in that build of Tiled.
|
||||
|
||||
- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and
|
||||
`kanto.world` places the 36 connected overworld maps at their real
|
||||
connection offsets. That world is pre-loaded (seeded into the workspace's
|
||||
Tiled session), so opening any one overworld map draws its neighbors around
|
||||
it and you scroll and edit straight across the seams. Everything else is a
|
||||
double-click away in Tiled's project panel.
|
||||
- **Extending Kanto wires both ends.** A connection lives on both maps, so
|
||||
hooking a new map onto a base map also emits the return connection as a
|
||||
patch on that base map, keeping its other directions intact. The return
|
||||
offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy
|
||||
`back.offset == -offset`.
|
||||
- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled
|
||||
tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet,
|
||||
so a tile layer *is* the map's `blocks` array. Warps, signs and objects
|
||||
sit on the 16px cell grid in object layers, which is the grid the engine
|
||||
addresses them on.
|
||||
- **Collision is visible.** View > Show Tile Collision Shapes draws the real
|
||||
walkability: a rectangle covers each cell whose feet tile is not in the
|
||||
tileset's `walkable` list, which is the rule `src/world/Map.lua` applies.
|
||||
- **Maps are shown in their real colors.** Each map is atlased in the SGB
|
||||
palette it renders with, so Cerulean is blue and Lavender is purple in the
|
||||
editor exactly as in game. Vanilla resolves that through a cascade with
|
||||
interiors inheriting the last outdoor map, so the workspace mirrors the
|
||||
cascade and walks the warp graph to colour interiors. Changing a map's
|
||||
`palette` exports `palette = "..."` on the record, which beats the cascade,
|
||||
and the editor offers the real palette names as a dropdown.
|
||||
- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks
|
||||
as raw 8x8 tiles, four by four, so new blocks can be composed there;
|
||||
per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`,
|
||||
`doorTiles` and the rest.
|
||||
- **Export is a diff, not a fork of the data.** The `gen1-mod-export`
|
||||
extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole
|
||||
loadable mod folder. An edited vanilla map diffs against the imported data
|
||||
and emits `mod.content.maps:patch` carrying *only* the fields that moved, so
|
||||
a mod covers the parts it changes and leaves the rest to the base game; a
|
||||
new map gets `:register` at an index of 1000 or above. An unchanged map
|
||||
exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`.
|
||||
- **Or the whole record, on request.** Ticking `exactExport` on a map switches
|
||||
it to `mod.content.maps:override`, pinning the map to exactly what the
|
||||
editor shows. It is off by default because an override wins outright over
|
||||
any other mod patching that map, where a patch composes.
|
||||
|
||||
No ROM-derived art travels into an exported mod: a tileset still drawing on
|
||||
the player's own imported sheet references that path rather than shipping the
|
||||
pixels, and only a sheet the author supplied is copied in.
|
||||
@@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
||||
|
||||
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:
|
||||
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||
PortMaster archives) 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
|
||||
@@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
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/gen1recomp/releases/latest`).
|
||||
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||
from the same release and persists the requirement, so it is visible again on
|
||||
every launch, including offline launches.
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
|
||||
```
|
||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||
updates/pending.txt crash-guard marker
|
||||
updates/full-update.json persistent native-package requirement
|
||||
```
|
||||
|
||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||
@@ -106,7 +109,8 @@ bundled game, in that case.
|
||||
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.
|
||||
needs_full | full_downloading | full_ready | error` plus the latest version,
|
||||
download progress, and (when applicable) the selected full-package asset.
|
||||
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
|
||||
@@ -117,6 +121,17 @@ bundled game, in that case.
|
||||
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.
|
||||
5. **Native-package requirement**: when `minShell` or `payloadHost` is
|
||||
incompatible, the worker writes `full-update.json` and surfaces a
|
||||
persistent launcher control. Android downloads the release APK, verifies
|
||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||
Installer. The installer asks the user for consent and enforces package,
|
||||
version-code, and signing-certificate compatibility. A legacy APK without
|
||||
the installer bridge links its full package for one manual bootstrap
|
||||
update, including when its downloaded payload already reports the latest
|
||||
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||
desktop, and PortMaster builds link their correctly named full package.
|
||||
Switch keeps its native OTA flow.
|
||||
|
||||
## Known limitations
|
||||
|
||||
@@ -140,6 +155,13 @@ bundled game, in that case.
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **Android full updates are user-confirmed and certificate-bound.** The app
|
||||
uses a private `FileProvider` cache path plus
|
||||
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
|
||||
"install unknown apps" setting, and never requests a silent install. The
|
||||
release job must use the original long-lived Android signing key; a new key
|
||||
causes Android to reject an in-place update and requires a one-time manual
|
||||
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
|
||||
- **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
|
||||
|
||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
function love.errorhandler(msg)
|
||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||
if hint and type(msg) == "string" then
|
||||
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
if defaultErrorHandler then
|
||||
@@ -291,6 +291,81 @@ function closeSkinStudio()
|
||||
end
|
||||
end
|
||||
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
||||
-- Keep the compatibility predicate so external hosts using it still work.
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
end
|
||||
|
||||
local function returnToLauncher()
|
||||
if not Game then return end
|
||||
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
end
|
||||
|
||||
Game = nil
|
||||
autopilot = nil
|
||||
driverCo = nil
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
|
||||
if love.window and love.window.setTitle then
|
||||
local Version = require("src.core.Version")
|
||||
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
|
||||
end
|
||||
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
@@ -314,11 +389,12 @@ function bootGame(version)
|
||||
love.window.setTitle(Version.title(
|
||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||
end
|
||||
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||
-- tables, save shape and screen registry -- so Gold boots its own service
|
||||
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
|
||||
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
|
||||
if GameVersion.isGold() then
|
||||
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||
-- tables, save shape and screen registry -- so Gold and Silver boot their
|
||||
-- own service owner, which mounts src/world/gen2 (walk / warps /
|
||||
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
|
||||
-- wiring.
|
||||
if GameVersion.generation() == 2 then
|
||||
Game = require("src.core.Game2").new()
|
||||
Game:load()
|
||||
else
|
||||
@@ -382,7 +458,7 @@ function love.load(args)
|
||||
|
||||
-- Apply the persisted Android orientation lock (#592) before the launcher
|
||||
-- shows: SDL created the window with no orientation hint, so without this
|
||||
-- the launcher would rotate freely until Game:applyOptions runs at boot.
|
||||
-- the launcher would rotate freely until options are applied at boot.
|
||||
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
@@ -442,8 +518,8 @@ function love.load(args)
|
||||
-- (#767) only pays off if something fills that catalog this early, and no
|
||||
-- restart could: the ordering is the same on every launch. Read the
|
||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||
-- translation reaches the launcher too. Game:load replaces this with the
|
||||
-- real merged catalog once a version boots.
|
||||
-- translation reaches the launcher too. The active game's loader replaces
|
||||
-- this with the real merged catalog once a version boots.
|
||||
do
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
@@ -484,17 +560,7 @@ function love.load(args)
|
||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -506,6 +572,7 @@ function love.update(dt)
|
||||
if TouchEditor then return TouchEditor.update(dt) end
|
||||
if Studio then return Studio.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
if not Game then return end
|
||||
|
||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||
-- once per Game:update, so they must keep a 1:1 relationship with the
|
||||
@@ -602,12 +669,14 @@ function love.keypressed(key, scancode, isrepeat)
|
||||
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||
if Studio then return Studio.keypressed(key) end
|
||||
if Importer then return Importer:keypressed(key) end
|
||||
if not Game then return end
|
||||
Game:keypressed(key)
|
||||
end
|
||||
|
||||
function love.keyreleased(key)
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:keyreleased(key)
|
||||
end
|
||||
|
||||
@@ -625,7 +694,9 @@ function love.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
@@ -643,7 +714,9 @@ function love.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
@@ -661,7 +734,9 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -679,7 +754,9 @@ function love.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
@@ -697,7 +774,9 @@ function love.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
@@ -715,7 +794,9 @@ function love.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -733,21 +814,25 @@ function love.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
if not Game then return end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
function love.joystickadded(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
||||
if editorMode or TouchEditor then return end
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:joystickadded(joystick)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
||||
if editorMode or TouchEditor then return end
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:joystickremoved(joystick)
|
||||
end
|
||||
|
||||
@@ -756,29 +841,81 @@ end
|
||||
-- unfocused, so reset input on either transition rather than trust it.
|
||||
function love.focus(f)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Studio then
|
||||
if Studio.focus then Studio.focus(f) end
|
||||
return
|
||||
end
|
||||
if Importer then
|
||||
require("src.core.Input"):reset()
|
||||
if Importer.focus then Importer:focus(f) end
|
||||
return
|
||||
end
|
||||
if not Game then return end
|
||||
Game:focus(f)
|
||||
end
|
||||
|
||||
-- v is true when the window becomes visible again, false on minimize.
|
||||
function love.visible(v)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Studio then
|
||||
if Studio.visible then Studio.visible(v) end
|
||||
return
|
||||
end
|
||||
if Importer then
|
||||
require("src.core.Input"):reset()
|
||||
return
|
||||
end
|
||||
if not Game then return end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.lowmemory()
|
||||
if editorMode or TouchEditor or Importer then return end
|
||||
if editorMode or TouchEditor or Studio or Importer then return end
|
||||
if Game then Game:onResume() end
|
||||
end
|
||||
|
||||
love.handlers = love.handlers or {}
|
||||
|
||||
function love.handlers.audiosuspend()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.audioreset()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
if ChipAudio then
|
||||
pcall(ChipAudio.setSuspended, false)
|
||||
pcall(ChipAudio.rebuildPlayback)
|
||||
end
|
||||
local Music = package.loaded["src.core.Music"]
|
||||
if Music then pcall(Music.onDeviceReset) end
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.intent_game(version)
|
||||
if type(version) ~= "string" or version == "" then return end
|
||||
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
if not RomImporter.isReady(version) then return end
|
||||
|
||||
local currentVersion = GameVersion.get()
|
||||
if Game and currentVersion == version then
|
||||
return
|
||||
end
|
||||
|
||||
if Game then
|
||||
returnToLauncher()
|
||||
end
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then
|
||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
||||
@@ -796,12 +933,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Studio then return Studio.touchpressed(id, x, y) end
|
||||
if Importer then
|
||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
if not Game then return end
|
||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
@@ -811,9 +950,11 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Studio then return Studio.touchmoved(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
if not Game then return end
|
||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
@@ -823,9 +964,11 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Studio then return Studio.touchreleased(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
if not Game then return end
|
||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
@@ -837,6 +980,7 @@ function love.wheelmoved(x, y)
|
||||
if TouchEditor then return end
|
||||
if Studio then return Studio.wheelmoved(x, y) end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
@@ -873,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousepressed(x, y, button) end
|
||||
if Studio then
|
||||
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
||||
-- Studio consumes the real finger stream above, so discard the twin.
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
@@ -908,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousereleased(x, y, button) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousereleased(x, y, button)
|
||||
end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -926,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if Studio then return Studio.mousemoved(x, y) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
@@ -977,11 +1132,16 @@ function love.quit()
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and not launchedIntoGame
|
||||
and (isAndroid or not launchedIntoGame)
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
if isAndroid then
|
||||
returnToLauncher()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
|
||||
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
|
||||
love-android 11.5a expects:
|
||||
|
||||
- **JDK 17**
|
||||
- Android SDK with **API 34**
|
||||
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
|
||||
- NDK **25.2.9519653** (Apple Silicon host supported)
|
||||
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||
|
||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||
@@ -109,10 +110,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
|
||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
packer verifies the Yellow manifest before it packages; if a partial source
|
||||
export omitted it, it restores the file from this checkout's Git data and then
|
||||
falls back to the project's GitHub copy. Generated game data,
|
||||
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
|
||||
Android packer verifies the Yellow, Gold, and Silver manifests before it
|
||||
packages; if a partial source export omitted one, it restores the file from
|
||||
this checkout's Git data and then falls back to the project's GitHub copy. Generated game data,
|
||||
scripts, tests, and mobile build sources are excluded.
|
||||
|
||||
## Branding (applied by the build script)
|
||||
@@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||
| `app.name` | Pokemon Red |
|
||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||
|
||||
## Releases
|
||||
|
||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||
`PokemonRed-<version>-android.apk`.
|
||||
`gen1recomp-<version>-android.apk`.
|
||||
|
||||
## Signing
|
||||
|
||||
Signed with the default Android keystore (no setup required).
|
||||
Production APKs are built with `scripts/build_android.sh --release`. They must
|
||||
be signed with the same long-lived certificate as the currently installed app:
|
||||
Android's Package Installer rejects an update with a different signing
|
||||
certificate. Store that keystore and its passwords only in CI secrets, expose
|
||||
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
|
||||
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
|
||||
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
|
||||
created certificate cannot update users who have an APK signed by a different
|
||||
legacy key; those users need one final manual reinstall before in-app updates
|
||||
can take over.
|
||||
|
||||
@@ -41,7 +41,7 @@ Quick Start:
|
||||
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
||||
Android Studio bundles its own JDK 17.
|
||||
|
||||
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
|
||||
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
|
||||
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
||||
|
||||
```
|
||||
|
||||
@@ -10,9 +10,12 @@ android {
|
||||
applicationId project.properties["app.application_id"]
|
||||
versionCode project.properties["app.version_code"].toInteger()
|
||||
versionName project.properties["app.version_name"]
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||
// the native toolchain and package-installer bridge on a supported ABI.
|
||||
minSdk 19
|
||||
// Android 16 / API 36: current Android distribution target.
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
|
||||
def getAppName = {
|
||||
def nameArray = project.properties["app.name_byte_array"]
|
||||
@@ -38,10 +41,31 @@ android {
|
||||
ORIENTATION:project.properties["app.orientation"],
|
||||
]
|
||||
}
|
||||
// Release signing lives outside the repository. The release build script
|
||||
// requires all five values below, while debug builds intentionally remain
|
||||
// usable without them.
|
||||
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
|
||||
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
|
||||
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
|
||||
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
|
||||
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
|
||||
|
||||
if (hasReleaseSigning) {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file(releaseStore)
|
||||
storePassword releaseStorePassword
|
||||
keyAlias releaseKeyAlias
|
||||
keyPassword releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
flavorDimensions = ['mode', 'recording']
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Required only to hand a checksum-verified, user-selected GitHub release
|
||||
APK to Android's own Package Installer. Android still shows the install
|
||||
confirmation and enforces package/signing-key/version compatibility. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||
counter, which Android 10+ gates behind this runtime permission.
|
||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||
@@ -29,17 +33,30 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/love"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="${NAME}" >
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
android:value="true" />
|
||||
<!-- The full-update APK is copied into this small cache subdirectory
|
||||
before it is handed to Package Installer. Keep the provider private
|
||||
and expose only that directory, never a storage root. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.full_update_provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/full_update_paths" />
|
||||
</provider>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||
android:label="${NAME}"
|
||||
android:launchMode="singleInstance"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
android:resizeableActivity="false"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
||||
@@ -54,7 +71,7 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -3,4 +3,10 @@
|
||||
<color name="colorPrimary">#3F51B5</color>
|
||||
<color name="colorPrimaryDark">#303F9F</color>
|
||||
<color name="colorAccent">#FF4081</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="shortcut_red">#E53935</color>
|
||||
<color name="shortcut_blue">#1E88E5</color>
|
||||
<color name="shortcut_yellow">#FDD835</color>
|
||||
<color name="shortcut_gold">#D4AF37</color>
|
||||
<color name="shortcut_silver">#BEC6D2</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
|
||||
arbitrary app, external, or shared storage. -->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="full_update" path="full-update/" />
|
||||
</paths>
|
||||
@@ -18,7 +18,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
|
||||
classpath 'com.android.tools.build:gradle:8.9.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
||||
# No need to modify anything past this line!
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=gen1recomp
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -10,9 +10,9 @@ android {
|
||||
ndkVersion '25.2.9519653'
|
||||
|
||||
defaultConfig {
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
minSdk 19
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||
@@ -63,8 +63,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
minifyEnabled false
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
// own, which can name a different volume on merged / adopted-SD storage.
|
||||
#include "filesystem/Filesystem.h"
|
||||
|
||||
#include "common/Module.h"
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/openal/Audio.h"
|
||||
#include "event/Event.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace android
|
||||
@@ -278,6 +283,104 @@ bool restartApp()
|
||||
return result;
|
||||
}
|
||||
|
||||
bool installApk(const char *path)
|
||||
{
|
||||
if (path == nullptr || path[0] == '\0')
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// This may be called from Lua's main thread, but use the activity object
|
||||
// class just like httpDownload so a future worker caller does not depend on
|
||||
// the system JNI class loader finding the app class.
|
||||
void *rawActivity = SDL_AndroidGetActivity();
|
||||
if (rawActivity == nullptr)
|
||||
return false;
|
||||
jobject activityObj = (jobject) rawActivity;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jpath = env->NewStringUTF(path);
|
||||
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
||||
env->DeleteLocalRef(jroot);
|
||||
env->DeleteLocalRef(jpath);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
if (activity == nullptr)
|
||||
return false;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jclass stringClass = env->FindClass("java/lang/String");
|
||||
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
|
||||
for (size_t i = 0; i < versions.size(); ++i)
|
||||
{
|
||||
jstring jstr = env->NewStringUTF(versions[i].c_str());
|
||||
env->SetObjectArrayElement(array, (jsize) i, jstr);
|
||||
env->DeleteLocalRef(jstr);
|
||||
}
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
|
||||
|
||||
env->DeleteLocalRef(array);
|
||||
env->DeleteLocalRef(stringClass);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string getLaunchGame()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
if (activity == nullptr)
|
||||
return "";
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return "";
|
||||
}
|
||||
|
||||
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (jgame == nullptr)
|
||||
{
|
||||
env->DeleteLocalRef(activity);
|
||||
return "";
|
||||
}
|
||||
|
||||
const char *str = env->GetStringUTFChars(jgame, nullptr);
|
||||
std::string result = (str != nullptr) ? str : "";
|
||||
if (str != nullptr)
|
||||
env->ReleaseStringUTFChars(jgame, str);
|
||||
|
||||
env->DeleteLocalRef(jgame);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
||||
{
|
||||
if (url == nullptr || destPath == nullptr)
|
||||
@@ -374,6 +477,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpRequest(const char *url, const char *method,
|
||||
const char *const *headerPairs, int headerPairCount,
|
||||
const char *body, int bodyLen, const char *userAgent, std::string &out)
|
||||
{
|
||||
out.clear();
|
||||
if (url == nullptr)
|
||||
return false;
|
||||
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// Same resolution rule as httpDownload: the activity's own class via
|
||||
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
|
||||
// runs on a love.thread worker, whose class loader cannot see them.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" instead of aborting
|
||||
// on a missing method (#597).
|
||||
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
|
||||
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
|
||||
if (method_id == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jobjectArray jheaders = nullptr;
|
||||
if (headerPairCount > 0)
|
||||
{
|
||||
// java/lang/String, unlike an app class, resolves from any thread.
|
||||
jclass stringClass = env->FindClass("java/lang/String");
|
||||
if (stringClass == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
|
||||
env->DeleteLocalRef(stringClass);
|
||||
if (jheaders == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < headerPairCount; i++)
|
||||
{
|
||||
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
|
||||
env->SetObjectArrayElement(jheaders, (jsize) i, field);
|
||||
if (field != nullptr)
|
||||
env->DeleteLocalRef(field);
|
||||
}
|
||||
}
|
||||
|
||||
jstring jurl = env->NewStringUTF(url);
|
||||
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
|
||||
// raw bytes across the bridge, as httpPost does: a request body is JSON
|
||||
// carrying a base64 save, and a jstring would run it through modified UTF-8
|
||||
jbyteArray jbody = nullptr;
|
||||
if (body != nullptr && bodyLen >= 0)
|
||||
{
|
||||
jbody = env->NewByteArray((jsize) bodyLen);
|
||||
if (jbody != nullptr && bodyLen > 0)
|
||||
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
|
||||
}
|
||||
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
|
||||
jheaders, jbody, jua);
|
||||
|
||||
env->DeleteLocalRef(jurl);
|
||||
env->DeleteLocalRef(jmethod);
|
||||
if (jheaders != nullptr)
|
||||
env->DeleteLocalRef(jheaders);
|
||||
if (jbody != nullptr)
|
||||
env->DeleteLocalRef(jbody);
|
||||
env->DeleteLocalRef(jua);
|
||||
env->DeleteLocalRef(activity);
|
||||
|
||||
if (result == nullptr)
|
||||
return false;
|
||||
|
||||
jbyteArray bytes = (jbyteArray) result;
|
||||
jsize length = env->GetArrayLength(bytes);
|
||||
if (length > 0)
|
||||
{
|
||||
out.resize((size_t) length);
|
||||
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
|
||||
}
|
||||
env->DeleteLocalRef(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
@@ -1320,4 +1521,98 @@ const char *love_android_poll_secondary_touch()
|
||||
return event.empty() ? nullptr : event.c_str();
|
||||
}
|
||||
|
||||
static love::audio::openal::Audio *love_android_openal_audio()
|
||||
{
|
||||
love::audio::Audio *audio = love::Module::getInstance<love::audio::Audio>(love::Module::M_AUDIO);
|
||||
|
||||
if (audio == nullptr)
|
||||
return nullptr;
|
||||
|
||||
const char *name = audio->getName();
|
||||
|
||||
if (name == nullptr || strcmp(name, "love.audio.openal") != 0)
|
||||
return nullptr;
|
||||
|
||||
return (love::audio::openal::Audio *) audio;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_love2d_android_GameActivity_nativeAudioFocusLost(JNIEnv *env, jclass cls)
|
||||
{
|
||||
(void) env;
|
||||
(void) cls;
|
||||
|
||||
love::audio::openal::pushAudioSuspendEvent();
|
||||
|
||||
love::audio::openal::Audio *audio = love_android_openal_audio();
|
||||
|
||||
if (audio != nullptr)
|
||||
audio->pauseContext();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_love2d_android_GameActivity_nativeAudioFocusGained(JNIEnv *env, jclass cls)
|
||||
{
|
||||
(void) env;
|
||||
(void) cls;
|
||||
|
||||
love::audio::openal::Audio *audio = love_android_openal_audio();
|
||||
|
||||
if (audio == nullptr)
|
||||
return;
|
||||
|
||||
audio->resumeContext();
|
||||
|
||||
if (!audio->isDeviceConnected())
|
||||
audio->reopenDevice();
|
||||
|
||||
love::audio::openal::pushAudioResetEvent();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclass cls)
|
||||
{
|
||||
(void) env;
|
||||
(void) cls;
|
||||
|
||||
love::audio::openal::Audio *audio = love_android_openal_audio();
|
||||
|
||||
if (audio == nullptr)
|
||||
return;
|
||||
|
||||
audio->pauseContext();
|
||||
audio->reopenDevice();
|
||||
audio->resumeContext();
|
||||
|
||||
love::audio::openal::pushAudioResetEvent();
|
||||
}
|
||||
|
||||
static void pushGameIntentEvent(const char *game)
|
||||
{
|
||||
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
|
||||
if (eventmodule == nullptr || game == nullptr)
|
||||
return;
|
||||
|
||||
std::vector<love::Variant> args;
|
||||
args.push_back(love::Variant(std::string(game)));
|
||||
|
||||
love::event::Message *msg = new love::event::Message("intent_game", args);
|
||||
eventmodule->push(msg);
|
||||
msg->release();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
|
||||
{
|
||||
(void) cls;
|
||||
if (game == nullptr)
|
||||
return;
|
||||
const char *str = env->GetStringUTFChars(game, nullptr);
|
||||
if (str != nullptr)
|
||||
{
|
||||
pushGameIntentEvent(str);
|
||||
env->ReleaseStringUTFChars(game, str);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -90,6 +90,22 @@ bool syncHealthSteps();
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/**
|
||||
* Stages a checksum-verified APK from the current save directory and starts
|
||||
* Android's user-confirmed Package Installer flow. Android-only.
|
||||
**/
|
||||
bool installApk(const char *path);
|
||||
|
||||
/**
|
||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||
**/
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions);
|
||||
|
||||
/**
|
||||
* Returns the game version requested via initial launch Intent (if any).
|
||||
**/
|
||||
std::string getLaunchGame();
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||
@@ -106,6 +122,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
**/
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||
|
||||
/**
|
||||
* Blocking HTTPS request with a method, headers and a byte body
|
||||
* (GameActivity.httpRequest). What save sync needs and neither of the two
|
||||
* above can give it: PUT, per-request auth headers, and the response body of
|
||||
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
|
||||
* headerPairCount entries; body/userAgent may be null. `out` receives the
|
||||
* Java side's envelope -- a head line of "STATUS <code>" or "ERROR <text>",
|
||||
* a newline, then the raw response bytes. False means the platform has no
|
||||
* such bridge at all (an old APK under a newer liblove), which the Lua side
|
||||
* reports as "update the app" rather than as a failed request.
|
||||
**/
|
||||
bool httpRequest(const char *url, const char *method,
|
||||
const char *const *headerPairs, int headerPairCount,
|
||||
const char *body, int bodyLen, const char *userAgent, std::string &out);
|
||||
|
||||
/**
|
||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "common/delay.h"
|
||||
#include "RecordingDevice.h"
|
||||
#include "sound/Decoder.h"
|
||||
#include "event/Event.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
@@ -30,6 +31,10 @@
|
||||
#include "common/ios.h"
|
||||
#endif
|
||||
|
||||
#ifndef ALC_CONNECTED
|
||||
#define ALC_CONNECTED 0x313
|
||||
#endif
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace audio
|
||||
@@ -37,9 +42,35 @@ namespace audio
|
||||
namespace openal
|
||||
{
|
||||
|
||||
Audio::PoolThread::PoolThread(Pool *pool)
|
||||
: pool(pool)
|
||||
static const int DISCONNECT_CHECK_INTERVAL = 200;
|
||||
|
||||
static void pushAudioEvent(const char *name)
|
||||
{
|
||||
auto eventmodule = Module::getInstance<event::Event>(Module::M_EVENT);
|
||||
|
||||
if (eventmodule == nullptr)
|
||||
return;
|
||||
|
||||
event::Message *msg = new event::Message(name);
|
||||
eventmodule->push(msg);
|
||||
msg->release();
|
||||
}
|
||||
|
||||
void pushAudioSuspendEvent()
|
||||
{
|
||||
pushAudioEvent("audiosuspend");
|
||||
}
|
||||
|
||||
void pushAudioResetEvent()
|
||||
{
|
||||
pushAudioEvent("audioreset");
|
||||
}
|
||||
|
||||
Audio::PoolThread::PoolThread(Audio *audio, Pool *pool)
|
||||
: audio(audio)
|
||||
, pool(pool)
|
||||
, finish(false)
|
||||
, paused(false)
|
||||
{
|
||||
threadName = "AudioPool";
|
||||
}
|
||||
@@ -51,6 +82,8 @@ Audio::PoolThread::~PoolThread()
|
||||
|
||||
void Audio::PoolThread::threadFunction()
|
||||
{
|
||||
int disconnectCheck = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
{
|
||||
@@ -61,7 +94,23 @@ void Audio::PoolThread::threadFunction()
|
||||
}
|
||||
}
|
||||
|
||||
if (paused.load())
|
||||
{
|
||||
disconnectCheck = 0;
|
||||
sleep(5);
|
||||
continue;
|
||||
}
|
||||
|
||||
pool->update();
|
||||
|
||||
if (audio != nullptr && ++disconnectCheck >= DISCONNECT_CHECK_INTERVAL)
|
||||
{
|
||||
disconnectCheck = 0;
|
||||
|
||||
if (!audio->isDeviceConnected() && audio->reopenDevice())
|
||||
pushAudioResetEvent();
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +121,11 @@ void Audio::PoolThread::setFinish()
|
||||
finish = true;
|
||||
}
|
||||
|
||||
void Audio::PoolThread::setPaused(bool paused)
|
||||
{
|
||||
this->paused.store(paused);
|
||||
}
|
||||
|
||||
ALenum Audio::getFormat(int bitDepth, int channels)
|
||||
{
|
||||
if (bitDepth != 8 && bitDepth != 16)
|
||||
@@ -99,6 +153,8 @@ Audio::Audio()
|
||||
, pool(nullptr)
|
||||
, poolThread(nullptr)
|
||||
, distanceModel(DISTANCE_INVERSE_CLAMPED)
|
||||
, alcReopenDeviceSOFT(nullptr)
|
||||
, reopenChecked(false)
|
||||
{
|
||||
// Before opening new device, check if recording
|
||||
// is requested.
|
||||
@@ -189,13 +245,6 @@ Audio::Audio()
|
||||
throw;
|
||||
}
|
||||
|
||||
poolThread = new PoolThread(pool);
|
||||
poolThread->start();
|
||||
|
||||
#ifdef LOVE_IOS
|
||||
love::ios::initAudioSessionInterruptionHandler();
|
||||
#endif
|
||||
|
||||
#ifdef LOVE_ANDROID
|
||||
bool hasPauseDeviceExt = alcIsExtensionPresent(device, "ALC_SOFT_pause_device") == ALC_TRUE;
|
||||
alcDevicePauseSOFT = hasPauseDeviceExt
|
||||
@@ -205,6 +254,13 @@ Audio::Audio()
|
||||
? (LPALCDEVICERESUMESOFT) alcGetProcAddress(device, "alcDeviceResumeSOFT")
|
||||
: nullptr;
|
||||
#endif
|
||||
|
||||
poolThread = new PoolThread(this, pool);
|
||||
poolThread->start();
|
||||
|
||||
#ifdef LOVE_IOS
|
||||
love::ios::initAudioSessionInterruptionHandler();
|
||||
#endif
|
||||
}
|
||||
|
||||
Audio::~Audio()
|
||||
@@ -314,6 +370,9 @@ std::vector<love::audio::Source*> Audio::pause()
|
||||
|
||||
void Audio::pauseContext()
|
||||
{
|
||||
if (poolThread != nullptr)
|
||||
poolThread->setPaused(true);
|
||||
|
||||
#ifdef LOVE_ANDROID
|
||||
if (alcDevicePauseSOFT)
|
||||
alcDevicePauseSOFT(device);
|
||||
@@ -350,6 +409,52 @@ void Audio::resumeContext()
|
||||
if (context && alcGetCurrentContext() != context)
|
||||
alcMakeContextCurrent(context);
|
||||
#endif
|
||||
|
||||
if (poolThread != nullptr)
|
||||
poolThread->setPaused(false);
|
||||
}
|
||||
|
||||
bool Audio::reopenDevice()
|
||||
{
|
||||
if (device == nullptr)
|
||||
return false;
|
||||
|
||||
thread::Lock lock(deviceMutex);
|
||||
|
||||
if (!reopenChecked)
|
||||
{
|
||||
reopenChecked = true;
|
||||
|
||||
if (alcIsExtensionPresent(device, "ALC_SOFT_reopen_device") == ALC_TRUE)
|
||||
alcReopenDeviceSOFT = (LPALCREOPENDEVICESOFT) alcGetProcAddress(device, "alcReopenDeviceSOFT");
|
||||
}
|
||||
|
||||
if (alcReopenDeviceSOFT == nullptr)
|
||||
return false;
|
||||
|
||||
alcGetError(device);
|
||||
|
||||
return alcReopenDeviceSOFT(device, nullptr, nullptr) == ALC_TRUE;
|
||||
}
|
||||
|
||||
bool Audio::isDeviceConnected()
|
||||
{
|
||||
if (device == nullptr)
|
||||
return false;
|
||||
|
||||
thread::Lock lock(deviceMutex);
|
||||
|
||||
if (alcIsExtensionPresent(device, "ALC_EXT_disconnect") != ALC_TRUE)
|
||||
return true;
|
||||
|
||||
ALCint connected = 1;
|
||||
alcGetError(device);
|
||||
alcGetIntegerv(device, ALC_CONNECTED, 1, &connected);
|
||||
|
||||
if (alcGetError(device) != ALC_NO_ERROR)
|
||||
return true;
|
||||
|
||||
return connected != 0;
|
||||
}
|
||||
|
||||
void Audio::setVolume(float volume)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#define LOVE_AUDIO_OPENAL_AUDIO_H
|
||||
|
||||
// STD
|
||||
#include <atomic>
|
||||
#include <queue>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
@@ -97,6 +98,8 @@ public:
|
||||
std::vector<love::audio::Source*> pause();
|
||||
void pauseContext();
|
||||
void resumeContext();
|
||||
bool reopenDevice();
|
||||
bool isDeviceConnected();
|
||||
void setVolume(float volume);
|
||||
float getVolume() const;
|
||||
|
||||
@@ -155,6 +158,7 @@ private:
|
||||
class PoolThread: public thread::Threadable
|
||||
{
|
||||
protected:
|
||||
Audio *audio;
|
||||
Pool *pool;
|
||||
|
||||
// Set this to true when the thread should finish.
|
||||
@@ -162,13 +166,16 @@ private:
|
||||
// will read from it.
|
||||
volatile bool finish;
|
||||
|
||||
std::atomic<bool> paused;
|
||||
|
||||
// finish lock
|
||||
love::thread::MutexRef mutex;
|
||||
|
||||
public:
|
||||
PoolThread(Pool *pool);
|
||||
PoolThread(Audio *audio, Pool *pool);
|
||||
virtual ~PoolThread();
|
||||
void setFinish();
|
||||
void setPaused(bool paused);
|
||||
void threadFunction();
|
||||
};
|
||||
|
||||
@@ -177,6 +184,13 @@ private:
|
||||
DistanceModel distanceModel;
|
||||
//float metersPerUnit = 1.0;
|
||||
|
||||
#ifndef ALC_SOFT_reopen_device
|
||||
typedef ALCboolean (ALC_APIENTRY*LPALCREOPENDEVICESOFT)(ALCdevice *device, const ALCchar *deviceName, const ALCint *attribs);
|
||||
#endif
|
||||
LPALCREOPENDEVICESOFT alcReopenDeviceSOFT;
|
||||
bool reopenChecked;
|
||||
love::thread::MutexRef deviceMutex;
|
||||
|
||||
#ifdef LOVE_ANDROID
|
||||
# ifndef ALC_SOFT_pause_device
|
||||
typedef void (ALC_APIENTRY*LPALCDEVICEPAUSESOFT)(ALCdevice *device);
|
||||
@@ -188,6 +202,9 @@ private:
|
||||
#endif
|
||||
}; // Audio
|
||||
|
||||
void pushAudioSuspendEvent();
|
||||
void pushAudioResetEvent();
|
||||
|
||||
#ifdef ALC_EXT_EFX
|
||||
// Effect objects
|
||||
extern LPALGENEFFECTS alGenEffects;
|
||||
|
||||
@@ -245,6 +245,35 @@ bool System::restartApp() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::installApk(const char *path) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::installApk(path);
|
||||
#else
|
||||
LOVE_UNUSED(path);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::updateAppShortcuts(versions);
|
||||
#else
|
||||
LOVE_UNUSED(versions);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string System::getLaunchGame() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::getLaunchGame();
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent, const char *accept) const
|
||||
{
|
||||
@@ -274,6 +303,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpRequest(const char *url, const char *method,
|
||||
const char *const *headerPairs, int headerPairCount,
|
||||
const char *body, int bodyLen, const char *userAgent,
|
||||
std::string &out) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
|
||||
body, bodyLen, userAgent, out);
|
||||
#else
|
||||
LOVE_UNUSED(url);
|
||||
LOVE_UNUSED(method);
|
||||
LOVE_UNUSED(headerPairs);
|
||||
LOVE_UNUSED(headerPairCount);
|
||||
LOVE_UNUSED(body);
|
||||
LOVE_UNUSED(bodyLen);
|
||||
LOVE_UNUSED(userAgent);
|
||||
out.clear();
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -143,6 +143,12 @@ public:
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
/** Starts Android's user-confirmed install flow for a verified APK. */
|
||||
virtual bool installApk(const char *path) const;
|
||||
|
||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||
virtual std::string getLaunchGame() const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into an absolute host path (Android only; false
|
||||
* elsewhere). Android has no curl, which is what every other platform
|
||||
@@ -159,6 +165,18 @@ public:
|
||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS request with a method, headers and a byte body (Android
|
||||
* only; false elsewhere). Save sync needs PUT, auth headers and the body
|
||||
* of a 4xx, none of which the two bridges above can express. headerPairs
|
||||
* is a flat name, value array; `out` receives the response envelope
|
||||
* ("STATUS <code>" or "ERROR <text>", a newline, then the raw body).
|
||||
**/
|
||||
virtual bool httpRequest(const char *url, const char *method,
|
||||
const char *const *headerPairs, int headerPairCount,
|
||||
const char *body, int bodyLen, const char *userAgent,
|
||||
std::string &out) const;
|
||||
|
||||
/**
|
||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
#include "wrap_System.h"
|
||||
#include "sdl/System.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace system
|
||||
@@ -129,6 +132,13 @@ int w_restartApp(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_installApk(lua_State *L)
|
||||
{
|
||||
const char *path = luaL_checkstring(L, 1);
|
||||
luax_pushboolean(L, instance()->installApk(path));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpDownload(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
@@ -150,6 +160,57 @@ int w_httpPost(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
|
||||
*
|
||||
* `headers` is a flat array of alternating header name and value strings, so
|
||||
* it maps straight onto the Java bridge's String[] without any parsing here.
|
||||
* The single return is the response envelope -- a head line of
|
||||
* "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
|
||||
* where the build has no bridge, which src/core/HostShell.lua turns into an
|
||||
* "update the app" notice rather than a failed request.
|
||||
*/
|
||||
int w_httpRequest(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
const char *method = luaL_optstring(L, 2, "GET");
|
||||
|
||||
std::vector<std::string> fields;
|
||||
if (!lua_isnoneornil(L, 3))
|
||||
{
|
||||
luaL_checktype(L, 3, LUA_TTABLE);
|
||||
size_t count = luax_objlen(L, 3);
|
||||
for (size_t i = 1; i <= count; i++)
|
||||
{
|
||||
lua_rawgeti(L, 3, (int) i);
|
||||
const char *field = lua_tostring(L, -1);
|
||||
fields.push_back(field != nullptr ? field : "");
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
std::vector<const char *> pairs;
|
||||
for (size_t i = 0; i < fields.size(); i++)
|
||||
pairs.push_back(fields[i].c_str());
|
||||
|
||||
size_t bodyLen = 0;
|
||||
const char *body = nullptr;
|
||||
if (!lua_isnoneornil(L, 4))
|
||||
body = luaL_checklstring(L, 4, &bodyLen);
|
||||
const char *ua = luaL_optstring(L, 5, nullptr);
|
||||
|
||||
std::string out;
|
||||
bool ok = instance()->httpRequest(url, method,
|
||||
pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(),
|
||||
body, (int) bodyLen, ua, out);
|
||||
if (!ok)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushlstring(L, out.data(), out.size());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -229,6 +290,34 @@ int w_tlsClose(lua_State *L)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w_updateShortcuts(lua_State *L)
|
||||
{
|
||||
if (!lua_istable(L, 1))
|
||||
return luaL_error(L, "Expected table of game version strings");
|
||||
|
||||
std::vector<std::string> versions;
|
||||
int len = (int) luax_objlen(L, 1);
|
||||
for (int i = 1; i <= len; ++i)
|
||||
{
|
||||
lua_rawgeti(L, 1, i);
|
||||
if (lua_isstring(L, -1))
|
||||
versions.push_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
luax_pushboolean(L, instance()->updateShortcuts(versions));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_getLaunchGame(lua_State *L)
|
||||
{
|
||||
std::string game = instance()->getLaunchGame();
|
||||
if (game.empty())
|
||||
lua_pushnil(L);
|
||||
else
|
||||
luax_pushstring(L, game);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const luaL_Reg functions[] =
|
||||
{
|
||||
{ "getOS", w_getOS },
|
||||
@@ -243,8 +332,12 @@ static const luaL_Reg functions[] =
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "installApk", w_installApk },
|
||||
{ "updateShortcuts", w_updateShortcuts },
|
||||
{ "getLaunchGame", w_getLaunchGame },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpPost", w_httpPost },
|
||||
{ "httpRequest", w_httpRequest },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve Oboe
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Android version(s):
|
||||
Android device(s):
|
||||
Oboe version:
|
||||
App name used for testing:
|
||||
(Please try to reproduce the issue using the OboeTester or an Oboe sample.)
|
||||
|
||||
**Short description**
|
||||
(Please only report one bug per Issue. Do not combine multiple bugs.)
|
||||
|
||||
**Steps to reproduce**
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
**Actual behavior**
|
||||
|
||||
**Device**
|
||||
|
||||
Please list which devices have this bug.
|
||||
If device specific, and you are on Linux or a Macintosh, connect the device and please share the result for the following script. This gets properties of the device.
|
||||
|
||||
```
|
||||
for p in \
|
||||
ro.product.brand ro.product.manufacturer ro.product.model \
|
||||
ro.product.device ro.product.cpu.abi ro.build.description \
|
||||
ro.hardware ro.hardware.chipname ro.arch "| grep aaudio";
|
||||
do echo "$p = $(adb shell getprop $p)"; done
|
||||
```
|
||||
|
||||
**Any additional context**
|
||||
|
||||
If applicable, please attach a few seconds of an uncompressed recording of the sound in a WAV or AIFF file.
|
||||
@@ -0,0 +1,36 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
version: 2
|
||||
|
||||
#Workaround for https://github.com/dependabot/dependabot-core/issues/6888#issuecomment-1539501116
|
||||
registries:
|
||||
maven-google:
|
||||
type: maven-repository
|
||||
url: "https://dl.google.com/dl/android/maven2/"
|
||||
|
||||
updates:
|
||||
#Check for updates to Github Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" #Location of package manifests
|
||||
target-branch: "main"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "dependencies/github-actions"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
#Check updates for Gradle dependencies
|
||||
- package-ecosystem: "gradle"
|
||||
registries:
|
||||
- maven-google
|
||||
directory: "/" #Location of package manifests
|
||||
target-branch: "main"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "dependencies/gradle"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Build CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
- name: build samples and apps
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: cpp
|
||||
- run: |
|
||||
pushd samples
|
||||
chmod +x gradlew
|
||||
./gradlew -q clean bundleDebug
|
||||
popd
|
||||
pushd apps/OboeTester
|
||||
chmod +x gradlew
|
||||
./gradlew -q clean bundleDebug
|
||||
popd
|
||||
pushd apps/fxlab
|
||||
chmod +x gradlew
|
||||
./gradlew -q clean bundleDebug
|
||||
popd
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Update Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Doxygen Action
|
||||
uses: mattnotmitt/doxygen-action@v1.9.8
|
||||
with:
|
||||
doxyfile-path: "./Doxyfile"
|
||||
working-directory: "."
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/reference
|
||||
@@ -4,3 +4,5 @@
|
||||
.cxx/
|
||||
.idea
|
||||
build
|
||||
.logpile
|
||||
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
LOCAL_PATH:= $(call my-dir)
|
||||
include $(CLEAR_VARS)
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
LOCAL_MODULE := oboe
|
||||
LOCAL_ARM_NEON := true
|
||||
|
||||
#
|
||||
# Flags
|
||||
#
|
||||
LOCAL_CFLAGS := -Wall -Wextra-semi -Wshadow -Wshadow-field
|
||||
LOCAL_CPPFLAGS := -std=c++14
|
||||
LOCAL_CPPFLAGS := -std=c++17
|
||||
|
||||
#
|
||||
# Include paths
|
||||
#
|
||||
LOCAL_C_INCLUDES := \
|
||||
$(LOCAL_PATH)/include \
|
||||
$(LOCAL_PATH)/src
|
||||
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)/include
|
||||
|
||||
#
|
||||
# Source files
|
||||
#
|
||||
LOCAL_SRC_FILES := \
|
||||
src/aaudio/AAudioLoader.cpp \
|
||||
src/aaudio/AudioStreamAAudio.cpp \
|
||||
src/common/AdpfWrapper.cpp \
|
||||
src/common/AudioSourceCaller.cpp \
|
||||
src/common/AudioStream.cpp \
|
||||
src/common/AudioStreamBuilder.cpp \
|
||||
@@ -36,8 +25,11 @@ LOCAL_SRC_FILES := \
|
||||
src/common/FixedBlockReader.cpp \
|
||||
src/common/FixedBlockWriter.cpp \
|
||||
src/common/LatencyTuner.cpp \
|
||||
src/common/OboeExtensions.cpp \
|
||||
src/common/SourceFloatCaller.cpp \
|
||||
src/common/SourceI16Caller.cpp \
|
||||
src/common/SourceI24Caller.cpp \
|
||||
src/common/SourceI32Caller.cpp \
|
||||
src/common/Utilities.cpp \
|
||||
src/common/QuirksManager.cpp \
|
||||
src/fifo/FifoBuffer.cpp \
|
||||
@@ -45,17 +37,26 @@ LOCAL_SRC_FILES := \
|
||||
src/fifo/FifoControllerBase.cpp \
|
||||
src/fifo/FifoControllerIndirect.cpp \
|
||||
src/flowgraph/FlowGraphNode.cpp \
|
||||
src/flowgraph/ChannelCountConverter.cpp \
|
||||
src/flowgraph/ClipToRange.cpp \
|
||||
src/flowgraph/Limiter.cpp \
|
||||
src/flowgraph/ManyToMultiConverter.cpp \
|
||||
src/flowgraph/MonoBlend.cpp \
|
||||
src/flowgraph/MonoToMultiConverter.cpp \
|
||||
src/flowgraph/MultiToManyConverter.cpp \
|
||||
src/flowgraph/MultiToMonoConverter.cpp \
|
||||
src/flowgraph/RampLinear.cpp \
|
||||
src/flowgraph/SampleRateConverter.cpp \
|
||||
src/flowgraph/SinkFloat.cpp \
|
||||
src/flowgraph/SinkI16.cpp \
|
||||
src/flowgraph/SinkI24.cpp \
|
||||
src/flowgraph/SinkI32.cpp \
|
||||
src/flowgraph/SinkI8_24.cpp \
|
||||
src/flowgraph/SourceFloat.cpp \
|
||||
src/flowgraph/SourceI16.cpp \
|
||||
src/flowgraph/SourceI24.cpp \
|
||||
src/flowgraph/SourceI32.cpp \
|
||||
src/flowgraph/SourceI8_24.cpp \
|
||||
src/flowgraph/resampler/IntegerRatio.cpp \
|
||||
src/flowgraph/resampler/LinearResampler.cpp \
|
||||
src/flowgraph/resampler/MultiChannelResampler.cpp \
|
||||
@@ -75,10 +76,6 @@ LOCAL_SRC_FILES := \
|
||||
src/common/Trace.cpp \
|
||||
src/common/Version.cpp
|
||||
|
||||
#
|
||||
# Libraries related
|
||||
#
|
||||
LOCAL_LDLIBS := -llog
|
||||
|
||||
# Build
|
||||
include $(BUILD_STATIC_LIBRARY)
|
||||
|
||||
@@ -9,6 +9,7 @@ project(oboe)
|
||||
set (oboe_sources
|
||||
src/aaudio/AAudioLoader.cpp
|
||||
src/aaudio/AudioStreamAAudio.cpp
|
||||
src/common/AdpfWrapper.cpp
|
||||
src/common/AudioSourceCaller.cpp
|
||||
src/common/AudioStream.cpp
|
||||
src/common/AudioStreamBuilder.cpp
|
||||
@@ -18,26 +19,38 @@ set (oboe_sources
|
||||
src/common/FixedBlockReader.cpp
|
||||
src/common/FixedBlockWriter.cpp
|
||||
src/common/LatencyTuner.cpp
|
||||
src/common/OboeExtensions.cpp
|
||||
src/common/SourceFloatCaller.cpp
|
||||
src/common/SourceI16Caller.cpp
|
||||
src/common/SourceI24Caller.cpp
|
||||
src/common/SourceI32Caller.cpp
|
||||
src/common/Utilities.cpp
|
||||
src/common/QuirksManager.cpp
|
||||
src/fifo/FifoBuffer.cpp
|
||||
src/fifo/FifoController.cpp
|
||||
src/fifo/FifoControllerBase.cpp
|
||||
src/fifo/FifoControllerIndirect.cpp
|
||||
src/flowgraph/FlowGraphNode.cpp
|
||||
src/flowgraph/FlowGraphNode.cpp
|
||||
src/flowgraph/ChannelCountConverter.cpp
|
||||
src/flowgraph/ClipToRange.cpp
|
||||
src/flowgraph/Limiter.cpp
|
||||
src/flowgraph/ManyToMultiConverter.cpp
|
||||
src/flowgraph/MonoBlend.cpp
|
||||
src/flowgraph/MonoToMultiConverter.cpp
|
||||
src/flowgraph/MultiToManyConverter.cpp
|
||||
src/flowgraph/MultiToMonoConverter.cpp
|
||||
src/flowgraph/RampLinear.cpp
|
||||
src/flowgraph/SampleRateConverter.cpp
|
||||
src/flowgraph/SinkFloat.cpp
|
||||
src/flowgraph/SinkI16.cpp
|
||||
src/flowgraph/SinkI24.cpp
|
||||
src/flowgraph/SinkI32.cpp
|
||||
src/flowgraph/SinkI8_24.cpp
|
||||
src/flowgraph/SourceFloat.cpp
|
||||
src/flowgraph/SourceI16.cpp
|
||||
src/flowgraph/SourceI24.cpp
|
||||
src/flowgraph/SourceI32.cpp
|
||||
src/flowgraph/SourceI8_24.cpp
|
||||
src/flowgraph/resampler/IntegerRatio.cpp
|
||||
src/flowgraph/resampler/LinearResampler.cpp
|
||||
src/flowgraph/resampler/MultiChannelResampler.cpp
|
||||
@@ -70,18 +83,23 @@ target_include_directories(oboe
|
||||
# Enable -Ofast
|
||||
target_compile_options(oboe
|
||||
PRIVATE
|
||||
-std=c++14
|
||||
-std=c++17
|
||||
-Wall
|
||||
-Wextra-semi
|
||||
-Wshadow
|
||||
-Wshadow-field
|
||||
-Ofast
|
||||
"$<$<CONFIG:RELEASE>:-Ofast>"
|
||||
"$<$<CONFIG:DEBUG>:-O3>"
|
||||
"$<$<CONFIG:DEBUG>:-Werror>")
|
||||
|
||||
# Enable logging of D,V for debug builds
|
||||
target_compile_definitions(oboe PUBLIC $<$<CONFIG:DEBUG>:OBOE_ENABLE_LOGGING=1>)
|
||||
|
||||
option(OBOE_DO_NOT_DEFINE_OPENSL_ES_CONSTANTS "Do not define OpenSLES constants" OFF)
|
||||
target_compile_definitions(oboe PRIVATE $<$<BOOL:${OBOE_DO_NOT_DEFINE_OPENSL_ES_CONSTANTS}>:DO_NOT_DEFINE_OPENSL_ES_CONSTANTS=1>)
|
||||
|
||||
target_link_libraries(oboe PRIVATE log OpenSLES)
|
||||
target_link_options(oboe PRIVATE "-Wl,-z,max-page-size=16384")
|
||||
|
||||
# When installing oboe put the libraries in the lib/<ABI> folder e.g. lib/arm64-v8a
|
||||
install(TARGETS oboe
|
||||
@@ -89,4 +107,4 @@ install(TARGETS oboe
|
||||
ARCHIVE DESTINATION lib/${ANDROID_ABI})
|
||||
|
||||
# Also install the headers
|
||||
install(DIRECTORY include/oboe DESTINATION include)
|
||||
install(DIRECTORY include/oboe DESTINATION include)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Please see the CONTRIBUTING.md file for more information.
|
||||