mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 895e904d75 | |||
| 1ce9e32dd1 | |||
| 352a9a0eb8 | |||
| 5533785364 | |||
| 11f4f5570e | |||
| 6239387643 | |||
| 4a50cb0728 | |||
| 78aa1f3c0a | |||
| 5128b840b1 | |||
| da14b7ee3f | |||
| 2dbe21babf | |||
| 7a85862602 | |||
| 09b12a7cf0 | |||
| e2f2b432ff | |||
| 8c274d969e | |||
| 4c7633b56e |
@@ -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"
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+72
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user