Pokemon Silver as a full launcher version, plus launcher mods-list and title-tempo fixes

Silver: derived import manifest (tools/make_silver_manifest.py re-resolves
the Gold manifest's symbols from pokesilver.sym), silver GameVersion row,
generation-keyed extractor routing, required-files override, edition save
stamping (a Silver playthrough no longer writes into the Gold save),
checkver-driven edition data, SILVER/KAMON/OSCAR/MAX presets, GOLD rival
default, edition credits banner, Lugia title screen (OAM layouts, bob,
trail, palettes as title.lua data keys with Gold defaults so old caches
need no re-import), packaging for every build target, docs, and tests.

Launcher: the installed-mods list is one continuous scroll (rows culled to
the viewport) instead of a pager with an inner scroll viewport; the pad
cursor's edge-scroll no longer runs it to the bottom. The game dropdown
shows just the initial and caret. Find-tab behavior unchanged.

Title tempo: a sprite-anim frame shows duration+1 ticks
(engine/sprite_anims/core.asm GetSpriteAnimFrame), which locks both
editions' 64-tick wing beat to the 64-tick sine bob; the title screens no
longer run fast and out of phase.
This commit is contained in:
bryanthaboi
2026-08-20 08:27:25 -04:00
parent 4c8c1cf36b
commit ec9dc29646
68 changed files with 18617 additions and 466 deletions
+12 -5
View File
@@ -189,11 +189,18 @@ defineString("CREDIT_END", "END")
-- STAFF and everything after it is a heading. Anything BELOW this id is a
-- person as far as ParseCredits is concerned.
Credits.STAFF = defineString("STAFF", {
" #MON",
" GOLD VERSION",
" PORT STAFF",
})
-- Credits_Staff differs per edition, including the centring spaces
-- (data/credits_strings.asm:54-60).
Credits.STAFF = defineString("STAFF",
require("src.core.GameVersion").get() == "silver" and {
" #MON",
" SILVER VERSION",
" PORT STAFF",
} or {
" #MON",
" GOLD VERSION",
" PORT STAFF",
})
defineString("DIRECTOR", " DIRECTOR")
defineString("PROGRAMMING", " PROGRAMMING")
defineString("ENGINE_DESIGN", " ENGINE DESIGN")
+2 -2
View File
@@ -147,8 +147,8 @@ function GoldSilverIntro.new(game, opts)
-- the movie's full 2335 frames over an empty screen and reads exactly
-- like "the intro does not work". Say so instead: the fix is a
-- re-import, and nothing else in the boot chain will mention it.
Logger.warn("gold intro: no intro.lua in the cache -- re-import Gold "
.. "or the movie plays blank")
Logger.warn("gen2 intro: no intro.lua in the cache -- re-import "
.. "this version or the movie plays blank")
end
self.images = {}
self.sheets = {}
+1 -1
View File
@@ -56,7 +56,7 @@ function MainMenu.new(game, opts)
self.save = opts.save
if self.save == nil and opts.hasSave ~= false then
local loaded = Save.load("gold")
local loaded = Save.load()
self.save = loaded
end
self.hasSave = opts.hasSave
+12 -4
View File
@@ -34,8 +34,14 @@ local NamePick = {}
NamePick.__index = NamePick
NamePick.isOpaque = true
-- data/player_names.asm PlayerNameArray, Gold's half of the IF.
-- data/player_names.asm PlayerNameArray, one half of the IF per edition.
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" }
local function presetsFor()
local silver = require("src.core.GameVersion").get() == "silver"
return silver and PRESETS_SILVER or PRESETS
end
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11
@@ -60,7 +66,7 @@ function NamePick.new(game, opts)
self.game = game
self.onDone = opts.onDone
self.items = { "NEW NAME" }
for _, name in ipairs(opts.presets or PRESETS) do
for _, name in ipairs(opts.presets or presetsFor()) do
self.items[#self.items + 1] = name
end
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
@@ -106,7 +112,7 @@ function NamePick:openNaming()
if name and #name > 0 then
self:choose(name)
else
self:choose(self.items[2] or "GOLD")
self:choose(self.items[2] or presetsFor()[1])
end
end,
})
@@ -146,7 +152,7 @@ function NamePick:update(_dt)
if self.fontOk then
self:openNaming()
else
self:choose(self.items[2] or "GOLD")
self:choose(self.items[2] or presetsFor()[1])
end
else
-- A preset returns through MovePlayerPicLeft, so the pic walks back
@@ -227,5 +233,7 @@ function NamePick:drawWidescreen(winW, winH)
end
NamePick.PRESETS = PRESETS
NamePick.PRESETS_SILVER = PRESETS_SILVER
NamePick.presetsFor = presetsFor
return NamePick
+4 -4
View File
@@ -72,8 +72,8 @@ end
-- Naming presets are boot config the same way Gen 1 reads them
-- (field.boot.namePresets), so a total conversion that replaces the list once
-- replaces it for both games; NamePick.PRESETS (data/player_names.asm
-- PlayerNameArray) is Gold's fallback.
-- replaces it for both games; NamePick.presetsFor (data/player_names.asm
-- PlayerNameArray) is the running edition's fallback.
local function namePresets(game, who, fallback)
local boot = game and game.data and game.data.field
and game.data.field.boot
@@ -351,9 +351,9 @@ function OakSpeech:openNamePick(step)
picColors = self.playerColors,
presets = step.presets
or namePresets(self.game, step.presetsWho or step.who or "player",
step.presetsFallback or NamePick.PRESETS),
step.presetsFallback or NamePick.presetsFor()),
onDone = function(name)
name = name or "GOLD"
name = name or NamePick.presetsFor()[1]
self.game.save.player.name = name
self.game.stack:pop() -- NamePick
self.busy = false
+1 -1
View File
@@ -181,7 +181,7 @@ function PcMenu:beginChangeBox(index)
self.saveTimer = 0
self.saved = nil
local existed = self.saveExists
if existed == nil then existed = Save.exists("gold") end
if existed == nil then existed = Save.exists() end
self.existed = existed
end
+1 -1
View File
@@ -71,7 +71,7 @@ function SaveMenu.new(game, opts)
self.onDone = opts.onDone
self.writer = opts.writer or Save.save
local existed = opts.existed
if existed == nil then existed = Save.exists("gold") end
if existed == nil then existed = Save.exists() end
self.existed = existed
-- confirm -> overwrite (only when a file exists) -> saving -> done
self.phase = "confirm"
+60 -25
View File
@@ -2,6 +2,7 @@
-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue.
-- drawWidescreen fills the window with sky/clouds so widescreen has no
-- pillarbox voids; the 160x144 art stays aspect-centered on top.
-- Every Gold/Silver difference arrives as a title.lua key, defaulted to Gold.
-- src/render/Assets.lua is the mod-override choke point: a raw
-- love.graphics.newImage skips overrides/ and AssetTransform output.
@@ -16,7 +17,7 @@ local TitleState = {}
TitleState.__index = TitleState
TitleState.isOpaque = true
-- title_bg_gold.pal mid-sky shade (sampled from composed title_screen.png).
-- title_bg_gold.pal mid-sky shade, for a cache built before title.sky existed.
local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 }
-- ...and its grey stand-in, for when COLOR is not GBC. The title art is the
-- one thing in the port baked with its colours in (see the extractor), so the
@@ -61,6 +62,15 @@ function TitleState.new(game, opts)
self.hoohY = tonumber(title.hoohY) or 56
self.cloudY = tonumber(title.cloudY) or 88
self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
self.hoohBobAmplitude = tonumber(title.hoohBobAmplitude) or 2
self.hoohBobStep = tonumber(title.hoohBobStep) or 1
local sky = title.sky
self.sky = (type(sky) == "table" and #sky >= 3)
and { sky[1], sky[2], sky[3], 1 } or SKY
local below = title.below
self.below = (type(below) == "table" and #below >= 3)
and { below[1], below[2], below[3], 1 } or { 1, 1, 1, 1 }
self.hoohColor, self.hoohGray = {}, {}
local paths = title.hoohFrames
@@ -80,8 +90,12 @@ function TitleState.new(game, opts)
self.sequence = title.hoohSequence or {
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
}
-- A frame shows duration + 1 ticks: GetSpriteAnimFrame stores the byte on
-- the advancing tick and only decrements on the ones after
-- (engine/sprite_anims/core.asm:400-434). Both editions' framesets total
-- 64 ticks with it, locking the wing beat to the 64-tick sine bob.
self.seqIndex = 1
self.seqLeft = self.sequence[1] and self.sequence[1][2] or 10
self.seqLeft = (self.sequence[1] and self.sequence[1][2] or 10) + 1
self.frame = 1
-- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1.
@@ -89,12 +103,21 @@ function TitleState.new(game, opts)
self.frameCounter = 0
self.cloudScroll = 0
self.trails = {}
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm), in pixels.
self.trailSpawns = {
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm:1069-1124), in
-- pixels.
self.trailSpawns = title.trailSpawns or {
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
{ 120, 88 }, { 88, 88 },
}
self.trailSpawnIndex = 1
-- AnimSeq_GSTitleTrail (engine/sprite_anims/functions.asm:720-818).
self.trailMode = title.trailMode or "gold"
self.trailSpawnEvery = tonumber(title.trailSpawnEvery) or 4
self.trailStepX = tonumber(title.trailStepX) or 4
self.trailStepY = tonumber(title.trailStepY) or 1
self.trailBobAmplitude = tonumber(title.trailBobAmplitude) or 2
self.trailPhaseStep = tonumber(title.trailPhaseStep) or 3
self.trailPhase = tonumber(title.trailPhase)
-- How far past the 160px frame trails may fly (GB pixels); set each draw.
self.trailMaxX = 200
self.musicStarted = false
@@ -119,47 +142,56 @@ function TitleState:enter()
end
end
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm): VAR1 counts up
-- one per frame and the struct's Y offset becomes `d * sin(VAR1 * pi/32)` with
-- d = 2 on Gold (Silver counts DOWN with d = 8). Sprites_Sine hands back the
-- byte the ASM leaves in a, so the down half of the wave arrives in two's
-- complement and has to be read as a signed pixel delta here.
function TitleState:hoohBob()
local value = SpriteAnims.sine(self.hoohPhase, 2)
if value >= 0x80 then value = value - 0x100 end
-- Sprites_Sine hands back the byte the ASM leaves in a, so the down half of
-- the wave arrives in two's complement and is a signed pixel delta here.
local function signed(value)
if value >= 0x80 then return value - 0x100 end
return value
end
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
function TitleState:hoohBob()
return signed(SpriteAnims.sine(self.hoohPhase, self.hoohBobAmplitude))
end
function TitleState:advanceHooh()
self.hoohPhase = (self.hoohPhase + 1) % 256
self.hoohPhase = (self.hoohPhase + self.hoohBobStep) % 256
self.seqLeft = self.seqLeft - 1
if self.seqLeft > 0 then return end
self.seqIndex = self.seqIndex + 1
if self.seqIndex > #self.sequence then self.seqIndex = 1 end
local step = self.sequence[self.seqIndex]
self.frame = step[1]
self.seqLeft = step[2]
self.seqLeft = step[2] + 1
end
function TitleState:spawnTrail()
if not (self.trailColor or self.trailGray) then return end
if self.frameCounter % 4 ~= 0 then return end
if #self.trailSpawns == 0 then return end
if self.frameCounter % self.trailSpawnEvery ~= 0 then return end
local spawn = self.trailSpawns[self.trailSpawnIndex]
self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1
if not spawn then return end
self.trails[#self.trails + 1] = {
x = spawn[1], y = spawn[2], phase = love.math.random(0, 255),
x = spawn[1], y = spawn[2],
phase = self.trailPhase or love.math.random(0, 255),
}
end
function TitleState:stepTrails()
local alive = {}
local maxX = self.trailMaxX or 200
local silver = self.trailMode == "silver"
for _, t in ipairs(self.trails) do
t.x = t.x + 4
t.y = t.y + 1
t.phase = t.phase + 3
t.drawY = t.y + math.floor(math.sin(t.phase / 16) * 2)
t.x = t.x + self.trailStepX
t.y = t.y + self.trailStepY
t.phase = t.phase + self.trailPhaseStep
if silver then
t.drawY = t.y + signed(SpriteAnims.sine(t.phase, self.trailBobAmplitude))
else
t.drawY = t.y
+ math.floor(math.sin(t.phase / 16) * self.trailBobAmplitude)
end
if t.x < maxX then alive[#alive + 1] = t end
end
self.trails = alive
@@ -254,14 +286,17 @@ function TitleState:drawWidescreen(winW, winH)
-- Let trails fly into the side bands.
self.trailMaxX = math.ceil((winW - ox) / scale) + 16
-- Sky above the cloud line, paper white below : edge to edge. The fill has
-- to match whichever baked set is showing, or the surround would stay blue
-- around a grey screen.
local sky = self:gray() and SKY_GRAY or SKY
-- Sky above the cloud line, title.below under it (Gold's white cloud
-- field, Silver's black sea) : edge to edge. The fill has to match
-- whichever baked set is showing, or the surround would stay blue around a
-- grey screen.
local sky = self:gray() and SKY_GRAY or self.sky
G.setColor(sky[1], sky[2], sky[3], 1)
G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop))
G.setColor(1, 1, 1, 1)
local below = self.below
G.setColor(below[1], below[2], below[3], 1)
G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
G.setColor(1, 1, 1, 1)
-- Clouds across the full window width, aligned to the GB cloud band.
G.push()