Files
gen1recomp/src/core/gen2/MagnetTrain.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

362 lines
14 KiB
Lua

-- The Magnet Train ride (pokegold engine/events/magnet_train.asm).
--
-- `special MagnetTrain` is a self-contained cutscene: it takes the whole frame
-- loop away from the overworld, redraws the background out of the train
-- station tileset, and runs a seven-entry jumptable until it sets
-- JUMPTABLE_EXIT. Everything below is that routine with no love calls in it,
-- so the timing, the scroll and the frameset can be asserted headless; the
-- screen that draws it is src/ui/gen2/MagnetTrainRide.lua.
--
-- The illusion is one 32x18 background and three horizontal SCX bands:
--
-- scanlines 0-46 wMagnetTrainOffset * 2 bushes, always moving
-- scanlines 47-94 wMagnetTrainPosition the train body
-- scanlines 95-143 wMagnetTrainOffset * 2 bushes again
--
-- MagnetTrain_UpdateLYOverrides writes those three runs into
-- wLYOverridesBackup every frame and then advances the offset, so the scenery
-- never stops even while the jumptable is parked on a .WaitScene. The train
-- band is what the jumptable actually moves, and the player sprite rides it
-- through wGlobalAnimXOffset, which is why the two stay locked together.
--
-- Everything here is 8-bit and wraps, exactly as the ASM's `add` does: the
-- forward trip runs wMagnetTrainPosition from 96 down past 0 to -96, and it is
-- the byte wrap that keeps SCX legal on the way.
local MagnetTrain = {}
MagnetTrain.__index = MagnetTrain
-- constants/gfx_constants.asm
local TILE_WIDTH = 8
local SCREEN_WIDTH, SCREEN_HEIGHT = 20, 18
local TILEMAP_WIDTH = 32
local SCREEN_HEIGHT_PX = 144
-- PAL_BG_* (constants/gfx_constants.asm) as 1-based palette slots, the way
-- src/world/gen2/Palettes.lua indexes a bgSet.
MagnetTrain.PAL_BG_GRAY = 1
MagnetTrain.PAL_BG_GREEN = 3
MagnetTrain.PAL_BG_YELLOW = 5
-- SetMagnetTrainPals paints the attribute map in four ByteFills: four rows of
-- green, ten of gray, four more of green, and then six tiles of yellow at
-- (7, 8) for the window the player is framed in.
local BUSH_ROWS_TOP = 4 -- hlbgcoord 0, 0 / bc = 4 * TILEMAP_WIDTH
local TRAIN_ROWS = 10 -- hlbgcoord 0, 4 / bc = 10 * TILEMAP_WIDTH
local WINDOW_ROW = 8 -- hlbgcoord 7, 8 / bc = 6
local WINDOW_COL, WINDOW_WIDTH = 7, 6
-- DrawMagnetTrain lays MagnetTrainTilemap over BG rows 6-9.
local FG_ROW = 6
local FG_ROWS = 4
-- Every value in this file is a hardware byte.
local function b(value) return value % 256 end
MagnetTrain.byte = b
--------------------------------------------------------------------------
-- The player in the window
--------------------------------------------------------------------------
-- data/sprite_anims/framesets.asm .Frameset_MagnetTrainRed: two OAM sets on an
-- eight frame beat, the fourth of them mirrored, then `oamrestart`. The
-- object's own sequence is SPRITE_ANIM_FUNC_NULL (data/sprite_anims/
-- objects.asm), so nothing ever moves the struct: the only motion the player
-- has is wGlobalAnimXOffset, which the two MoveTrain states advance.
local FRAMESET = {
{ oamset = 1, duration = 8, xflip = false },
{ oamset = 2, duration = 8, xflip = false },
{ oamset = 1, duration = 8, xflip = false },
{ oamset = 2, duration = 8, xflip = true },
"restart",
}
-- data/sprite_anims/oam.asm: SPRITE_ANIM_OAMSET_MAGNET_TRAIN_RED_1 and _2 are
-- vtile $00 and $04 over the same .OAMData_MagnetTrainRed 2x2 block. Those
-- two vtiles are the two four-tile requests MagnetTrain_LoadGFX_PlayMusic
-- makes: ChrisSpriteGFX at vTiles0 $00, and ChrisSpriteGFX + 12 tiles at
-- vTiles0 $04. A walking overworld sprite is six 16x16 frames, so those are
-- sheet frame 0 (standing down) and sheet frame 3 (the down walk step).
local OAMSET_VTILE = { 0x00, 0x04 }
MagnetTrain.SHEET_FRAME = { [0x00] = 0, [0x04] = 3 }
-- .OAMData_MagnetTrainRed, `dbsprite x tile, y tile, x px, y px, vtile, attr`.
-- Every entry carries OAM_PRIO, so on the cart the four tiles sit BEHIND
-- background colours 1-3 and only show through the window's colour 0.
local OAM_DATA = {
{ x = b(-1 * TILE_WIDTH), y = b(-1 * TILE_WIDTH), tile = 0x00 },
{ x = b(0 * TILE_WIDTH), y = b(-1 * TILE_WIDTH), tile = 0x01 },
{ x = b(-1 * TILE_WIDTH), y = b(0 * TILE_WIDTH), tile = 0x02 },
{ x = b(0 * TILE_WIDTH), y = b(0 * TILE_WIDTH), tile = 0x03 },
}
-- AddOrSubtractX: a mirrored object flips around its own 8-pixel cell.
local function mirror(value, flip)
if not flip then return value end
return b(-(value + TILE_WIDTH))
end
--------------------------------------------------------------------------
-- The ride
--------------------------------------------------------------------------
-- `toGoldenrod` is the wScriptVar the script left behind: Goldenrod's officer
-- writes `setval FALSE` and Saffron's writes `setval TRUE`, and MagnetTrain
-- reads it as "and a / jr nz, .ToGoldenrod".
--
-- opts.bgTiles is MagnetTrainBGTiles (a 2x18 tilemap) and opts.fgTilemap is
-- MagnetTrainTilemap (20x4); both come from the extracted cache and either may
-- be missing, in which case :tilemap() answers nil and the ride still runs.
function MagnetTrain.new(opts)
opts = opts or {}
local self = setmetatable({}, MagnetTrain)
self.toGoldenrod = opts.toGoldenrod and true or false
if self.toGoldenrod then
-- .ToGoldenrod: `ld a, -1` / `lb bc, -8 tiles, -12 tiles` /
-- `lb de, (11 tiles) + (11 tiles + 4), 12 tiles`.
self.direction = b(-1)
self.holdPosition = b(-8 * TILE_WIDTH) -- b
self.initPosition = b(-12 * TILE_WIDTH) -- c
self.finalPosition = b(12 * TILE_WIDTH) -- e
self.playerSpriteInitX =
b((11 * TILE_WIDTH) + (11 * TILE_WIDTH + 4)) -- d
else
-- forwards: `ld a, 1` / `lb bc, 8 tiles, 12 tiles` /
-- `lb de, (11 tiles) - (11 tiles + 4), -12 tiles`.
self.direction = 1
self.holdPosition = b(8 * TILE_WIDTH)
self.initPosition = b(12 * TILE_WIDTH)
self.finalPosition = b(-12 * TILE_WIDTH)
self.playerSpriteInitX = b((11 * TILE_WIDTH) - (11 * TILE_WIDTH + 4))
end
-- MagnetTrain_LoadGFX_PlayMusic's tail writes wJumptableIndex and the three
-- bytes after it, so the wait counter starts life holding the init position.
-- State 0 overwrites it before any .WaitScene reads it.
self.index = 0
self.offset = self.initPosition
self.position = self.initPosition
self.waitCounter = self.initPosition
self.exited = false
self.globalX = 0
-- The sprite struct does not exist until .InitPlayerSpriteAnim runs.
self.spriteX, self.spriteY = nil, nil
self.frame, self.frameDuration = -1, 0
self.oamFrame = nil
self.bgTiles = opts.bgTiles
self.fgTilemap = opts.fgTilemap
self:updateLYOverrides(true)
return self
end
function MagnetTrain:done() return self.exited end
-- MagnetTrain's .loop, one pass: the exit bit, PlaySpriteAnimations, the
-- jumptable, then the LY overrides. Returns the sfx label the frame played,
-- which is only ever SFX_TRAIN_ARRIVED on the last one.
function MagnetTrain:update()
if self.exited then return nil end
self:stepSpriteFrame()
local sfx = self:runJumptable()
self:updateLYOverrides()
return sfx
end
-- MagnetTrain_Jumptable.Next
function MagnetTrain:next()
self.index = self.index + 1
end
-- .WaitScene: zero means "advance", anything else counts down. A counter of
-- 128 therefore holds for 129 frames, the last of which is the one that reads
-- zero and moves on.
function MagnetTrain:waitScene()
if self.waitCounter == 0 then
self:next()
return
end
self.waitCounter = self.waitCounter - 1
end
function MagnetTrain:runJumptable()
local index = self.index
if index == 0 then
-- .InitPlayerSpriteAnim: InitSpriteAnimStruct at d = (8 + 2) * 8 + 5,
-- e = wMagnetTrainPlayerSpriteInitX, then SPRITEANIMSTRUCT_TILE_ID = 0.
self.spriteY = b((8 + 2) * TILE_WIDTH + 5)
self.spriteX = self.playerSpriteInitX
self.frame, self.frameDuration, self.oamFrame = -1, 0, nil
self:next()
self.waitCounter = 128
elseif index == 1 or index == 3 or index == 5 then
self:waitScene()
elseif index == 2 then
-- .MoveTrain1: one pixel a frame until the train reaches its hold
-- position, then park for another 128 frames.
if self.position == self.holdPosition then
self:next()
self.waitCounter = 128
return nil
end
self.position = b(self.position - self.direction)
self.globalX = b(self.globalX + self.direction)
elseif index == 4 then
-- .MoveTrain2: the same, at double speed, until it leaves the screen.
if self.position == self.finalPosition then
self:next()
return nil
end
self.position = b(self.position - 2 * self.direction)
self.globalX = b(self.globalX + 2 * self.direction)
elseif index >= 6 then
-- .TrainArrived: JUMPTABLE_EXIT and SFX_TRAIN_ARRIVED, and the loop reads
-- the exit bit at the top of the next pass.
self.exited = true
return "Sfx_TrainArrived"
end
return nil
end
-- MagnetTrain_UpdateLYOverrides. The three runs are 6*8-1, 6*8 and 6*8+1
-- entries, which is 144 scanlines exactly; hSCX takes the first band's value
-- because line 0 is drawn before the LCD interrupt has fired. The offset
-- advances by two per frame (`add d` twice) AFTER the overrides are written.
--
-- `initial` builds the first frame's table without advancing, matching
-- MagnetTrain_InitLYOverrides, which ByteFills the whole array with the init
-- position before the loop starts.
function MagnetTrain:updateLYOverrides(initial)
local ly = self.ly or {}
if initial then
for line = 1, SCREEN_HEIGHT_PX do ly[line] = self.initPosition end
self.ly = ly
self.scx = self.initPosition
return ly
end
local scx = b(self.offset * 2)
self.scx = scx
local line = 1
for _ = 1, 6 * TILE_WIDTH - 1 do ly[line] = scx; line = line + 1 end
for _ = 1, 6 * TILE_WIDTH do ly[line] = self.position; line = line + 1 end
for _ = 1, 6 * TILE_WIDTH + 1 do ly[line] = scx; line = line + 1 end
self.ly = ly
self.offset = b(self.offset + 2 * self.direction)
return ly
end
-- The SCX each of the three bands is scrolled by this frame, as
-- { first scanline, last scanline (inclusive), scx }. A band is a run of
-- equal LY overrides, so this is the same information the table holds and the
-- shape a renderer wants.
function MagnetTrain:bands()
local ly = self.ly
if not ly then return {} end
local out = {}
local start, value = 0, ly[1]
for line = 1, SCREEN_HEIGHT_PX do
if ly[line] ~= value then
out[#out + 1] = { start, line - 2, value }
start, value = line - 1, ly[line]
end
end
out[#out + 1] = { start, SCREEN_HEIGHT_PX - 1, value }
return out
end
--------------------------------------------------------------------------
-- The background
--------------------------------------------------------------------------
-- DrawMagnetTrain. Rows 0-17 are MagnetTrainBGTiles' two-tile pair for that
-- row repeated across all 32 columns (`.FillAlt`, TILEMAP_WIDTH / 2 times),
-- and then MagnetTrainTilemap's four 20-tile lines are laid over rows 6-9.
--
-- Answers nil when the cache carries no tilemaps, which is what a cache built
-- before the extractor learned about them looks like.
function MagnetTrain:tilemap()
local bg = self.bgTiles
if not (bg and #bg >= SCREEN_HEIGHT * 2) then return nil end
local rows = {}
for row = 0, SCREEN_HEIGHT - 1 do
local even, odd = bg[row * 2 + 1], bg[row * 2 + 2]
local line = {}
for col = 0, TILEMAP_WIDTH - 1 do
line[col + 1] = (col % 2 == 0) and even or odd
end
rows[row + 1] = line
end
local fg = self.fgTilemap
if fg and #fg >= SCREEN_WIDTH * FG_ROWS then
for line = 0, FG_ROWS - 1 do
local row = rows[FG_ROW + line + 1]
for col = 0, SCREEN_WIDTH - 1 do
row[col + 1] = fg[line * SCREEN_WIDTH + col + 1]
end
end
end
return rows
end
-- SetMagnetTrainPals, read back as "which palette does this cell use".
-- `col` and `row` are 0-based BG map coordinates.
function MagnetTrain.paletteSlot(col, row)
if row == WINDOW_ROW and col >= WINDOW_COL
and col < WINDOW_COL + WINDOW_WIDTH then
return MagnetTrain.PAL_BG_YELLOW
end
if row < BUSH_ROWS_TOP then return MagnetTrain.PAL_BG_GREEN end
if row < BUSH_ROWS_TOP + TRAIN_ROWS then return MagnetTrain.PAL_BG_GRAY end
return MagnetTrain.PAL_BG_GREEN
end
--------------------------------------------------------------------------
-- The sprite
--------------------------------------------------------------------------
-- GetSpriteAnimFrame, cut down to one frameset that never waits, ends or
-- changes sequence. A frame with duration 8 is therefore shown nine times:
-- the pass that sets the duration, then eight that decrement it.
function MagnetTrain:stepSpriteFrame()
if not self.spriteX then return end
if self.frameDuration ~= 0 then
self.frameDuration = self.frameDuration - 1
return
end
self.frame = self.frame + 1
local entry = FRAMESET[self.frame + 1]
if entry == "restart" then
self.frame = 0
entry = FRAMESET[1]
end
self.frameDuration = entry.duration
self.oamFrame = entry
end
-- The four OAM entries the player is drawn as this frame, each
-- { x, y, tile, xflip } in SCREEN pixels (the hardware's byte minus the 8 and
-- 16 pixel OAM origins). `tile` is the vtile the OAM set resolves to, which
-- MagnetTrain.SHEET_FRAME turns into a 16x16 frame of the walking sheet.
--
-- Empty before .InitPlayerSpriteAnim has run.
function MagnetTrain:playerOam()
local entry = self.oamFrame
if not (entry and self.spriteX) then return {} end
local vtile = OAMSET_VTILE[entry.oamset]
local out = {}
for _, sprite in ipairs(OAM_DATA) do
local x = b(self.spriteX + self.globalX + mirror(sprite.x, entry.xflip))
local y = b(self.spriteY + sprite.y)
out[#out + 1] = {
x = x - 8,
y = y - 16,
tile = vtile + sprite.tile,
xflip = entry.xflip,
}
end
return out
end
return MagnetTrain