Compare commits

..

16 Commits

Author SHA1 Message Date
bryanthaboi 895e904d75 too many comments 2026-07-23 14:25:00 -04:00
bryanthaboi 1ce9e32dd1 frame cap update 2026-07-23 14:20:23 -04:00
bryanthaboi 352a9a0eb8 Merge branch 'graphics-and-stutters-fixes' 2026-07-23 12:20:00 -04:00
bryanthaboi 5533785364 Create gen1recomp_cover.png 2026-07-23 12:19:47 -04:00
bryanthaboi 11f4f5570e Merge pull request #108 from bryanthaboi/graphics-and-stutters-fixes
MAJOR UPDATE
2026-07-23 11:51:59 -04:00
bryanthaboi 6239387643 renamed 2026-07-23 11:49:45 -04:00
bryanthaboi 4a50cb0728 hopefulyl a soltuion to broken end game saves 2026-07-23 11:29:58 -04:00
bryanthaboi 78aa1f3c0a add blue 2026-07-23 11:14:40 -04:00
bryanthaboi 5128b840b1 launcher spruce p2 2026-07-23 09:44:09 -04:00
bryanthaboi da14b7ee3f lauuncher spruce up part 1 2026-07-23 09:13:00 -04:00
bryanthaboi 2dbe21babf map stutter p2 2026-07-23 08:29:04 -04:00
bryanthaboi 7a85862602 graphics and stutters part 1 2026-07-23 07:59:09 -04:00
bryanthaboi 09b12a7cf0 Fixes portable windows and unlocks orientation on android
Fixes portable windows
and unlocks orientation on android
2026-07-22 15:44:14 -04:00
bryanthaboi e2f2b432ff bazinga moment 2 2026-07-22 15:42:19 -04:00
bryanthaboi 8c274d969e Merge pull request #77 from bryanthaboi/portablemodefix
PORTABLE MODE
2026-07-22 14:20:00 -04:00
bryanthaboi 4c7633b56e PORTABLE MODE 2026-07-22 14:18:39 -04:00
46 changed files with 48685 additions and 1368 deletions
+10 -10
View File
@@ -177,8 +177,8 @@ jobs:
set -a; . "$ci_dir/notary.env"; set +a
echo "::add-mask::$APPLE_APP_PASSWORD"
app=".bazinga/work/PokemonRed.app"
zip="dist/mac/PokemonRed-macos.zip"
app=".bazinga/work/gen1recomp.app"
zip="dist/mac/gen1recomp-macos.zip"
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
@@ -208,12 +208,12 @@ jobs:
outdir="dist/release"
rm -rf "$outdir"
mkdir -p "$outdir"
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
cp "dist/linux/PokemonRed-linux.zip" "$outdir/PokemonRed-${v}-linux.zip"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/PokemonRed-${v}-android.apk"
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ls -lh "$outdir"
- name: Publish GitHub Release
@@ -263,10 +263,10 @@ jobs:
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/PokemonRed-${v}-macos.zip" \
"dist/release/PokemonRed-${v}-windows.zip" \
"dist/release/PokemonRed-${v}-linux.zip" \
"dist/release/PokemonRed-${v}-android.apk"
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk"
echo "Published release $tag"
+9 -7
View File
@@ -50,18 +50,20 @@ developer data build, test suites, and cache management are covered in
By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the
executable (or next to `main.lua`/`conf.lua` when running from source), then
launch the game.
between computers), drop an empty file named `portable.txt` next to the app
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package.
With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory.
- After a ROM import, the generated `data/generated` and `assets/generated`
cache is copied into that folder too, so a later launch (even on a
different computer, as long as the same folder comes along) reuses it
without asking for the ROM again.
- A ROM import writes the generated `data/generated` and `assets/generated`
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

+20 -7
View File
@@ -23,9 +23,13 @@ function love.conf(t)
-- back to the plain title if the source is not mounted yet
local ok, Version = pcall(require, "src.core.Version")
t.window.title = ok and Version.title()
or "Pokemon Red (Gen 1 Recompilation Project)"
t.window.width = 160 * 4
t.window.height = 144 * 4
or "gen1recomp"
-- Open at the launcher's design size (the split-screen ROM selector is
-- laid out for 1024x768). The window is resizable and the 160x144 game
-- canvas letterboxes into whatever size it ends up, so this only sets the
-- starting size, not the game's resolution.
t.window.width = 1024
t.window.height = 768
end
t.version = "11.5"
t.window.vsync = 1
@@ -37,10 +41,19 @@ function love.conf(t)
local osName = love._os
local mobile = osName == "Android" or osName == "iOS"
if mobile then
-- On Android/iOS, width/height aspect picks portrait vs landscape
-- (fullscreen alone is not enough). Use a tall portrait size; the
-- OS then resizes to the real display. highdpi is required for
-- Retina iOS (Android always behaves as highdpi).
-- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
-- display and rotations resize again. highdpi is required for Retina iOS
-- (Android always behaves as highdpi).
t.window.width = 1080
t.window.height = 1920
t.window.fullscreen = true
+34
View File
@@ -712,6 +712,29 @@ M.HALL_OF_FAME = {
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
if toggles then toggles.HALLOFFAME_OAK = nil end
-- Make the Hall of Fame recording machine interactable. The console
-- juts from the north wall as the two solid cells (4,1) and (5,1); a
-- sign on each lets the player face the machine (from below, or from
-- either side) and press A to run the TEXT_HALLOFFAME_PC talk script.
-- maps.lua is generated from the ROM and gitignored, so the sign is
-- injected here (a tracked script) rather than baked into map data.
local def = ow.map.def
def.signs = def.signs or {}
local present = false
for _, s in ipairs(def.signs) do
if s.text == "TEXT_HALLOFFAME_PC" then present = true break end
end
if not present then
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 4, y = 1 })
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 5, y = 1 })
end
-- the live Map instance built its signAt lookup from def.signs before
-- this hook ran, so rebuild it (idempotent) to pick up the injection
ow.map.signAt = {}
for _, s in ipairs(def.signs) do
ow.map.signAt[s.y * ow.map.widthCells + s.x] = s
end
if not game.save.pendingHallOfFame then return end
game.save.pendingHallOfFame = false
ow:queueScript({
@@ -725,6 +748,17 @@ M.HALL_OF_FAME = {
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
})
end,
talk = {
-- The recording machine doubles as a "warp home" PC: a YES/NO prompt
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
},
},
}
-- -------------------------------------------------------------------
+24 -6
View File
@@ -390,24 +390,42 @@ M.GAME_CORNER = {
},
}
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
local PRIZES = {
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(PRIZES) do
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
+25 -10
View File
@@ -68,19 +68,31 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the global shade-remap
display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**:
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
The first three are the real colorizations; the rest are DMG-shade novelties:
- **GBC** (default): current SGB / GBC zone palettes.
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`).
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **GBC INV**: each SGB zone palette with shade order reversed.
- **SGB INV**: each SGB zone palette with shade order reversed.
- **CLASSIC**: original Game Boy pea-soup greens
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
The transform is applied centrally in `PaletteFX.sendColors`, so it covers
overworld, menus, battles, and tilt upright billboards. Persisted as
`save.options.colors`.
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
## GBC FX
@@ -132,7 +144,10 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
+126 -13
View File
@@ -26,7 +26,19 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
local function bootGame()
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
-- overlay its extracted cache BEFORE anything requires generated data, so
-- data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
Game = require("src.core.Game")
Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then
@@ -68,20 +80,46 @@ function love.load(args)
end
local RomImporter = require("src.import.RomImporter")
if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then
Importer = RomImporter.new(function()
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame()
end)
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
if importPath then Importer:startPath(importPath) end
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
-- Red); the launcher's per-column choice does not apply to them.
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
local ready = RomImporter.isReady(scriptedVersion)
-- Scripted / headless runs have to reach the game with no human pressing
-- Play: an autopilot, a frame driver, an import-only build step, or an
-- explicit ROM path all bypass the interactive launcher and keep today's
-- import-then-boot (or boot-straight-in) behavior.
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
if scripted then
if forceImport or not ready then
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
-- passes it to onComplete; boot that version.
Importer = RomImporter.new(function(version)
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame(version or scriptedVersion)
end)
if importPath then Importer:startPath(importPath) end
return
end
bootGame(scriptedVersion)
return
end
bootGame()
-- Interactive: the launcher always runs. Red and Blue are each live: a
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end
function love.update(dt)
@@ -261,3 +299,78 @@ function love.filedropped(file)
end
if Importer then Importer:filedropped(file) end
end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+3 -1
View File
@@ -6,7 +6,9 @@
#app.name=LÖVE for Android
app.application_id=com.theboisclub.pokemonred
app.orientation=portrait
# fullUser: allow every orientation the player's device permits (portrait and
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
app.orientation=fullUser
app.version_code=32
app.version_name=11.5a
+5
View File
@@ -62,10 +62,15 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
+6 -6
View File
@@ -8,12 +8,12 @@
# [--notary-profile NAME] [--no-notarize]
# [--release] # ios only: release config instead of debug
#
# Output: dist/mac/PokemonRed-macos.zip
# dist/win/PokemonRed-win64.zip
# dist/linux/PokemonRed-linux.zip (fused x86_64 AppImage)
# Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/)
set -euo pipefail
@@ -25,7 +25,7 @@ WORK="$HERE/work"
DIST="$ROOT/dist"
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
APP_NAME="PokemonRed"
APP_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
@@ -60,7 +60,7 @@ say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
+3 -3
View File
@@ -22,7 +22,7 @@ ANDROID_DIR="$ROOT/mobile/android"
EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets"
LOVE_FILE="$EMBED_ASSETS/game.love"
DIST="$ROOT/dist/android"
APP_NAME="Pokemon Red"
APP_NAME="gen1recomp"
APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653"
@@ -101,7 +101,7 @@ def set_prop(text, key, value):
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
text = set_prop(text, "app.name", name)
text = set_prop(text, "app.application_id", app_id)
text = set_prop(text, "app.orientation", "portrait")
text = set_prop(text, "app.orientation", "fullUser")
if version:
text = set_prop(text, "app.version_name", version)
text = set_prop(text, "app.version_code", version_code)
@@ -137,7 +137,7 @@ pack_game_love() {
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
+6 -6
View File
@@ -15,8 +15,8 @@
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/PokemonRed.app (convenience copy)
# mobile/ios/build/Build/Products/<Config>-<sdk>/PokemonRed.app
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
set -euo pipefail
@@ -33,8 +33,8 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
LOVE_FILE="$RESOURCES_DIR/game.love"
LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="PokemonRed"
DISPLAY_NAME="Pokemon Red"
APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
@@ -155,7 +155,7 @@ require_ios_libraries
apply_ios_branding() {
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
local dest="$XCODE_DIR/ios/love-ios.plist"
say "applying iOS branding (portrait-only Info.plist, display name)"
say "applying iOS branding (portrait + landscape Info.plist, display name)"
cp "$OVERLAY_PLIST" "$dest"
}
@@ -166,7 +166,7 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
+19
View File
@@ -144,6 +144,25 @@ function AnimPlayer.new(data)
}, AnimPlayer)
end
-- Release the tilesheet images and quads this player built. They live in
-- per-instance caches (a fresh AnimPlayer is made per battle), so unlike a
-- shared module cache they are dead the moment the battle ends -- freeing
-- them here instead of waiting on a GC finalizer keeps grinding battles
-- from piling orphaned VRAM up faster than the (Lua-heap-triggered) GC
-- reclaims it.
function AnimPlayer:release()
for _, img in pairs(self.images) do
if img and img.release then pcall(img.release, img) end
end
self.images = {}
for _, byTile in pairs(self.quads) do
for _, q in pairs(byTile) do
if q and q.release then pcall(q.release, q) end
end
end
self.quads = {}
end
function AnimPlayer:warnOnce(key, fmt, ...)
if not self.warned[key] then
self.warned[key] = true
+31 -1
View File
@@ -934,6 +934,20 @@ end
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
-- module caches (imageCache/imagePadBottom, keyed by path+palette) are
-- reused by the next battle, so they are deliberately left alone -- only
-- the per-instance objects, which are dead once this battle is popped,
-- are released here.
local function rel(o) if o and o.release then pcall(o.release, o) end end
rel(self.bgCanvas); self.bgCanvas = nil
rel(self.waveCanvas); self.waveCanvas = nil
self.colorFxReady = nil
if self.animPlayer and self.animPlayer.release then
self.animPlayer:release()
end
end
-- An action the battler is locked into (bypasses the menu), or nil.
@@ -3551,12 +3565,28 @@ function BattleState:sgbBattlePals()
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
return {
local out = {
[0] = bar(self.player),
[1] = bar(self.enemy),
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
[3] = mon(self.enemy, self.showEnemyTrainer),
}
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
-- white paper, black ink -- so every zone shares the same background and
-- outline; only the two mid shades differ per element (green HP bar, red
-- mon pic). The bar/base zones otherwise carry the SGB off-white
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
if PaletteFX.mode == "ogred" then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
out[i] = { white, c[2], c[3], black }
end
end
return out
end
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
+282 -801
View File
File diff suppressed because it is too large Load Diff
+760
View File
@@ -0,0 +1,760 @@
-- Pure Game Boy audio synthesis (the DMG/GBC channel-program interpreter and
-- PCM renderer), factored out of ChipAudio so it can run on EITHER the main
-- thread (SFX/cries, and the synchronous music fallback) or the ChipAudio
-- worker thread (src/core/chip_worker.lua), which is where map/battle music is
-- synthesized so a song change never stutters the render thread.
--
-- Deliberately depends ONLY on `bit`, love.sound and love.filesystem: no
-- love.audio (Sources are a playback concern the caller owns) and no
-- src.render.Assets (hot-reload registration stays in ChipAudio). Both of
-- those are unavailable or main-thread-only inside a love.thread worker, so
-- keeping them out is what lets the same synth code run in the worker.
local bit = require("bit")
local ChipSynth = {}
local SAMPLE_RATE = 22050
local TICKS_PER_SECOND = 15360
local FRAME_TICKS = 256
local GB_CLOCK = 4194304
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
-- (~6s) for stall tolerance (window resize, a long GC pause)
local MUSIC_BUFFER_SAMPLES = 4096
local MUSIC_BUFFER_COUNT = 32
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
}
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
local NOISE_DIVISORS = {
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
}
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
end
local cachedProgramFile
local cachedBanks
local function loadBanks(data)
local audio = data.audio
if cachedProgramFile == audio.programFile and cachedBanks then
return cachedBanks
end
local raw, readError = love.filesystem.read(audio.programFile)
if not raw then error("could not read sound programs: " .. tostring(readError)) end
local banks = {}
for index, bank in ipairs(audio.bankOrder) do
local first = (index - 1) * 0x4000 + 1
banks[bank] = raw:sub(first, first + 0x3FFF)
end
cachedProgramFile, cachedBanks = audio.programFile, banks
return banks
end
-- drop the single-slot bank cache; the worker keeps its own copy of this
-- module's state, so ChipAudio.invalidate must reach it via a worker message
function ChipSynth.invalidateBanks()
cachedProgramFile, cachedBanks = nil, nil
end
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
-- target work unchanged. The ROM's own cached bank table is never touched
-- because bank 0 differs per def, and a blob that carries its own waves and
-- drums renders even where programs.bin is unreadable.
local function engineBanks(data, chip)
if not chip then return loadBanks(data) end
local banks = {}
local ok, romBanks = pcall(loadBanks, data)
if ok then
for bank, bytes in pairs(romBanks) do banks[bank] = bytes end
end
banks[0] = chip.blob
return banks
end
local function romByte(banks, bank, address)
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
local value = bytes:byte(address - 0x4000 + 1)
if not value then
error(("audio read outside bank %02X:%04X"):format(bank, address))
end
return value
end
local function romWord(banks, bank, address)
return romByte(banks, bank, address)
+ romByte(banks, bank, address + 1) * 0x100
end
local function headerChannels(banks, header)
local channels = {}
local address = header.address
local first = romByte(banks, header.bank, address)
local count = bit.rshift(bit.band(first, 0xF0), 6) + 1
for _ = 1, count do
local descriptor = romByte(banks, header.bank, address)
channels[#channels + 1] = {
number = bit.band(descriptor, 0x0F) + 1,
address = romWord(banks, header.bank, address + 1),
}
address = address + 3
end
return channels
end
local function fadeValue(nibble)
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
return nibble
end
local Channel = {}
Channel.__index = Channel
function Channel.new(engine, spec, options)
options = options or {}
local hardware = (spec.number - 1) % 4 + 1
local isSfxChannel = spec.number > 4
return setmetatable({
engine = engine,
bank = options.bank,
address = spec.address,
number = spec.number,
hardware = hardware,
wave = hardware == 3,
noise = hardware == 4,
sfx = isSfxChannel,
executeMusic = not isSfxChannel,
allowLoops = options.allowLoops ~= false,
frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS,
speed = 12,
volume = 12,
fade = 0,
duty = 0.5,
octave = 4,
waveInstrument = 0,
waveLevel = 1,
perfectPitch = false,
vibrato = nil,
pendingSlide = nil,
sweep = nil,
callStack = {},
loopCounts = {},
event = nil,
ended = false,
phase = 0,
noiseLfsr = 0x7FFF,
noiseClock = 0,
timeTicks = 0,
}, Channel)
end
function Channel:byte()
local value = romByte(self.engine.banks, self.bank, self.address)
self.address = self.address + 1
return value
end
function Channel:word()
local value = romWord(self.engine.banks, self.bank, self.address)
self.address = self.address + 2
return value
end
function Channel:frequency(note, octave)
local signed = PITCHES[note + 1] - 0x10000
local register = bit.band(
bit.arshift(signed, math.max(0, (octave or self.octave) - 1)), 0x7FF)
if self.perfectPitch then register = bit.band(register + 1, 0x7FF) end
return bit.band(register + self.frequencyOffset, 0x7FF)
end
function Channel:durationTicks(length)
local tempo = self.sfx and self.frameTicks or self.engine.tempo
local speed = self.sfx and (self.executeMusic and self.speed or 1)
or self.speed
return length * speed * tempo
end
function Channel:timedEvent(event, ticks)
local first = snapTicks(self.timeTicks)
self.timeTicks = self.timeTicks + ticks
event.duration = ticks / TICKS_PER_SECOND
event.samples = snapTicks(self.timeTicks) - first
event.sample = 0
event.elapsed = 0
return event
end
function Channel:pan()
local mask = bit.lshift(1, self.hardware - 1)
return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0,
bit.band(self.engine.pan, mask) ~= 0
end
function Channel:tone(ticks, register, volume, fade)
if register >= 0x800 then
return self:timedEvent({ silence = true }, ticks)
end
local duration = ticks / TICKS_PER_SECOND
local panLeft, panRight = self:pan()
local slide
if self.pendingSlide then
slide = {
target = self.pendingSlide.target,
frames = math.max(1, duration * 60 - self.pendingSlide.length),
}
self.pendingSlide = nil
end
return self:timedEvent({
register = register,
volume = volume == nil and self.volume or volume,
fade = fade == nil and self.fade or fade,
duty = self.duty,
wave = self.wave,
waveInstrument = self.waveInstrument,
waveLevel = self.waveLevel,
vibrato = slide and nil or self.vibrato,
slide = slide,
sweep = self.sfx and self.hardware == 1 and self.sweep or nil,
panLeft = panLeft,
panRight = panRight,
}, ticks)
end
function Channel:noiseEvent(ticks, volume, fade, parameter)
local panLeft, panRight = self:pan()
return self:timedEvent({
noise = true,
volume = volume or self.volume,
fade = fade or 0,
noiseParameter = parameter,
panLeft = panLeft, panRight = panRight,
}, ticks)
end
function Channel:drumEvent(ticks, instrument)
local panLeft, panRight = self:pan()
return self:timedEvent({
noise = true,
drum = self.engine:noiseInstrument(instrument),
panLeft = panLeft,
panRight = panRight,
}, ticks)
end
function Channel:silenceEvent(ticks)
return self:timedEvent({ silence = true }, ticks)
end
function Channel:nextEvent()
if self.ended then return nil end
for _ = 1, 100000 do
local commandAddress = self.address
local command = self:byte()
if (self.executeMusic or not self.sfx) and command < 0xC0 then
local note = bit.rshift(command, 4)
local length = bit.band(command, 0x0F) + 1
if self.noise then
local instrument = note
if command >= 0xB0 then instrument = self:byte() end
return self:drumEvent(self:durationTicks(length), instrument)
end
return self:tone(self:durationTicks(length), self:frequency(note))
elseif command >= 0xC0 and command < 0xD0 then
local length = bit.band(command, 0x0F) + 1
return self:silenceEvent(self:durationTicks(length))
elseif command >= 0xD0 and command < 0xE0 then
self.speed = bit.band(command, 0x0F)
if not self.noise then
local packed = self:byte()
if self.wave then
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
self.waveInstrument = bit.band(packed, 0x0F)
else
self.volume = bit.rshift(packed, 4)
self.fade = fadeValue(bit.band(packed, 0x0F))
end
end
elseif command >= 0xE0 and command <= 0xE7 then
self.octave = 8 - bit.band(command, 7)
elseif command == 0xE8 then
self.perfectPitch = not self.perfectPitch
elseif command == 0xE9 then
-- Unused command.
elseif command == 0xEA then
local delay, packed = self:byte(), self:byte()
local depth = bit.rshift(packed, 4)
if depth == 0 then
self.vibrato = nil
else
self.vibrato = {
delay = delay,
above = bit.rshift(depth, 1) + bit.band(depth, 1),
below = bit.rshift(depth, 1),
rate = bit.band(packed, 0x0F),
}
end
elseif command == 0xEB then
local length, packed = self:byte(), self:byte()
local octave = 8 - bit.rshift(packed, 4)
self.pendingSlide = {
length = length,
target = self:frequency(bit.band(packed, 0x0F), octave),
}
elseif command == 0xEC then
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
elseif command == 0xEE then
self.engine.pan = self:byte()
elseif command == 0xEF or command == 0xF0 then
self:byte()
elseif command == 0xF8 then
self.executeMusic = not self.executeMusic
elseif command == 0xFC then
local packed = self:byte()
self.duty = {
DUTY[bit.band(bit.rshift(packed, 6), 3)],
DUTY[bit.band(bit.rshift(packed, 4), 3)],
DUTY[bit.band(bit.rshift(packed, 2), 3)],
DUTY[bit.band(packed, 3)],
}
elseif command == 0xFD then
self.callStack[#self.callStack + 1] = self.address + 2
self.address = self:word()
elseif command == 0xFE then
local count, target = self:byte(), self:word()
if count == 0 then
if self.allowLoops then
self.address = target
else
self.ended = true
return nil
end
else
local remaining = self.loopCounts[commandAddress]
if remaining == nil then remaining = count end
remaining = remaining - 1
if remaining > 0 then
self.loopCounts[commandAddress] = remaining
self.address = target
else
self.loopCounts[commandAddress] = nil
end
end
elseif command == 0xFF then
local returnAddress = table.remove(self.callStack)
if returnAddress then
self.address = returnAddress
else
self.ended = true
return nil
end
elseif self.sfx and command >= 0x20 and command < 0x30 then
local length = bit.band(command, 0x0F) + 1
local packed = self:byte()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = self:byte()
return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter)
end
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
return self:tone(self:durationTicks(length), register, volume, fade)
elseif command == 0x10 then
local packed = self:byte()
self.sweep = {
pace = bit.band(bit.rshift(packed, 4), 7),
subtract = bit.band(packed, 8) ~= 0,
shift = bit.band(packed, 7),
}
else
self.ended = true
return nil
end
end
self.ended = true
return nil
end
local function envelopeVolume(volume, fade, elapsed)
if fade == 0 then return volume end
local steps = math.floor(elapsed / (math.abs(fade) / 64))
if fade > 0 then return math.max(0, volume - steps) end
return math.min(15, volume + steps)
end
function Channel:resetNoise()
self.noiseLfsr = 0x7FFF
self.noiseClock = 0
end
function Channel:clockNoise(width7)
local feedback = bit.bxor(
bit.band(self.noiseLfsr, 1),
bit.band(bit.rshift(self.noiseLfsr, 1), 1))
self.noiseLfsr = bit.bor(
bit.rshift(self.noiseLfsr, 1),
bit.lshift(feedback, 14))
if width7 then
self.noiseLfsr = bit.bor(
bit.band(self.noiseLfsr, bit.bnot(0x40)),
bit.lshift(feedback, 6))
end
end
function Channel:sampleNoise(parameter)
parameter = parameter or 0
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
local shift = bit.rshift(parameter, 4)
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
if shift >= 14 then return output end
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
local width7 = bit.band(parameter, 8) ~= 0
local remaining = cycles
local area = 0
while remaining > 0 do
local untilClock = 1 - self.noiseClock
local span = math.min(remaining, untilClock)
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
area = area + output * span
self.noiseClock = self.noiseClock + span
remaining = remaining - span
if self.noiseClock >= 1 - 1e-12 then
self.noiseClock = 0
self:clockNoise(width7)
end
end
return area / cycles
end
local function sweepCalculation(register, sweep)
local delta = math.floor(register / (2 ^ sweep.shift))
if sweep.subtract then return register - delta end
return register + delta
end
local function sweptRegister(register, sweep, elapsed)
if not sweep or sweep.shift == 0 then return register end
local nextRegister = sweepCalculation(register, sweep)
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
if sweep.pace == 0 then return register end
local iterations = math.floor(elapsed * 128 / sweep.pace)
for _ = 1, iterations do
register = nextRegister
nextRegister = sweepCalculation(register, sweep)
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
end
return register
end
function Channel:sampleDrum(event, sampleIndex)
local index = event.drumSegmentIndex or 1
local segment = event.drum[index]
while segment and sampleIndex >= segment.endSample do
index = index + 1
segment = event.drum[index]
end
if not segment or sampleIndex < segment.startSample then return 0 end
if event.drumSegmentIndex ~= index then
event.drumSegmentIndex = index
self:resetNoise()
end
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
end
function Channel:sample()
while not self.ended
and (not self.event or self.event.sample >= self.event.samples) do
self.event = self:nextEvent()
self.phase = 0
self:resetNoise()
end
local event = self.event
if not event then return 0 end
local sampleIndex = event.sample
event.elapsed = sampleIndex / SAMPLE_RATE
event.sample = sampleIndex + 1
if event.silence then return 0 end
if event.drum then return self:sampleDrum(event, sampleIndex) end
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
end
local register = event.register
local frame = math.floor(event.elapsed * 60)
if event.sweep then
register = sweptRegister(register, event.sweep, event.elapsed)
if not register then return 0 end
elseif event.slide then
local amount = math.min(1, frame / event.slide.frames)
register = register + (event.slide.target - register) * amount
elseif event.vibrato and frame >= event.vibrato.delay then
local vibrato = event.vibrato
local toggles = math.floor(
(frame - vibrato.delay + 1) / (vibrato.rate + 1))
if toggles > 0 then
local low = bit.band(register, 0xFF)
local high = bit.band(register, 0x700)
if bit.band(toggles, 1) ~= 0 then
register = high + math.min(0xFF, low + vibrato.above)
else
register = high + math.max(0, low - vibrato.below)
end
end
end
local frequency = 131072 / (2048 - math.min(register, 2047))
if event.wave then frequency = frequency * 0.5 end
local phase = self.phase
self.phase = (phase + frequency / SAMPLE_RATE) % 1
if event.wave then
local wave = self.engine.waves[
math.min(event.waveInstrument + 1, #self.engine.waves)]
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55
end
local duty = event.duty
if type(duty) == "table" then
duty = duty[frame % 4 + 1]
end
return (phase < duty and 1 or -1) * volume / 15 * 0.5
end
local Engine = {}
Engine.__index = Engine
function Engine:noiseInstrument(number)
-- a def-local drum wins over the ROM engine's table for that id
local custom = self.customDrums and self.customDrums[number]
if custom then return custom end
local cached = self.noiseInstruments[number]
if cached then return cached end
local header = self.noiseHeaders[tostring(number)]
local segments = {}
if header then
local spec = headerChannels(self.banks, header)[1]
local address = spec and spec.address
local ticks = 0
for _ = 1, 64 do
local command = romByte(self.banks, header.bank, address)
address = address + 1
if command == 0xFF then break end
if command < 0x20 or command >= 0x30 then
error(("unsupported drum command %02X at %02X:%04X")
:format(command, header.bank, address - 1))
end
local packed = romByte(self.banks, header.bank, address)
local parameter = romByte(self.banks, header.bank, address + 1)
address = address + 2
local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS
segments[#segments + 1] = {
startSample = snapTicks(ticks),
endSample = snapTicks(ticks + duration),
volume = bit.rshift(packed, 4),
fade = fadeValue(bit.band(packed, 0x0F)),
parameter = parameter,
}
ticks = ticks + duration
end
end
self.noiseInstruments[number] = segments
return segments
end
local function readWaves(banks, audio, engineNumber)
local spec = audio.waveBanks[tostring(engineNumber)]
local waves = {}
for wave = 0, 4 do
local values = {}
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + wave * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
end
waves[#waves + 1] = values
end
local values = {}
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
end
for _ = 1, 4 do waves[#waves + 1] = values end
return waves
end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter
local function normalizeWaves(source)
local waves = {}
for index, values in ipairs(source) do
local nibbles = false
for _, value in ipairs(values) do
if value > 1 or value < -1 then nibbles = true break end
end
local wave = {}
for position, value in ipairs(values) do
wave[position] = nibbles and (value - 7.5) / 7.5 or value
end
waves[index] = wave
end
return waves
end
function Engine.new(data, header, options)
options = options or {}
local audio = data.audio or {}
-- shape dispatch: a def-local chip program supplies its own channels and
-- may supply its own waves/drums, falling back to a ROM engine's tables
local chip = header.chip
local banks = engineBanks(data, chip)
local engineNumber = chip and (chip.engine or 1) or header.engine
local waves
if chip and chip.waves then
waves = normalizeWaves(chip.waves)
elseif chip then
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
waves = ok and romWaves or {}
else
waves = readWaves(banks, audio, engineNumber)
end
local engine = setmetatable({
banks = banks,
tempo = 0x100,
pan = 0xFF,
waves = waves,
noiseHeaders = audio.noiseHeaders
and audio.noiseHeaders[tostring(engineNumber)] or {},
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
}, Engine)
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
frameTicks = FRAME_TICKS
elseif options.cryLength then
frameTicks = 0x80 + options.cryLength
end
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
bank = chip and 0 or header.bank,
sfx = options.sfx,
allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset,
frameTicks = frameTicks,
})
end
return engine
end
function Engine:finished()
for _, channel in ipairs(self.channels) do
if not channel.ended or channel.event then return false end
end
return true
end
function Engine:sample()
local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
return math.max(-1, math.min(1, value * 0.5))
end
function Engine:sampleStereo()
local left, right = 0, 0
for _, channel in ipairs(self.channels) do
local value = channel:sample()
local event = channel.event
if not event or event.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end
end
return math.max(-1, math.min(1, left * 0.5)),
math.max(-1, math.min(1, right * 0.5))
end
function Engine:sampleChannel(number)
local selected = 0
for _, channel in ipairs(self.channels) do
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected * 0.5))
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
-- is available on worker threads, so this is the hand-off unit the worker
-- produces and the main thread queues.
local function soundData(engine, samples, channels)
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
for index = 0, samples - 1 do
if channels == 2 then
local left, right = engine:sampleStereo()
result:setSample(index, 1, left)
result:setSample(index, 2, right)
else
result:setSample(index, engine:sample())
end
end
return result
end
-- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is
-- too short to be audible. The caller wraps it in a static love.audio.Source
-- (a playback concern, hence not done here).
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
options.sfx = true
options.allowLoops = false
local engine = Engine.new(data, header, options)
local maximum = SAMPLE_RATE * 5
local values = {}
local count = 0
while count < maximum and not engine:finished() do
count = count + 1
values[count] = engine:sample()
end
if count < math.floor(SAMPLE_RATE / 100) then return nil end
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
for index = 1, count do result:setSample(index - 1, values[index]) end
return result
end
ChipSynth.newEngine = Engine.new
ChipSynth.soundData = soundData
ChipSynth.renderEffectData = renderEffectData
return ChipSynth
+72
View File
@@ -0,0 +1,72 @@
-- Render frame-rate cap. With a driver control panel forcing
-- vsync off, the 160x144 game is trivially cheap and love.run will present
-- thousands of frames a second; over hours that cooks the graphics driver
-- until a restart, and it wastes power whenever the window is left open in
-- the background. A hard cap bounds the present rate. Render-only: game
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
-- changes nothing about timing, audio, or determinism.
--
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
-- frame for its sleep budget. The module never touches love.timer itself,
-- so it stays safe under the headless test stub.
local FrameCap = {}
-- Selectable steps: the normal framerate stops between the floor and the
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
FrameCap.MIN = 30
FrameCap.MAX = 160
FrameCap.DEFAULT = 60
-- The live cap the run loop paces to. Defaults so the launcher and the
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
function FrameCap.normalize(value)
value = tonumber(value)
if not value then return FrameCap.DEFAULT end
local best, bestDiff = FrameCap.DEFAULT, math.huge
for _, step in ipairs(FrameCap.STEPS) do
local diff = math.abs(step - value)
if diff < bestDiff then best, bestDiff = step, diff end
end
return best
end
-- plain numeric text for the options row (e.g. "60")
function FrameCap.label(value)
return tostring(FrameCap.normalize(value))
end
-- cycle to the next/previous step, wrapping (the options row idiom)
function FrameCap.cycle(value, dir)
local steps = FrameCap.STEPS
local snapped = FrameCap.normalize(value)
local cur = 1
for i, step in ipairs(steps) do
if step == snapped then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
return steps[nextIdx]
end
-- Store the chosen cap as the live value the run loop paces to. Never
-- touches love.timer, so it is safe headless -- the loop just reads the
-- number back. Returns the normalized value it stored.
function FrameCap.apply(value)
FrameCap.current = FrameCap.normalize(value)
return FrameCap.current
end
function FrameCap.applyOptions(opts)
FrameCap.apply(opts and opts.fpsCap)
end
return FrameCap
+16 -1
View File
@@ -99,7 +99,12 @@ end
-- total conversion overrides. Threaded into SaveData so persistence stays
-- free of a Data dependency.
function Game:bootConfig()
return self.data and self.data.field and self.data.field.boot
local boot = self.data and self.data.field and self.data.field.boot
-- stamp the running game version onto the boot config so a New Game records
-- it (SaveData.newGame reads boot.version); this is what routes a Blue
-- playthrough to save_blue.lua and Blue's version-gated content
if boot then boot.version = require("src.core.GameVersion").get() end
return boot
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
@@ -194,6 +199,13 @@ function Game:update(dt)
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
-- only has to keep ordinary Lua-heap garbage (per-frame tables/closures)
-- from drifting upward over a long session, and to spread collection out
-- so the default lazy schedule never batches it into a visible pause.
if collectgarbage then collectgarbage("step", 1) end
end
-- render.zones' identity default: unhooked, the zone list reaches the blit
@@ -433,6 +445,9 @@ function Game:applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
end
+77
View File
@@ -0,0 +1,77 @@
-- Which Gen-1 game this process is running: Red (the historical default) or
-- Blue. One source of truth for everything that differs by version -- the
-- accepted ROM hash, the import manifest, where the extracted cache lives,
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
-- screen and palette all agree.
--
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
-- so existing installs are untouched; Blue is namespaced under blue/ and
-- _blue so both can be imported and played side by side.
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
-- the launcher's column choice (main.lua); it defaults to Red.
local GameVersion = {}
GameVersion.VERSIONS = {
red = {
id = "red",
label = "Red",
displayName = "Pokemon Red",
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible)
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
},
blue = {
id = "blue",
label = "Blue",
displayName = "Pokemon Blue",
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
},
}
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
GameVersion.ORDER = { "red", "blue" }
GameVersion.current = "red"
function GameVersion.set(id)
GameVersion.current = GameVersion.VERSIONS[id] and id or "red"
return GameVersion.current
end
function GameVersion.get()
return GameVersion.current
end
function GameVersion.isBlue()
return GameVersion.current == "blue"
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
end
function GameVersion.saveSuffix(id)
return GameVersion.info(id).saveSuffix
end
function GameVersion.cachePrefix(id)
return GameVersion.info(id).cachePrefix
end
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
function GameVersion.forSha1(sha1)
for id, info in pairs(GameVersion.VERSIONS) do
if info.sha1 == sha1 then return id end
end
return nil
end
return GameVersion
+77 -13
View File
@@ -18,14 +18,30 @@ local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local GameVersion = require("src.core.GameVersion")
local SaveData = {}
local FILENAME = "save.lua"
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
-- global preferences and the mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- one rolling backup plus the staged-write witness; load promotes either
-- when the main file is missing or fails to parse
local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
function SaveData.saveFilename(version)
local main = saveNames(version)
return main
end
-- ------- portable mode
-- LÖVE's save directory is always the OS per-user path derived from the
@@ -83,15 +99,41 @@ local function detectPortable()
portableChecked = true
portableBase = false
if not (love and love.filesystem) then return false end
-- Desktop only: portable mode carries the save (and, since issue #74, the
-- ROM cache) in the game folder next to the executable/source. On
-- Android/iOS the source is a read-only package with no such folder, so
-- portable mode never applies there.
if love.system and love.system.getOS then
local osName = love.system.getOS()
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
return false
end
end
local src = love.filesystem.getSource and love.filesystem.getSource()
local sbd = love.filesystem.getSourceBaseDirectory
and love.filesystem.getSourceBaseDirectory()
-- A packaged macOS build nests the game inside gen1recomp.app/Contents/
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
-- bundle -- not where the player drops portable.txt (next to the .app).
-- Recover the folder containing the .app so a packaged app finds its
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
-- and the plain source-base directory (next to the .exe/AppImage) is used.
local function appContainer(path)
local appPath = path and path:match("^(.*%.app)/Contents/")
return appPath and appPath:match("^(.*)/[^/]+$") or nil
end
-- Order: the .app's containing folder (packaged macOS), then the
-- source-base directory (next to a packaged .exe/AppImage), then the
-- source itself (a `love <gamedir>` run drops portable.txt in the game
-- folder). First one holding the marker wins. Built by appending so a
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
local candidates = {}
if love.filesystem.getSourceBaseDirectory then
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory()
end
if love.filesystem.getSource then
candidates[#candidates + 1] = love.filesystem.getSource()
end
local appDir = appContainer(src) or appContainer(sbd)
if appDir then candidates[#candidates + 1] = appDir end
if sbd then candidates[#candidates + 1] = sbd end
if src then candidates[#candidates + 1] = src end
for _, base in ipairs(candidates) do
if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
portableBase = base
break
end
@@ -150,6 +192,8 @@ function SaveData.defaultOptions()
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -423,6 +467,17 @@ SaveData.addCoreMigration(1, function(save)
Boxes.ensure(save)
end)
-- game version (Red vs Blue) prep: saves written before Blue support
-- existed carry no `version` tag, and Red is the only game that ever
-- shipped, so default every untagged save to Red. from=2 so it catches
-- every pre-bump save (format 1 and 2) and is skipped once re-stamped to
-- the current format.
SaveData.addCoreMigration(2, function(save)
if not save.version then
save.version = "red"
end
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -432,6 +487,9 @@ end)
-- itself rolls the last good save into .bak and stages the new bytes as
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active
-- one, so a Blue playthrough always lands in save_blue.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
end
@@ -471,7 +529,10 @@ end
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report
function SaveData.load()
function SaveData.load(version)
-- version defaults to the active game (set at boot from the launcher);
-- an explicit version lets callers/tests load a specific game's save.
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(version)
local fs = persistFs(nil)
local data, err = readTable(fs, FILENAME)
local recovered
@@ -759,6 +820,9 @@ function SaveData.newGame(boot)
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
-- which game this playthrough is (Red vs Blue). Only Red ships today;
-- boot carries the choice once Blue support lands.
version = boot.version or "red",
player = {
map = map,
x = x,
+3 -3
View File
@@ -7,13 +7,13 @@ local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 2, -- save.meta.format
saveFormat = 3, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
-- "gen1recomp v1.0.0"
function Version.title(base)
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
return (base or "gen1recomp")
.. " v" .. Version.engine
end
+96
View File
@@ -0,0 +1,96 @@
-- ChipAudio synthesis worker (love.thread). Runs the Game Boy audio synth
-- (src/core/ChipSynth.lua) off the main thread so a map/battle song change
-- never stutters the render thread: filling the ~6s playback queue from
-- scratch is ~200ms of Lua synthesis, and this is where it now happens.
--
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
-- channel and drains produced buffers off "chipaudio_out":
-- cmd = "play" { gen, header, allowLoops, audio } start a song
-- cmd = "stop" halt production
-- cmd = "invalidate" drop the bank cache
-- cmd = "quit" end the thread
-- out buffers are tagged with the play's `gen` so the main thread can
-- discard anything left over from a superseded song:
-- { gen, sd = SoundData } one rendered buffer
-- { gen, done = true } the song ended (non-looping jingle finished)
-- { gen, error = msg } build/synth failed; main logs and gives up
require("love.thread")
require("love.timer")
require("love.sound")
require("love.filesystem")
-- Load the synth explicitly via love.filesystem (a fresh thread Lua state does
-- not necessarily carry the package searcher that resolves "src.core..."):
local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))()
local cmdCh = love.thread.getChannel("chipaudio_cmd")
local outCh = love.thread.getChannel("chipaudio_out")
local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES
-- how many finished buffers may sit in the hand-off channel before the worker
-- pauses. The deep (~6s) playback depth lives in the main-thread Source; this
-- only bounds the worker's look-ahead (and its memory) between drains.
local LOOKAHEAD = 8
local gen = nil -- active song generation, or nil when stopped
local engine = nil -- the ChipSynth engine producing the current song
local finished = false -- the current song ran out (non-looping)
local data = nil -- { audio = <slim audio tables> } for ROM bank/wave reads
local function handle(cmd)
if cmd.cmd == "play" then
gen = cmd.gen
finished = false
engine = nil
outCh:clear() -- drop any buffers left from the previous song
data = { audio = cmd.audio }
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
{ allowLoops = cmd.allowLoops })
if ok then
engine = eng
else
outCh:push({ gen = gen, error = tostring(eng) })
finished = true
end
elseif cmd.cmd == "stop" then
gen = nil
engine = nil
finished = false
outCh:clear()
elseif cmd.cmd == "invalidate" then
ChipSynth.invalidateBanks()
elseif cmd.cmd == "quit" then
return true
end
return false
end
while true do
-- drain every pending command first, so a stop/new-play is seen promptly
local quit = false
local cmd = cmdCh:pop()
while cmd do
if handle(cmd) then quit = true end
cmd = cmdCh:pop()
end
if quit then break end
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
local activeGen = gen
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
if not ok then
outCh:push({ gen = activeGen, error = tostring(sd) })
finished = true
else
outCh:push({ gen = activeGen, sd = sd })
if engine:finished() then
outCh:push({ gen = activeGen, done = true })
finished = true
end
end
else
-- nothing to do (idle, or the look-ahead is full): yield the core
love.timer.sleep(0.001)
end
end
+283
View File
@@ -0,0 +1,283 @@
-- Routes ROM-derived cache I/O (data/generated, assets/generated and the
-- rom-cache.complete marker) to the right place.
--
-- Normally the cache lives in LÖVE's per-user OS save directory and is
-- written through love.filesystem. In portable mode it lives in the game
-- folder next to the executable instead (the folder holding portable.txt --
-- see SaveData), so nothing is left on the host machine. That folder is
-- written with raw io.* (love.filesystem can only write to the save dir) and
-- read back through love.filesystem, require and love.graphics.newImage --
-- which works because the folder is on the physfs read path:
--
-- * Source runs (`love <gamedir>`, what the Play-* launchers use): the
-- folder IS the physfs source, so it is already readable.
-- * Fused builds (the packaged .app/.exe): the folder sits next to the
-- executable and is NOT normally readable, so CacheFs mounts it onto the
-- read path via PhysFS. love.filesystem.mount refuses external folders,
-- but the underlying PHYSFS_mount (exported from love's framework) allows
-- them; we call it through LuaJIT's FFI.
--
-- Directories in the portable folder are created with a plain mkdir syscall
-- via FFI rather than os.execute, so importing never flashes a console window
-- on Windows (issue #74 -- the old per-file `os.execute("mkdir")` froze the
-- app behind a storm of one-frame cmd.exe windows).
--
-- Portable mode is desktop-only (Windows/Linux/macOS); on Android/iOS the
-- source is a read-only package with no game folder to write into, so
-- SaveData.isPortable() is false there and this module falls back to the
-- ordinary love.filesystem/save-directory behaviour.
local CacheFs = {}
local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
-- keeps the historical root. The launcher sets it per import / per readiness
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
CacheFs.prefix = ""
local function withPrefix(rel)
local p = CacheFs.prefix
if p == nil or p == "" then return rel end
return p .. rel
end
-- lazily-resolved windowless mkdir: function(absolutePath) or false when
-- FFI is unavailable (the cache then stays on the save directory)
local mkdirFn = nil
local function resolveMkdir()
if mkdirFn ~= nil then return mkdirFn end
mkdirFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return mkdirFn end
if ffi.os == "Windows" then
-- kernel32 is reliably resolvable through ffi.C on Windows (the engine
-- already binds it in DiscordPresence); CreateDirectoryA returns
-- nonzero on success and 0 when the directory already exists -- both
-- fine, the result is ignored.
pcall(ffi.cdef,
"int CreateDirectoryA(const char *lpPathName, void *lpSecurityAttributes);")
local resolved = pcall(function() return ffi.C.CreateDirectoryA end)
if resolved then
mkdirFn = function(path) pcall(ffi.C.CreateDirectoryA, path, nil) end
end
else
pcall(ffi.cdef, "int mkdir(const char *pathname, unsigned int mode);")
local resolved = pcall(function() return ffi.C.mkdir end)
if resolved then
mkdirFn = function(path) pcall(ffi.C.mkdir, path, 493) end -- 0755
end
end
return mkdirFn
end
-- Mount an external directory onto the physfs read path (appended, so the
-- game's own source always wins a name clash). Returns true on success.
--
-- PHYSFS_mount is exported by love's own binary. How ffi finds it differs
-- per platform: on macOS/Linux the symbol is in the default namespace, so
-- ffi.C resolves it; on Windows it lives in love.dll, which ffi.C does NOT
-- search, so love.dll is loaded explicitly with ffi.load("love"). Try the
-- default first, then love.
local physfsMountFn = nil
local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end
pcall(ffi.cdef,
"int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then
physfsMountFn = function(d, append)
if append == nil then append = true end
local okr, ret = pcall(fn, d, "", append and 1 or 0)
return okr and ret ~= 0
end
break
end
end
end
return physfsMountFn
end
-- append (default true): the game's own source wins a name clash, matching
-- how the portable cache root has always been mounted. Pass false to
-- prepend, so the mounted tree wins -- used to overlay the active version's
-- cache on top of the root (Red) copy and the source.
local function mountReadable(dir, append)
local fn = resolveMount()
if not fn then return false end
return fn(dir, append)
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
local portableRoot = nil
local portableResolved = false
local function resolvePortableRoot()
if portableResolved then return portableRoot end
portableResolved = true
portableRoot = nil
if not resolveMkdir() then return nil end
local base = require("src.core.SaveData").portableBaseDir()
if not base then return nil end
if love.filesystem.getSource and base == love.filesystem.getSource() then
-- source run: the folder is already the physfs source
portableRoot = base
elseif mountReadable(base) then
-- fused build: base is next to the executable; mount it so io.* writes
-- there are visible to love.filesystem/require/newImage
portableRoot = base
end
return portableRoot
end
function CacheFs.root()
return resolvePortableRoot()
end
local function realPath(root, rel)
return root .. SEP .. rel:gsub("/", SEP)
end
-- create every parent directory of `rel` under `root` (best effort; an
-- already-existing directory is fine, a genuine failure surfaces when the
-- subsequent io.open write fails)
local function ensureParents(root, rel)
local mkdir = resolveMkdir()
if not mkdir then return end
local parts = {}
for part in rel:gmatch("[^/]+") do parts[#parts + 1] = part end
local cur = root
for i = 1, #parts - 1 do
cur = cur .. SEP .. parts[i]
mkdir(cur)
end
end
-- write cache-relative `rel` (forward-slash path) with the given bytes;
-- returns ok, err like love.filesystem.write
function CacheFs.write(rel, data)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
ensureParents(root, rel)
local f, err = io.open(realPath(root, rel), "wb")
if not f then return false, err end
f:write(data)
f:close()
return true
end
local parent = rel:match("^(.*)/[^/]+$")
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
return false, "could not create " .. parent .. ": " .. reason
end
return love.filesystem.write(rel, data)
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
return data
end
return love.filesystem.read(rel)
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
if not f then return false end
f:close()
return true
end
return love.filesystem.getInfo(rel, "file") ~= nil
end
-- remove a single cache-relative file
function CacheFs.remove(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
os.remove(realPath(root, rel))
return
end
love.filesystem.remove(rel)
end
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
-- cache-format bump does not leave orphaned files behind. No-op when the
-- portable cache is inactive (the save-directory copy is cleared by
-- RomImporter's own removeTree). The tree is enumerated through
-- love.filesystem (the game folder is mounted) and the real files deleted
-- with os.remove; empty directories are harmless and left in place.
function CacheFs.removeTree(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if not root then return end
local function walk(r)
local info = love.filesystem.getInfo(r)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(love.filesystem.getDirectoryItems(r)) do
walk(r .. "/" .. child)
end
else
os.remove(realPath(root, r))
end
end
walk(rel)
end
-- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. Red lives at the
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
-- it wins over any Red copy at the root and over the game source. Called
-- once at boot, before Game:load (main.lua). Returns true when nothing was
-- needed or the mount succeeded.
function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
-- The cache root is the portable game folder when active, else LÖVE's OS
-- save directory (where love.filesystem wrote blue/...).
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
if not base then return false end
if mountReadable(base .. SEP .. sub, false) then return true end
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
-- that lives in the save directory by name (prepended: appendToPath=false).
if love.filesystem.mount then
return love.filesystem.mount(sub, "", false)
end
return false
end
return CacheFs
+6 -6
View File
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
end
function ImageWriter.save(image, path)
local parent = path:match("^(.*)/[^/]+$")
if parent then
local ok, err = love.filesystem.createDirectory(parent)
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
end
local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
local written, writeError = love.filesystem.write(path, fileData)
-- CacheFs routes this to the OS save directory (normal builds) or straight
-- into the game folder (portable installs), creating parent directories as
-- needed. io.* needs the bytes as a string; love.filesystem would also
-- take the FileData, but getString() keeps one code path.
local CacheFs = require("src.import.CacheFs")
local written, writeError = CacheFs.write(path, fileData:getString())
if not written then
error("could not write " .. path .. ": " .. tostring(writeError))
end
+5 -11
View File
@@ -87,17 +87,11 @@ function LuaWriter.encode(value)
end
function LuaWriter.write(path, value)
local parent = path:match("^(.*)/[^/]+$")
-- love.filesystem.createDirectory returns a single boolean (no error
-- value), so a second return here is always nil; getInfo after a failure
-- at least reports what is blocking the path (e.g. a file with that name).
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
error("could not create " .. parent .. ": " .. reason)
end
local ok, err = love.filesystem.write(path, LuaWriter.encode(value))
-- CacheFs routes this to the OS save directory (normal builds) or straight
-- into the game folder (portable installs); it also creates the parent
-- directories. See src/import/CacheFs.lua.
local CacheFs = require("src.import.CacheFs")
local ok, err = CacheFs.write(path, LuaWriter.encode(value))
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
end
+5 -4
View File
@@ -1669,10 +1669,11 @@ function RomExtractor:extractAudio()
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
self:tick("Sound programs", index, #bankOrder + 2)
end
local ok, writeError = love.filesystem.createDirectory(
"assets/generated/audio")
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end
ok, writeError = love.filesystem.write(
-- CacheFs (not love.filesystem directly) so a portable install lands this
-- in the game folder with the rest of the cache; it creates the parent
-- directory too.
local CacheFs = require("src.import.CacheFs")
local ok, writeError = CacheFs.write(
"assets/generated/audio/programs.bin", table.concat(chunks))
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
+803 -308
View File
File diff suppressed because it is too large Load Diff
+72 -14
View File
@@ -10,16 +10,23 @@
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors.
local GameVersion = require("src.core.GameVersion")
local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
-- label was a misnomer -- it never was the real Game Boy Color palette).
PaletteFX.MODE_LABELS = {
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
gbc_inv = "GBC INV", classic = "CLASSIC",
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
}
PaletteFX.mode = "gbc"
@@ -28,6 +35,40 @@ PaletteFX.CLASSIC = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
}
-- OG RED: the Game Boy Color boot-ROM auto-palette for Pokemon Red. Pokemon
-- Red ships no CGB code (pokered's wOnCGB is hardwired 0), so on a Game Boy
-- Color the boot ROM colorizes it with ONE global palette pair -- a red
-- background and green objects -- applied to the whole game with no per-map
-- variation (that variety was the Super Game Boy's doing, i.e. SGB mode).
-- Lightest shade first, matching the SGB palette tables. Values verified
-- against hardware captures of Pallet Town and Oak's Lab.
PaletteFX.GBC_BG = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
PaletteFX.GBC_OBJ = {
{ 255, 255, 255 }, { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 },
}
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
-- the green-character look on a Game Boy Color.
PaletteFX.GBC_BG_BLUE = {
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
return PaletteFX.GBC_BG
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -141,16 +182,18 @@ function PaletteFX.usesGbcPack(mode)
return mode == "redpp"
end
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
-- plain GBC mode too, not only under the RED++ pack: without it the
-- whole-map zone shader paints characters with whatever two mid shades
-- the terrain palette defines. RED++ handles sprites through the baked
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
-- palettes over a shader-colorized background.
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
-- NPCs stay green over the red background, exactly like Pokemon Red on a
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
-- palette of its own, so the characters tint with the whole-map region
-- palette along with the terrain (the Super Game Boy never colored Pokemon
-- Red's sprites separately -- baking a per-sprite palette there was the
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
-- the usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "gbc"
return (mode or PaletteFX.mode) == "ogred"
end
-- ------- post-zone sprite redraw (GBC mode)
@@ -201,7 +244,13 @@ end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
-- (the hardware had a single BGP for the whole game), so terrain zones,
-- battle HP bars / text, and menu boxes all come out red -- everything a
-- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
@@ -217,6 +266,11 @@ end
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
-- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field.
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then
@@ -458,7 +512,11 @@ function PaletteFX.applyOptions(opts)
end
function PaletteFX.modeLabel(mode)
return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC"
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
-- When a state exposes no SGB zones but COLORS needs a forced palette
+6
View File
@@ -79,6 +79,10 @@ function Renderer:beginWorldPass()
local vw, vh = self:worldViewSize()
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
or self.worldCanvas:getHeight() ~= vh then
-- free the old canvas before replacing it: a zoom/tilt tween changes
-- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh)
self.worldCanvas:setFilter("nearest", "nearest")
end
@@ -107,6 +111,7 @@ function Renderer:beginUprightPass()
local cw, ch = vw + 2 * M, vh + 2 * M
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
self.uprightCanvas:setFilter("nearest", "nearest")
end
@@ -200,6 +205,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
-- 2x for extra crispness.
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
self.tiltCanvas:setFilter("linear", "linear")
end
+6 -8
View File
@@ -114,14 +114,12 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
image = getObpImage(self.def.image, colors, group)
end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- plain GBC: the terrain zone shader still runs over the world canvas,
-- so the baked sprite is also queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then
image = getObpImage(self.def.image, colors, group)
redraw = true
end
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
-- object palette. The red BG zone shader still runs over the world
-- canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
redraw = true
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
+165 -95
View File
@@ -90,8 +90,8 @@ function TileRenderer.setSpinning(active)
end
-- true while the spinner arrow tiles should show the 'blur' graphic; false
-- means draw nothing extra (the static mapBatch/ringBatch tile shows
-- through, matching the asm's restore-to-original behavior). The 8-tick
-- means draw nothing extra (the static window tile shows through,
-- matching the asm's restore-to-original behavior). The 8-tick
-- half-period approximates one GB movement step (2px/frame); this is a
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
-- a cycle-accurate replication -- the port's tweened scriptMove has no
@@ -426,22 +426,24 @@ function TileRenderer.new(map, data)
end
local def = map.def
local wB, hB = def.width, def.height
-- two batches: the border-block ring around the map, and the map body.
-- Connected-map strips draw body-only on top of this map's ring.
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
-- animated tiles overdraw the static batches each frame. Entry order
-- decides which one claims a tile listed twice, so the vanilla defaults
-- keep the old water-then-flower-then-spinner precedence.
-- The map body measured in 8px tiles (each block is 4x4 tiles). The tile
-- layer is drawn windowed to the camera (see :ensureWindow) instead of
-- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp,
-- a connection seam -- costs nothing to "build": there is no per-map batch
-- construction that scales with map size, which is what stuttered.
self.bodyTilesW = def.width * 4
self.bodyTilesH = def.height * 4
-- Animated tiles overdraw the static window each frame. Only the per-entry
-- render spec (textures/sequence/gate) is kept here; the animated cells are
-- gathered per camera window in :ensureWindow, so nothing here scales with
-- map size either. Entry order decides which entry claims a tile listed
-- twice (the vanilla water-then-flower-then-spinner precedence).
local anims, claimedBy = {}, {}
local declared = map.tileset.animatedTiles
or TileRenderer.defaultAnimatedTiles(map.tileset)
for _, spec in ipairs(declared) do
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
if anim then
anim.cells = {}
anims[#anims + 1] = anim
for _, tile in ipairs(anim.tiles) do
if claimedBy[tile] == nil then claimedBy[tile] = anim end
@@ -460,66 +462,9 @@ function TileRenderer.new(map, data)
aliasMap[al.block] = cells
end
end
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
local batch = inside and self.mapBatch or self.ringBatch
-- beyond-edge ring cells use the same override drawBorderFill does,
-- so the ring and the far background fill agree (OVERWORLD maps
-- whose raw border_block is water still ring with the tree wall)
local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map)
local block = map.tileset.blocks[blockId + 1]
if not block then
-- a tileset without the tree-wall block keeps its own border
blockId = map:blockAt(bx, by)
block = map.tileset.blocks[blockId + 1]
end
local remap = aliasMap and aliasMap[blockId]
for ty = 0, 3 do
for tx = 0, 3 do
local ci = ty * 4 + tx
local tile = block[ci + 1]
if remap and remap[ci] then tile = remap[ci] end
local quad = self.quads[tile]
if quad then
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
end
local anim = claimedBy[tile]
if anim then
local cells = anim.cells
cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile }
end
end
end
end
end
-- animated overdraw batches: the full set (ring + body) for the
-- current map, and a body-only set for connected-map drawing --
-- a neighbor's water ring must never overdraw this map's tiles.
-- `quadFor`, when given, looks up a per-entry quad (used by toggle
-- entries, whose texture is a full tileset-atlas clone rather than a
-- single-tile image like the hshift/frames variants).
local function animBatches(entries, image, quadFor)
if #entries == 0 then return nil, nil end
local all = love.graphics.newSpriteBatch(image, #entries, "static")
local body
for _, c in ipairs(entries) do
if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end
if c[3] then
body = body or love.graphics.newSpriteBatch(image, #entries, "static")
if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end
end
end
return all, body
end
for _, anim in ipairs(anims) do
anim.batch, anim.bodyBatch =
animBatches(anim.cells, anim.textures[1], anim.quadFor)
anim.cells = nil
end
self.aliasMap = aliasMap
self.anims = anims
self.claimedBy = claimedBy
-- a repeating 32x32 image of the border block, tiled behind
-- everything the 3-block ring doesn't cover (the survey zoom sees
@@ -554,8 +499,16 @@ function TileRenderer:drawBorderFill(camX, camY, vw, vh)
if not self.borderFill then return end
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
local x, y = math.floor(camX), math.floor(camY)
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
love.graphics.draw(self.borderFill, quad, 0, 0)
-- one reused Quad per renderer, mutated in place: this runs every
-- overworld frame, so allocating a fresh Quad here churned the GC
local q = self.borderQuad
if q then
q:setViewport(x, y, vw, vh, 32, 32)
else
q = love.graphics.newQuad(x, y, vw, vh, 32, 32)
self.borderQuad = q
end
love.graphics.draw(self.borderFill, q, 0, 0)
end
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
@@ -616,19 +569,98 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
end
end
-- animated overdraw at the current step; bodyOnly skips the ring
-- positions (connected maps draw body-only)
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
-- Window cover for the static tile layer. Refill the reusable window batch
-- (and the per-entry animated batches) only when the camera has scrolled past
-- what they already cover; a small margin keeps small scrolls free. Cost
-- scales with the view, never the map -- crossing a seam or warping in builds
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
-- there, so only body tiles are gathered here.
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
function TileRenderer:ensureWindow(camX, camY, vw, vh)
local W, H = self.bodyTilesW, self.bodyTilesH
vw = vw or W * 8 -- a nil view (headless draw) means the whole body
vh = vh or H * 8
-- visible body-tile range (8px tiles), clamped to the map body
local tx0 = math.min(W, math.max(0, math.floor(camX / 8)))
local ty0 = math.min(H, math.max(0, math.floor(camY / 8)))
local tx1 = math.max(0, math.min(W, math.floor((camX + vw) / 8) + 1))
local ty1 = math.max(0, math.min(H, math.floor((camY + vh) / 8) + 1))
local win = self.win
if win and tx0 >= win.tx0 and ty0 >= win.ty0
and tx1 <= win.tx1 and ty1 <= win.ty1 then
return -- still inside the last fill
end
-- refill with margin so the next few scrolled pixels stay covered
tx0 = math.max(0, tx0 - WINDOW_MARGIN)
ty0 = math.max(0, ty0 - WINDOW_MARGIN)
tx1 = math.min(W, tx1 + WINDOW_MARGIN)
ty1 = math.min(H, ty1 + WINDOW_MARGIN)
if not self.winBatch then
self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic")
end
self.winBatch:clear()
local anims = self.anims
for _, anim in ipairs(anims) do
if not anim.batch then
anim.batch = love.graphics.newSpriteBatch(anim.textures[1], 256, "dynamic")
end
anim.batch:clear()
end
local map, quads = self.map, self.quads
local claimedBy, aliasMap = self.claimedBy, self.aliasMap
for ty = ty0, ty1 - 1 do
local by = math.floor(ty / 4)
local ty4 = ty % 4
for tx = tx0, tx1 - 1 do
local blockId = map:blockAt(math.floor(tx / 4), by)
local block = map.tileset.blocks[blockId + 1]
if block then
local ci = ty4 * 4 + (tx % 4)
local tile = block[ci + 1]
local remap = aliasMap and aliasMap[blockId]
if remap and remap[ci] then tile = remap[ci] end
local wx, wy = tx * 8, ty * 8
local quad = quads[tile]
if quad then self.winBatch:add(quad, wx, wy) end
local anim = claimedBy[tile]
if anim then
if anim.quadFor then
anim.batch:add(anim.quadFor(tile), wx, wy)
else
anim.batch:add(wx, wy)
end
end
end
end
end
self.win = { tx0 = tx0, ty0 = ty0, tx1 = tx1, ty1 = ty1 }
end
-- draw the static tile window, then its animated overdraw, at the camera offset
function TileRenderer:drawWindow(camX, camY, vw, vh)
self:ensureWindow(camX, camY, vw, vh)
if self.winBatch then
love.graphics.draw(self.winBatch, -math.floor(camX), -math.floor(camY))
end
self:drawAnimated(camX, camY)
end
-- animated overdraw at the current step, over the static window batch. The
-- cells were gathered for the current camera window by :ensureWindow, so this
-- only ever touches on-screen animated tiles.
function TileRenderer:drawAnimated(camX, camY)
local anims = self.anims
if not anims then return end
local x, y = -math.floor(camX), -math.floor(camY)
for _, anim in ipairs(anims) do
local batch = bodyOnly and anim.bodyBatch or anim.batch
local batch = anim.batch
if batch then
if anim.gate then
-- a gated entry has only the two frames the asm has (patch /
-- restore-to-static); when the gate is shut draw nothing so the
-- already-static mapBatch/ringBatch tile shows through unchanged
-- already-static window tile shows through unchanged
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
else
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
@@ -649,30 +681,68 @@ function TileRenderer:markTrueColor(camX, camY, blocks)
(def.height + 2 * blocks) * 32)
end
function TileRenderer:draw(camX, camY)
function TileRenderer:draw(camX, camY, vw, vh)
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
self:drawAnimated(camX, camY)
self:drawWindow(camX, camY, vw, vh)
end
-- body only, for connected-map strips
function TileRenderer:drawMapOnly(camX, camY)
-- body only, for connected-map strips. Identical to :draw now that the
-- border ring is served by :drawBorderFill for the current map too -- the
-- only remaining difference is the trueColor mark extent.
function TileRenderer:drawMapOnly(camX, camY, vw, vh)
if self.trueColor then self:markTrueColor(camX, camY, 0) end
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
self:drawAnimated(camX, camY, true)
self:drawWindow(camX, camY, vw, vh)
end
-- rebuild after a block change (Cut trees)
local function safeRelease(o)
if o and o.release then pcall(o.release, o) end
end
-- Release only the GPU objects this instance built and uniquely owns: the
-- two SpriteBatches, the border-fill image and its quad, the per-tile
-- quads, and the animated-overdraw batches. Deliberately leaves the
-- tileset atlas (self.image, shared through Assets/imageCache) and the
-- animation textures (shared module caches) alone -- other maps still use
-- them. Used by :rebuild before it swaps in fresh batches, and by
-- :release on eviction.
function TileRenderer:releaseBatches()
safeRelease(self.winBatch); self.winBatch = nil
safeRelease(self.borderFill); self.borderFill = nil
safeRelease(self.borderQuad); self.borderQuad = nil
self.win = nil
if self.quads then
for _, q in pairs(self.quads) do safeRelease(q) end
self.quads = nil
end
if self.anims then
for _, a in ipairs(self.anims) do
safeRelease(a.batch); a.batch = nil
-- a.textures are shared, module-cached: never released here
end
end
end
-- Full teardown for eviction (MapLoader.evict): the owned batches, plus
-- the RED++ per-map recolored atlas, which -- unlike the plain tileset
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
function TileRenderer:release()
self:releaseBatches()
if self.gbcAtlas and self.image then
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
safeRelease(self.image)
self.image = nil
self.gbcAtlas = nil
end
self.anims = nil
end
-- rebuild after a block change (Cut trees, card-key doors). The tile layer
-- is read live from the map on every window fill, so a block swap only needs
-- the cached window dropped -- the next draw re-reads the changed blocks. No
-- SpriteBatch is reconstructed; that is the whole point of the windowed draw.
function TileRenderer:rebuild()
local fresh = TileRenderer.new(self.map, self.data)
self.image = fresh.image
self.gbcAtlas = fresh.gbcAtlas
self.quads = fresh.quads
self.ringBatch = fresh.ringBatch
self.mapBatch = fresh.mapBatch
self.anims = fresh.anims
self.borderFill = fresh.borderFill
self.win = nil
end
-- drop every atlas and every derived animation texture so the next
+14
View File
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS",
value = function(g)
return FrameCap.label(g.save.options.fpsCap)
end,
step = function(g, dir)
local o = g.save.options
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
FrameCap.apply(o.fpsCap)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
+32 -9
View File
@@ -5,6 +5,7 @@
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local TitleState = {}
TitleState.__index = TitleState
@@ -30,6 +31,13 @@ local CYCLE_SPECIES = {
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP",
}
-- Blue's TitleMons (data/pokemon/title_mons.asm, _BLUE branch): STARTER2 is
-- Squirtle, STARTER1 Charmander, STARTER3 Bulbasaur.
local BLUE_CYCLE_SPECIES = {
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path)
@@ -61,9 +69,13 @@ function TitleState.new(game, opts)
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.blue = GameVersion.isBlue()
-- Blue cycles its own title mons and prints its ribbon contiguously; a
-- field.title.cycleSpecies override (mods / total conversions) still wins.
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or CYCLE_SPECIES
and title.cycleSpecies or defaultCycle
self.sprites = {} -- species -> image or false (load failed)
self.cycleIndex = 1
self.timer = 0
@@ -92,8 +104,10 @@ end
local function hasSave()
local ok, info = pcall(function()
-- the active game's save file (save.lua for Red, save_blue.lua for Blue)
local name = require("src.core.SaveData").saveFilename(GameVersion.get())
return love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo("save.lua") or nil
and love.filesystem.getInfo(name) or nil
end)
return ok and info ~= nil
end
@@ -211,17 +225,26 @@ function TitleState:draw()
love.graphics.draw(self.logo, 16, 8)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("POKéMON RED", (160 - 11 * 8) / 2, 24)
Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED",
(160 - 12 * 8) / 2, 24)
love.graphics.setColor(1, 1, 1, 1)
end
if self.version then
-- the strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version")
local iw, ih = self.version:getDimensions()
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
if self.blue then
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
-- The extracted strip packs those eight glyph tiles into image tiles
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
else
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end
end
local sprite = self:currentSprite()
if sprite then
+72 -5
View File
@@ -2,6 +2,13 @@
-- data, cached by map id. The cache is keyed so a mod that patches one
-- map record after boot (or the dev-mode hot reload) can drop just that
-- entry instead of every map's SpriteBatches.
--
-- The resident set is trimmed LRU (trim) so exploring a large world never
-- holds every visited map's GPU objects at once. Maps are built on demand
-- and cheaply: a map's tile layer draws windowed to the camera (see
-- TileRenderer), so there is no per-map batch to construct up front and thus
-- nothing to stream -- OverworldState:rebuildNeighbors just loads each
-- neighbor directly.
local Assets = require("src.render.Assets")
local Map = require("src.world.Map")
@@ -10,9 +17,20 @@ local TileRenderer = require("src.render.TileRenderer")
local MapLoader = {}
local cache = {}
-- mapId -> monotonic access stamp, for LRU eviction
local lru = {}
local accessSeq = 0
-- max map renderers kept resident. Comfortably above the largest
-- current+neighbor set (~15 in dense overworld), so protected maps are
-- never the ones evicted; this only caps the lingering trail behind you.
local RESIDENT_CAP = 32
function MapLoader.load(data, mapId)
if cache[mapId] then return cache[mapId] end
local function touch(mapId)
accessSeq = accessSeq + 1
lru[mapId] = accessSeq
end
local function build(data, mapId)
local def = data.maps[mapId]
assert(def, "unknown map: " .. tostring(mapId) ..
" (not in the maps registry)")
@@ -25,26 +43,75 @@ function MapLoader.load(data, mapId)
local map = Map.new(def, tilesetDef)
map.renderer = TileRenderer.new(map, data)
cache[mapId] = map
touch(mapId)
return map
end
function MapLoader.load(data, mapId)
local m = cache[mapId]
if m then touch(mapId); return m end
return build(data, mapId)
end
-- the live instance for a map id, or nil when it has not been loaded;
-- callers that must not build a map (invalidation, tests) use this
function MapLoader.cached(mapId)
return cache[mapId]
local m = cache[mapId]
if m then touch(mapId) end
return m
end
-- evict one resident map, releasing its renderer's GPU objects. Callers
-- must ensure the map is not the current map and not drawn as a connected
-- strip (nothing live may hold its renderer) -- MapLoader.trim guarantees
-- this via its `protected` set.
function MapLoader.evict(mapId)
local m = cache[mapId]
if not m then return false end
cache[mapId] = nil
lru[mapId] = nil
local r = m.renderer
if r and r.release then pcall(r.release, r) end
return true
end
-- keep the resident renderer set bounded. `protected` (mapId -> true) is
-- never evicted (the current map and everything drawn as a connected
-- strip); the rest is trimmed least-recently-used down to RESIDENT_CAP.
function MapLoader.trim(protected)
local n = 0
for _ in pairs(cache) do n = n + 1 end
if n <= RESIDENT_CAP then return end
local ids = {}
for id in pairs(cache) do
if not (protected and protected[id]) then ids[#ids + 1] = id end
end
table.sort(ids, function(a, b) return (lru[a] or 0) < (lru[b] or 0) end)
local over = n - RESIDENT_CAP
for _, id in ipairs(ids) do
if over <= 0 then break end
MapLoader.evict(id)
over = over - 1
end
end
-- drop one map so the next load re-reads its record and rebuilds its
-- renderer. Callers holding the old instance keep it -- OverworldState
-- re-points self.map itself (WorldAPI:invalidateMap).
-- renderer. Deliberately does NOT release the old renderer: callers
-- holding the old instance keep drawing it until they re-point themselves
-- (OverworldState re-points self.map / self.neighbors via setMap /
-- rebuildNeighbors), so releasing here would free a batch still in use.
-- The orphaned instance is reclaimed by GC; MapLoader.evict is the path
-- that releases eagerly, and it only runs on maps nothing live holds.
function MapLoader.invalidate(mapId)
local had = cache[mapId] ~= nil
cache[mapId] = nil
lru[mapId] = nil
return had
end
function MapLoader.invalidateAll()
cache = {}
lru = {}
end
-- kept as the pre-v2 name
+33 -9
View File
@@ -357,20 +357,32 @@ end
-- widened to everything the current view size can show so a full
-- zoom-out never runs past the rendered set. Re-run whenever the view
-- grows (zoom/resize), not only on setMap.
--
-- Neighbors are built eagerly here. A TileRenderer is now a light object --
-- the tile layer draws windowed to the camera, so nothing per-map is
-- constructed up front (see TileRenderer) -- so there is no build cost to
-- amortize and no prefetch race to lose at a seam. That is what the old
-- one-per-frame streaming queue existed to hide, and it is gone.
function OverworldState:rebuildNeighbors()
local mapId = self.map.id
self.neighbors = {}
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
local vw, vh = Game.renderer:worldViewSize()
self.neighborViewW, self.neighborViewH = vw, vh
-- resident set the eviction pass must never touch: the current map plus
-- every drawn neighbor
local keep = { [mapId] = true }
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
hops,
math.floor(vw / 2) + 64,
math.floor(vh / 2) + 64)) do
table.insert(self.neighbors,
{ map = MapLoader.load(Game.data, n.id),
ox = n.ox, oy = n.oy })
keep[n.id] = true
local m = MapLoader.load(Game.data, n.id)
table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy })
end
-- bound resident memory: drop maps behind us that are neither current nor
-- a drawn neighbor, releasing their window batch / border image / atlas
MapLoader.trim(keep)
-- visual-only NPCs on connected maps (survey zoom): same spawn filter
-- as a real map entry, but they never join self.entities -- no sight
@@ -434,7 +446,9 @@ function OverworldState:paletteNameFor(map)
return Runtime.call("map.palette", samePalette, name, map)
end
-- UI-pass palette (text boxes and menus tint with the current map)
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
-- so this needs no mode-specific branch.
function OverworldState:sgbPalettes()
local PaletteFX = require("src.render.PaletteFX")
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
@@ -3346,9 +3360,9 @@ function OverworldState:drawWorld()
-- reorder (no draws), so they run once for both paths.
local tilt = Tilt.active()
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
self.map.renderer:draw(cam.x, bgY)
self.map.renderer:draw(cam.x, bgY, vw, vh)
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy)
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
-- per-billboard SGB palette source; only needed (and only paid for) when
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
@@ -3492,11 +3506,21 @@ function OverworldState:drawWorld()
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
-- picks question/happy crops instead
local rect = bubble.bubbles and bubble.bubbles[self.emote.bubble or 1]
local bi = self.emote.bubble or 1
local rect = bubble.bubbles and bubble.bubbles[bi]
if ok and img and rect then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y,
rect.w, rect.h, img:getDimensions()), ex, ey)
-- one Quad per bubble crop, cached: this draws every frame the "!"
-- (or the emote-command crops) is up, so a fresh Quad here churned
-- the GC. The bubble set is small and fixed, so the cache is bounded.
self.emoteQuads = self.emoteQuads or {}
local q = self.emoteQuads[bi]
if not q then
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
img:getDimensions())
self.emoteQuads[bi] = q
end
love.graphics.draw(img, q, ex, ey)
drawn = true
end
end
+69
View File
@@ -0,0 +1,69 @@
-- Driver: the Hall of Fame recording-machine "PC". Teleports into the
-- HALL_OF_FAME room, faces the console jutting from the north wall, and
-- exercises the injected TEXT_HALLOFFAME_PC sign both ways: NO leaves the
-- player in the room, YES warps home to the new-game bedroom spawn
-- (REDS_HOUSE_2F, 3, 6). Screenshots the prompt and the arrival.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
-- press A to advance the ask text until the YES/NO box is on top
local function openChoice()
for _ = 1, 120 do
if topIs(ChoiceBox) then return true end
if topIs(TextBox) then U.tap(game, "a") end
U.wait(3)
end
return false
end
-- stand at (4,2) facing up: the console solid cell (4,1) is dead ahead
U.teleport(game, "HALL_OF_FAME", 4, 2, "up")
local ow = game.overworld
local fx, fy = ow.player:facingCell()
U.log(("at (%d,%d) facing %s -> facing cell (%d,%d)"):format(
ow.player.cellX, ow.player.cellY, ow.player.facing, fx, fy))
check(ow.map:signAtCell(fx, fy) ~= nil, "a sign sits on the console tile")
-- ---- NO: prompt appears, player stays in the Hall of Fame ----
U.tap(game, "a") -- interact -> ask
U.wait(4)
check(openChoice(), "NO run: YES/NO prompt opened")
U.shot(game, DIR .. "/hof_pc_1_prompt.png")
U.tap(game, "down") -- cursor YES -> NO
U.wait(2)
U.tap(game, "a") -- confirm NO
U.wait(20)
check(ow.map.id == "HALL_OF_FAME", "NO keeps the player in HALL_OF_FAME")
-- ---- YES: warp back to the bedroom spawn ----
U.teleport(game, "HALL_OF_FAME", 4, 2, "up")
ow = game.overworld
U.tap(game, "a")
U.wait(4)
check(openChoice(), "YES run: YES/NO prompt opened")
U.tap(game, "a") -- YES (default cursor)
local warped = false
for _ = 1, 300 do
if ow.map.id == "REDS_HOUSE_2F" and not ow.transitioning then warped = true; break end
U.wait(2)
end
U.log(("after YES: map=%s pos=(%d,%d) facing=%s"):format(
ow.map.id, ow.player.cellX, ow.player.cellY, ow.player.facing))
U.wait(10)
U.shot(game, DIR .. "/hof_pc_2_home.png")
check(warped, "YES warps to REDS_HOUSE_2F")
check(ow.player.cellX == 3 and ow.player.cellY == 6,
"landed on the bedroom spawn (3,6)")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
+48
View File
@@ -0,0 +1,48 @@
-- Visual test: SQUIRTLE (player) vs BULBASAUR (enemy) in OG RED, to match
-- the Game Boy Color hardware capture. Run with a display:
--
-- SHOT_DIR=/tmp/ogred POKEPORT_IDENTITY=pokeport-ogred-shot \
-- POKEPORT_DRIVER=tests/drivers/ogred_battle_test.lua love .
--
-- Captures ogred_0{1..5}_*.png into SHOT_DIR. OG RED is a global palette:
-- red BG (terrain, mon pics, HUD, text) + green OBJ (overworld characters,
-- battle effects). Battle mon pics are BG tiles, so they come out red/pink
-- on the near-white field -- see PaletteFX.GBC_BG / monPal.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "."
local PaletteFX = require("src.render.PaletteFX")
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
-- save.options.colors, so a bare setMode would get reverted to the default.
game.save.options = game.save.options or {}
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "SQUIRTLE", 5) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "BULBASAUR", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
U.wait(220)
U.shot(game, DIR .. "/ogred_01_intro.png")
for _ = 1, 24 do U.tap(game, "a"); U.wait(6) end
U.shot(game, DIR .. "/ogred_02_menu.png")
U.tap(game, "a"); U.wait(12) -- FIGHT -> move list
U.shot(game, DIR .. "/ogred_03_moves.png")
U.tap(game, "down"); U.wait(6) -- TACKLE -> TAIL WHIP
U.tap(game, "a"); U.wait(30)
U.shot(game, DIR .. "/ogred_04_tailwhip.png")
U.wait(40)
U.shot(game, DIR .. "/ogred_05_after.png")
U.wait(4)
end
+2
View File
@@ -40,6 +40,8 @@ stub.graphics = {
newSpriteBatch = function(image, size)
local batch = { image = image, sprites = {} }
function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end
function batch:clear() self.sprites = {} end
function batch:setTexture(tex) self.texture = tex end
return batch
end,
draw = noop, rectangle = noop, setColor = noop, clear = noop,
+5
View File
@@ -113,6 +113,7 @@ love.graphics = {
newSpriteBatch = function(image, size)
local batch = { image = image, sprites = {} }
function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end
function batch:clear() self.sprites = {} end
function batch:setTexture(tex) self.texture = tex end
return batch
end,
@@ -287,6 +288,8 @@ local renderer = TileRenderer.new(map)
check(#renderer.anims == 1, "a declared animatedTiles entry builds one anim")
check(renderer.anims[1].period == 12, "the declared period is honored")
check(#renderer.anims[1].textures == 3, "the declared frame images load")
-- the camera-window fill gathers the on-screen animated cells into anims[].batch
renderer:draw(0, 0)
check(renderer.anims[1].batch ~= nil, "the animated tile collected cells")
-- the vanilla water cycle, driven through the same data path: eight
@@ -307,6 +310,8 @@ local sea = TileRenderer.new({
check(#sea.anims == 1, "an OVERWORLD tileset animates its water with no record edit")
check(#sea.anims[1].textures == 8, "the water entry builds 8 shifted variants")
-- fill the camera window so the water cells are gathered into the batch
sea:draw(0, 0)
local seen = {}
for step = 1, 8 do
sea:drawAnimated(0, 0)
+11 -1
View File
@@ -196,6 +196,7 @@ do
check(#loaded.meta.mods == 0, "old vanilla save records an empty mod set")
check(loaded.boxes and loaded.boxes[1][1].species == "PIDGEY", "box list folded into boxes[1]")
check(loaded.box == nil, "legacy box key gone")
check(loaded.version == "red", "untagged save defaults to the Red version")
local opts = SaveSerializer.decode(files["options.lua"])
check(opts and opts.musicVol == 2, "embedded options split into options.lua")
@@ -203,7 +204,16 @@ do
local current = { meta = { format = Version.saveFormat, mods = {} },
player = { map = "PALLET_TOWN", x = 1, y = 1 } }
SaveData.runMigrations(current)
check(current.player.id == nil, "format-2 save skips the id backfill")
check(current.player.id == nil, "current-format save skips the id backfill")
-- a format-2 save (Blue support not yet shipped) gains the Red tag;
-- a save that already names a version keeps it
local untagged = { meta = { format = 2, mods = {} }, player = {} }
SaveData.runMigrations(untagged)
check(untagged.version == "red", "pre-version save defaults to Red")
local tagged = { meta = { format = 2, mods = {} }, version = "blue", player = {} }
SaveData.runMigrations(tagged)
check(tagged.version == "blue", "an existing version tag is left untouched")
love.filesystem = realFS
end
+41 -3
View File
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
local ManagerState = require("src.mods.ManagerState")
local ModUI = require("src.ui.ModUI")
local Theme = require("src.ui.Theme")
local FrameCap = require("src.core.FrameCap")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
@@ -205,7 +206,7 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "speed", "mods", "controls" }
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -235,10 +236,47 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[12].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[12].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
"FrameCap keeps the exact floor and ceiling")
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
"FrameCap.label renders the normalized cap as plain text")
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
check(FrameCap.cycle(nil, 1) == 75,
"FrameCap cycle normalizes a nil cap (60) before stepping")
-- apply drives the live value the run loop paces to; never touches love.timer
FrameCap.apply(144)
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
FrameCap.applyOptions({})
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[13].activate(mgGame)
om.rows[14].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +286,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[14].activate(cbGame)
om.rows[15].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+17 -7
View File
@@ -2091,6 +2091,7 @@ do
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions())
@@ -2155,7 +2156,16 @@ do
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 12, "cursor reaches GAME SPEED")
eq(om.index, 12, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
-- Driven by the step list rather than a literal press count, like GAME
-- SPEED below: a full loop of #STEPS presses returns to the 60 default.
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
press("down")
eq(om.index, 13, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2164,19 +2174,19 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 13, "cursor reaches MODS")
eq(om.index, 14, "cursor reaches MODS")
press("down")
eq(om.index, 14, "cursor reaches CONTROLS")
eq(om.index, 15, "cursor reaches CONTROLS")
press("down")
eq(om.index, 15, "CANCEL stays the fixed final row")
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
eq(om.index, 16, "CANCEL stays the fixed final row")
eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 15, "up from the top wraps to CANCEL")
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 16, "up from the top wraps to CANCEL")
eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Derive the Pokemon Blue import manifest from the shipped Red manifest.
Red and Blue are assembled from one pokered source tree; the two ROMs are
byte-identical except for a small set of version-gated regions (wild
encounters, SGB/GBC palettes, the title ribbon, credits text, preset names).
Everything the manifest carries is either identical between the versions or
derivable, so rather than re-parsing pokered from scratch -- which would drag
in unrelated drift between the checked-out source and the *shipped* Red
manifest -- we take the shipped Red manifest verbatim and override only the
fields that genuinely differ for Blue:
* romSha1 -- Blue's ROM hash.
* symbols -- the 23 map-header symbols in bank $1D that shift by one
byte in Blue (everything else, audio included, is at the
same address). Re-sourced from pokeblue.sym.
* field.presetNames -- player/rival name presets swap (Blue's player is
BLUE/GARY/JOHN, rival RED/ASH/JACK).
* field.credits -- the "BLUE VERSION STAFF" title line (and any staff
reordering) parsed with _BLUE defined.
Every other field -- maps, trainers, text, audio addresses, trainer party
overrides, tilesets, etc. -- is inherited unchanged, so Blue behaves exactly
like Red wherever the two games are identical. The version-specific *content*
that is not in the manifest (wild Pokemon, palette colours, the ribbon
graphic, credits strings) is decoded from the Blue ROM at import time, which
works because the symbol addresses above now point at Blue's data.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import field, util # noqa: E402
from rom_data import CANONICAL_BLUE_SHA1, SymbolTable # noqa: E402
DEV = "/Users/bryanbassett/Documents/development"
DEFAULT_RED = os.path.join(os.path.dirname(__file__), "rom_manifest.json")
DEFAULT_OUT = os.path.join(os.path.dirname(__file__), "rom_manifest_blue.json")
DEFAULT_POKERED = os.path.join(DEV, "pokered")
DEFAULT_SYMBOLS = os.path.join(DEV, "decprep/pokered-symbols/pokeblue.sym")
def derive(red, pokered, symbols_path):
"""Return the Blue manifest derived from the Red manifest dict."""
blue = copy.deepcopy(red)
blue["romSha1"] = CANONICAL_BLUE_SHA1
# Re-source every symbol the manifest already references from Blue's .sym.
# The name set is identical between versions; only 23 bank-$1D map headers
# actually move, but resolving the whole set keeps this robust to future
# shifts and fails loudly if pokeblue.sym is ever missing a name.
blue_symbols = SymbolTable(symbols_path)
resolved, missing = {}, []
for name in red["symbols"]:
symbol = blue_symbols.by_name.get(name)
if symbol is None:
missing.append(name)
continue
resolved[name] = [symbol.bank, symbol.address]
if missing:
raise SystemExit(
"pokeblue.sym is missing symbols the manifest needs: "
+ ", ".join(sorted(missing)[:10])
+ (" ..." if len(missing) > 10 else ""))
blue["symbols"] = resolved
# Version-gated field bits. Calling the parsers directly (rather than
# through field_metadata) sidesteps field_metadata's Red-only sanity
# checks, which is exactly what we want for a Blue build.
saved = util.ASM_DEFINES
util.ASM_DEFINES = {"_BLUE"}
try:
blue["field"]["presetNames"] = field.parse_preset_names(pokered)
blue["field"]["credits"] = field.parse_credits(pokered)
finally:
util.ASM_DEFINES = saved
# Sanity: Blue's presets must be the swapped set, and the credits banner
# must say BLUE. A silent Red-through here would be a hard-to-spot bug.
presets = blue["field"]["presetNames"]
if "BLUE" not in presets["player"] or "RED" not in presets["rival"]:
raise SystemExit("Blue preset-name parse did not swap player/rival")
banner = blue["field"]["credits"]["screens"][0]["lines"][1]["text"]
if banner != "BLUE VERSION STAFF":
raise SystemExit(f"Blue credits banner is {banner!r}, expected BLUE")
return blue
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--red", default=DEFAULT_RED,
help="shipped Red manifest to derive from")
parser.add_argument("--pokered", default=DEFAULT_POKERED,
help="pokered source checkout (for _BLUE field bits)")
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS,
help="pokeblue.sym symbol file")
parser.add_argument("--out", default=DEFAULT_OUT)
args = parser.parse_args()
pokered = os.path.abspath(args.pokered)
if not os.path.isfile(os.path.join(pokered, "main.asm")):
raise SystemExit(f"{pokered} is not a pokered checkout")
with open(args.red, encoding="utf-8") as f:
red = json.load(f)
blue = derive(red, pokered, os.path.abspath(args.symbols))
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(blue, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
+1
View File
@@ -9,6 +9,7 @@ from dataclasses import dataclass
CANONICAL_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
CANONICAL_BLUE_SHA1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2"
ROM_BANK_SIZE = 0x4000
File diff suppressed because it is too large Load Diff