Merge branch 'fix1037' into fix1038

# Conflicts:
#	docs/modding.md
This commit is contained in:
bryanthaboi
2026-08-10 14:12:21 -04:00
46 changed files with 1851 additions and 285 deletions
+10 -15
View File
@@ -1067,23 +1067,18 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
-- as a clip in #847 and re-checked against home/overworld.asm
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
{ "move_player", "up", 3 }, -- 26
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
-- The player walks out after Oak instead of the screen just fading on the
-- spot (#704). Route one tile right before walking north so the player
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
-- (4,2) cell. The original simulated movement bypasses entity collision,
-- but this scene should not visibly walk through the defeated rival.
{ "move_player", "right", 1 }, -- 26
{ "move_player", "up", 3 }, -- 27
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 27
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
{ "set_field", "pendingHallOfFame", true }, -- 28
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
}
M.CHAMPIONS_ROOM = {
+1 -1
View File
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
+86
View File
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
fields that moved; a new map becomes a `:register`. See
`docs/new-features.md` and the extension's own README.
## Read-only map overviews
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
`"3"` (darkest); their matching width and height fields describe the grid.
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
`warp`, visible `item`, and untaken `hidden` locations. All fields are
read-only snapshots; mods choose which layers to render.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -100,6 +110,44 @@ Three rules worth knowing:
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Variable-size overworld sprites
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
but a mod can describe any frame rectangle and anchor for player characters,
NPCs, followers, mounts, vehicles, bosses, or other field actors:
```lua
mod.content.sprites:register("SPRITE_COMPANION", {
image = "mods/example/companion.png", -- one frame per row
frames = 6,
walker = true,
frameWidth = 32,
frameHeight = 32,
anchorX = 16, -- frame-relative bottom-center anchor
anchorY = 32,
})
```
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
measured from each frame's top-left; when omitted they default to the frame's
horizontal center and bottom edge, so a larger sprite grows upward while its
feet stay on the same world cell. Omitting all four fields is exactly the
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
these values automatically, including horizontal flips and the fishing pose.
Custom render pipelines can use the same geometry without reproducing the
pose rules:
```lua
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
```
`getFrameGeometry(frame)` is the corresponding accessor for a specific
zero-based sheet frame. Both accessors return fresh tables and share the
renderers frame selection and mirror conventions.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
@@ -290,6 +338,11 @@ update and input ownership, so a mod can mirror a native menu on another
display without reimplementing it. The default is `true`. Treat the wrapper as
a pure predicate: the renderer may ask it more than once per frame.
Scrollable list states expose `state.kind` for use with this hook. Generic
lists fall back to their title; PC lists use stable, localization-independent
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
@@ -305,3 +358,36 @@ which stay unconditional and are never visible to a subscriber.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
that embeds this engine and wraps its window in platform UI) can live
entirely in a mod instead of hand-patching `main.lua`, which every other
engine change also touches.
`core.update` receives `(next, game, dt)` once per frame from
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
mod may skip calling `next(game, dt)` to pause the simulation for that
frame (e.g. while a native settings sheet is on top), and may run
additional per-frame polling before or after that call regardless of
whether it calls `next` -- useful for one-shot flags that must be observed
every frame even while paused.
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
returns the engine's own decision for whether closing the window should
return to the Lua launcher instead of exiting; a mod may return `false`
outright, without ever calling `next`, to veto that and let the process
really quit -- for a platform host that owns its own "return to launcher"
UI and would otherwise get looped straight back into the game it just
quit.
A manifest may also declare `force_enable_env`, an environment variable
name that re-enables the mod regardless of a saved disable in
`options.mods` when that variable is set to `"1"`. This is for a mod that
cannot function disabled on the one build where its env var is set (a
platform-bridge mod bundled only with that build's launcher, for example).
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
already falls straight through to the vanilla function when no mod has
wrapped the name, at negligible cost.
+107
View File
@@ -0,0 +1,107 @@
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
## Status
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
## Motivation
A platform-specific launcher wrapper -- a native shell that embeds this
engine and owns its own UI around the game window (a mobile app shell,
say, presenting its own settings/import/save screens and only handing
control to the LÖVE window once play starts) needs three things no
current hook covers:
1. Pause the simulation while its own UI is on top of the game window.
2. Live-reload options it wrote from outside any Lua UI.
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
behavior when the platform shell owns that job itself -- without this,
a shell that re-fronts its own launcher UI on quit gets looped straight
back into `HostShell.restart()`'s in-process reboot instead.
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
directly ties every such integration to editing the one file every other
engine change also touches, guaranteeing merge conflicts for any second
platform integration (or any unrelated engine PR landing around the same
time). No existing hook covers "should the per-frame simulation step run"
or "should closing the window return to the Lua launcher."
## The decision it extends
No prior D-number. Extends the hook-contract section of `docs/modding.md`
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
## The exact API delta
Backward-compatible, additive-only.
### `core.update`
New hook, `(game, dt) -> nil` through the public wrapper signature
`(next, game, dt)`, called once per frame from `love.update` via
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
behavior (used when no mod claims the hook) is `game:update(dt)`,
unconditionally -- identical to `love.update`'s behavior before this hook
existed. A subscriber may skip calling `next(game, dt)` to pause the
simulation for that frame, or do additional per-frame work before/after
calling it regardless of whether it calls `next`.
### `core.quit_to_launcher`
New hook, `() -> boolean` through the public wrapper signature `(next)`,
called once from `love.quit()` via
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
non-platform-specific decision (`Game and not Importer and not
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
may return `false` outright to veto returning to the Lua launcher (without
ever calling `next`, so the vanilla condition is never evaluated), or call
`next()` and return its result to pass the vanilla decision through
unchanged.
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
every call, matching the existing `input.step` precedent
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
`vanilla(...)` call when no mod has wrapped the name.
### `Manifest.force_enable_env`
New optional manifest field, a bare env-var name. `Loader:load` re-enables
a mod carrying this field whenever that variable is set to `"1"`,
regardless of a saved disable in `options.mods`. This exists for exactly
the mod class this RFC is for: a platform-bridge mod that ships only with
one build and cannot function disabled there, but must still behave like
every other mod (a manifest opt-in, not an engine special case) on every
build that doesn't set its variable.
## Migration note for existing mods
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
unconditionally every frame and `love.quit()`'s restart-to-launcher
decision is exactly the pre-existing condition -- bit-identical to today's
behavior on every platform where no mod wraps either hook. A manifest with
no `force_enable_env` field behaves exactly as before.
## Parity tests
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
through unchanged. Both hooks are picked up automatically by the
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
for `Runtime.call("...")` call sites), so neither needs a dedicated
no-mod test file.
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
through a fixture mod loaded via the public loader (not the engine's
internals), that a subscriber can skip the vanilla update call (pause),
run extra per-frame polling regardless of pause state, and veto the
quit-to-launcher decision without the vanilla condition ever running.
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
`force_enable_env`: a matching env var re-enables a mod saved as
disabled, and an unset one leaves the saved disable alone.
## Deprecation etiquette
Nothing deprecated. These are two additive hooks and one additive manifest
field; `main.lua`'s only footprint is one `require` and two call sites
into `src/core/PlatformHooks.lua`.
+16 -3
View File
@@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE =
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -426,7 +427,11 @@ function love.update(dt)
end
return
end
Game:update(dt)
-- Mods may wrap or veto the per-frame simulation step (pause it, react
-- to external platform state, etc.) -- see docs/modding.md's core.update
-- entry. Vanilla behavior (used when no mod claims the hook) is just
-- Game:update(dt), unconditionally, exactly as before this hook existed.
PlatformHooks.update(Game, dt)
end
function love.draw()
@@ -819,8 +824,16 @@ function love.quit()
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
-- back to and the restart would re-read the shortcut, so it exits instead.
if Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame then
--
-- A platform launcher that owns "return to launcher" itself (see
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below.
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame
end)
if wouldReturnToLauncher then
quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
-- once, so the restart really does land in the launcher (#887). A failed
+5
View File
@@ -1899,6 +1899,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
@@ -1935,6 +1936,7 @@ function BattleState:update(dt)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
@@ -1996,9 +1998,11 @@ function BattleState:update(dt)
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
@@ -2037,6 +2041,7 @@ function BattleState:update(dt)
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
+5 -5
View File
@@ -281,11 +281,11 @@ MoveEffects.primary = {
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
-- A Substitute costs one quarter of max HP, rounded down. Do not let
-- the cost consume the user's last HP: the move must fail at the exact
-- boundary as well as below it, or the next turn's HP guard can leave a
-- trainer battle unable to progress.
if user.mon.hp <= cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end
+83 -30
View File
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
}
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
local LPF_ALPHA = 0.8
local MIX_SCALE = 0.5
local function snapTicks(ticks)
return math.floor((ticks * 1470 + 256) / 512)
end
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
phase = 0,
noiseLfsr = 0x7FFF,
noiseClock = 0,
drumTail = nil,
timeTicks = 0,
}, Channel)
end
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
return math.min(15, volume + steps)
end
local function envelopeRingSamples(volume, fade)
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
end
local function extendDrumEnvelope(segments)
local last = segments and segments[#segments]
if not last then return segments end
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
if ringEnd > last.endSample then last.endSample = ringEnd end
return segments
end
local function drumAudioEnd(drum)
local last = drum and drum[#drum]
return last and last.endSample or 0
end
function Channel:resetNoise()
self.noiseLfsr = 0x7FFF
self.noiseClock = 0
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
end
end
end
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
end
local function sweepCalculation(register, sweep)
@@ -611,21 +635,51 @@ end
function Channel:sample()
while not self.ended
and (not self.event or self.event.sample >= self.event.samples) do
local prev = self.event
self.event = self:nextEvent()
self.phase = 0
self:resetNoise()
if self.event and self.event.drum then
self.drumTail = nil
self:resetNoise()
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
-- ..(audio/engine_1.asm ln 197)
self.drumTail = prev
elseif not (self.event and self.event.silence and self.drumTail) then
self.drumTail = nil
self:resetNoise()
end
end
local event = self.event
if not event then return 0 end
local gain = channelVolume[self.hardware] or 1
if not event then
local tail = self.drumTail
if not tail then return 0 end
local sampleIndex = tail.sample
tail.sample = sampleIndex + 1
if sampleIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, sampleIndex) * gain
end
local sampleIndex = event.sample
event.elapsed = sampleIndex / SAMPLE_RATE
event.sample = sampleIndex + 1
if event.silence then return 0 end
local gain = channelVolume[self.hardware] or 1
if event.silence then
local tail = self.drumTail
if not tail then return 0 end
local tailIndex = tail.sample
tail.sample = tailIndex + 1
if tailIndex >= drumAudioEnd(tail.drum) then
self.drumTail = nil
return 0
end
return self:sampleDrum(tail, tailIndex) * gain
end
if event.drum then
return self:sampleDrum(event, sampleIndex) * gain
end
self.drumTail = nil
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
@@ -665,7 +719,8 @@ function Channel:sample()
-- 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 * gain
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
return (nibble / 15) * event.waveLevel * gain
end
local duty = event.duty
if type(duty) == "table" then
@@ -674,7 +729,7 @@ function Channel:sample()
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
local step = math.floor(phase * 8) % 8
if pattern[step + 1] == 0 then
return -volume / 15 * gain
return 0
end
return volume / 15 * gain
end
@@ -685,7 +740,7 @@ 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
if custom then return extendDrumEnvelope(custom) end
local cached = self.noiseInstruments[number]
if cached then return cached end
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
end
end
extendDrumEnvelope(segments)
self.noiseInstruments[number] = segments
return segments
end
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
customDrums = chip and chip.drums or nil,
noiseInstruments = {},
channels = {},
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
lpf = 0, lpfLeft = 0, lpfRight = 0,
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
@@ -826,10 +884,20 @@ function Engine:finished()
return true
end
local function analogOut(engine, input, hpfField, lpfField)
local cap = engine[hpfField]
local hp = input - cap
engine[hpfField] = input - hp * HPF_CHARGE
local prev = engine[lpfField]
local lp = prev + LPF_ALPHA * (hp - prev)
engine[lpfField] = lp
return math.max(-1, math.min(1, lp * MIX_SCALE))
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 / 4))
return analogOut(self, value, "hpfCap", "lpf")
end
function Engine:sampleStereo()
@@ -840,8 +908,8 @@ function Engine:sampleStereo()
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 / 4)),
math.max(-1, math.min(1, right / 4))
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
analogOut(self, right, "hpfCapRight", "lpfRight")
end
function Engine:sampleChannel(number)
@@ -850,7 +918,7 @@ function Engine:sampleChannel(number)
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected / 4))
return analogOut(self, selected, "hpfCap", "lpf")
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
return result
end
-- Render a one-shot effect (SFX/cry) to a two-channel 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).
--
-- The synthesis is mono (one summed value per frame, unlike the music path's
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
-- position, exactly where the listener sits, is rendered as an ambient sound
-- spread over EVERY output channel the device exposes at gains that differ
-- from the front pair. On an interface with more than two outputs that put
-- the SFX on outputs 5+6 as well, while the 2-channel music source
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
-- spatialization entirely and map onto the front pair, so duplicating the
-- sample costs one buffer's memory and makes effects route exactly like
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
+37 -39
View File
@@ -1,12 +1,3 @@
-- Music playback supports compact ROM channel programs synthesized live by
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
-- branch is chosen per song definition, never by a global import flag, so a
-- file-backed song and a chip song coexist in one dataset. Songs with split
-- files chain def.file into def.loopFile in Music.update().
-- Map themes switch on map change; battles override with the battle
-- theme and restore afterwards; riding the bike overrides outdoor map
-- themes with the bike song until dismount.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
@@ -14,20 +5,10 @@ local Music = {}
local VOLUME = 0.7
-- port additions driven by OptionsMenu / save.options: musicVol scales
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
-- low-passes the song. Each filter step keeps 40% of the previous
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
-- applied twice/three times over.
local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
@@ -115,11 +96,6 @@ function Music.duckForFanfare(src)
end
end
-- Overworld themes where the bike can be ridden (outdoor maps plus the
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
-- for caches built before the importer wrote the table.
local OUTDOOR = {
Music_PalletTown = true,
Music_Cities1 = true,
@@ -218,6 +194,7 @@ end
-- the single choke point every song choice passes through, so one hook
-- covers map themes, battle themes, jingles and scene music
local function selectSong(song, ctx)
if ctx and ctx.selected then return song end
if not Runtime.wantsHook("music.select") then return song end
return Runtime.call("music.select", function(chosen) return chosen end, song, {
reason = ctx and ctx.reason or "direct",
@@ -234,17 +211,28 @@ end
function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
ctx = ctx or {}
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if ctx.fade and state.source then
local queued = {}
for key, value in pairs(ctx) do queued[key] = value end
queued.fade, queued.selected = nil, true
local pending = { data = data, song = song, loop = loop, ctx = queued }
if state.fade then
state.fade.pending = pending
else
Music.fadeOut(ctx.fade, pending)
end
return
end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
@@ -317,24 +305,26 @@ function Music.reload()
Music.stop()
end
-- Ramp the current song's volume to silence, then stop it, mirroring the
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
-- integer levels, one level every `control` frames, and the music stops
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
-- to silence). Ticked once per frame from Music.update().
function Music.fadeOut(control)
if not state.source then Music.stop() return end
function Music.fadeOut(control, pending)
if not state.source then
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
control = math.max(1, control or 10)
state.fade = {
control = control,
counter = control, -- frames until the next volume step
level = 7, -- current master-volume level (rAUDVOL nibble)
from = VOLUME * volumeScale, -- level-7 (full) source volume
pending = pending,
}
end
Music.MAP_FADE = 10
-- the song a map should currently play, honoring the bike/surf overrides
local function effectiveMapSong(data, song)
if not song or not outdoorSongs(data)[song] then return song end
@@ -351,14 +341,17 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing)
function Music.playMap(data, mapId, onBike, surfing, fade)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
local play = effectiveMapSong(data, song)
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
if play then
Music.play(data, play, nil,
{ reason = "map", mapId = mapId, fade = fade })
end
end
-- toggle the surf override mid-map (starting/ending a surf)
@@ -475,8 +468,13 @@ function Music.update(data)
f.counter = f.control
f.level = f.level - 1
if f.level <= 0 then
-- ..(home/fade_audio.asm ln 36)
state.fade = nil
local pending = f.pending
Music.stop()
if pending then
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
end
return
end
local vol = f.from * f.level / 7
+18
View File
@@ -0,0 +1,18 @@
-- Generic process-lifecycle mod hooks so a platform-specific launcher
-- integration (a native shell embedding this engine, e.g. wrapping the
-- window in a platform UI) can live entirely in a mod instead of
-- hand-patching main.lua, which every other engine change also touches.
-- See docs/modding.md's "Process-lifecycle hooks" section.
local ModRuntime = require("src.mods.Runtime")
local PlatformHooks = {}
function PlatformHooks.update(game, dt)
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
end
function PlatformHooks.quitToLauncher(vanilla)
return ModRuntime.call("core.quit_to_launcher", vanilla)
end
return PlatformHooks
+46 -1
View File
@@ -25,6 +25,18 @@ local function noEffect(data)
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
end
local function registeredEffect(data, itemDef)
if not data or not itemDef or not itemDef.effect then
return nil
end
if not data.item_effects then
return nil
end
return data.item_effects[itemDef.effect]
end
local HEAL_AMOUNT = {
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
@@ -72,7 +84,18 @@ function ItemEffects.healsHP(id)
end
-- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef)
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
function ItemEffects.needsTarget(id, itemDef, data)
if itemDef and itemDef.needsTarget ~= nil then
return itemDef.needsTarget
end
local effect = registeredEffect(data, itemDef)
if effect and effect.needsTarget ~= nil then
return effect.needsTarget
end
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
or id == "RARE_CANDY" or STONES[id]
@@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
local effectDef = registeredEffect(data, itemDef)
if effectDef then
if battle and effectDef.battle == false then
return "failed", { notTime(data, save) }
end
if not battle and effectDef.field == false then
return "failed", { notTime(data, save) }
end
return effectDef.use({
data = data,
save = save,
itemId = itemId,
item = itemDef,
target = target,
battle = battle,
moveIndex = moveIndex,
overworld = ow,
})
end
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
-- (jp nz, ItemUseNotTime)
+10
View File
@@ -951,6 +951,16 @@ function Loader:load(data)
end
end
end
-- A manifest may name an env var that force-enables it regardless of a
-- saved disable in options.mods -- generic, not tied to any mod id, for
-- a mod (e.g. a native-launcher bridge) that cannot function disabled on
-- the one build where its env var is set.
for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env
if envName and os.getenv(envName) == "1" then
self.disabled[id] = nil
end
end
for id, mod in pairs(self.mods) do
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
+1
View File
@@ -229,6 +229,7 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
path = path,
raw = raw,
}
+7
View File
@@ -592,6 +592,13 @@ R.sprites = {
image = f.path,
frames = f.int(1),
walker = f.opt(f.bool),
-- Optional sheet geometry for mod actors. Defaults match the vanilla
-- 16x16 grounded walker; anchors are measured from each frame's
-- top-left in pixels (default: bottom-center).
frameWidth = f.opt(f.int(1)),
frameHeight = f.opt(f.int(1)),
anchorX = f.opt(f.num),
anchorY = f.opt(f.num),
trueColor = f.opt(f.bool),
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
-- palette assignment without claiming that the image itself came from
+4 -2
View File
@@ -137,8 +137,10 @@ function Font.load(data)
-- typo'd path degrades exactly like a missing page image does above.
if type(def.ttf) == "table" then
local file = def.ttf.file or Font.PLAINPIXEL
local ok, obj = pcall(love.graphics.newFont, file,
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
local size = def.ttf.size or Font.PLAINPIXEL_SIZE
-- The game renders into a pixel-exact canvas, so keep the TTF rasterizer
-- on that same 1x grid instead of inheriting the window DPI on mobile.
local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1)
if ok and obj then
-- nearest keeps the pixel font crisp under the integer UI scale
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
+137 -34
View File
@@ -1,7 +1,8 @@
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
-- 6 16x16 frames: stand down/up/left, walk down/up/left
-- (data/sprites/facings.asm). Mod records may opt into another frame size
-- and anchor; the defaults below preserve the original grounded placement.
-- Right-facing frames are horizontal flips of the left frames.
-- Sprites draw 4px above their cell, like the GB engine.
local Assets = require("src.render.Assets")
local PaletteFX = require("src.render.PaletteFX")
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- Sprite records are anchored at the point where the actor stands in the
-- world. In the vanilla renderer that point is the bottom-center of a
-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is
-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left
-- in sheet pixels and may be fractional for a sub-pixel art style.
local DEFAULT_FRAME_WIDTH = 16
local DEFAULT_FRAME_HEIGHT = 16
local DEFAULT_ANCHOR_X = 8
local DEFAULT_ANCHOR_Y = 16
local WORLD_ANCHOR_X = 8
local WORLD_ANCHOR_Y = 12
SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH
SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT
SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X
SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y
local function finiteNumber(value)
if type(value) ~= "number" or value ~= value
or value == math.huge or value == -math.huge then
return nil
end
return value
end
local function positiveInteger(value, fallback)
value = finiteNumber(value)
if value and value >= 1 then return math.floor(value) end
return fallback
end
local function numberOr(value, fallback)
return finiteNumber(value) or fallback
end
local function pose(self, facing, walkPhase, stepFlip)
if self.frameCount <= 1 then return 0, false end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
frame = frame or 0
-- Preserve the old fallback for a short custom sheet whose pose table
-- names a frame it does not provide.
if not self.frames[frame] then frame = 0 end
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up")
and walkPhase == 1 and stepFlip then
flip = true
end
return frame, flip
end
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
function SpriteRenderer.new(spriteDef, seed)
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
self.def = spriteDef
self.seed = seed
self.image = getImage(spriteDef.image)
self.frameCount = positiveInteger(spriteDef.frames, 1)
self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH)
self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT)
self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2)
self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight)
local iw, ih = self.image:getDimensions()
self.frames = {}
for f = 0, spriteDef.frames - 1 do
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
for f = 0, self.frameCount - 1 do
self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
self.frameWidth, self.frameHeight,
iw, ih)
end
return self
end
-- Return the sheet rectangle and top-left-relative anchor for a frame. The
-- result is a fresh table so a custom render pipeline may annotate it without
-- changing the renderer's shared definition.
function SpriteRenderer:getFrameGeometry(frame)
frame = math.floor(finiteNumber(frame) or 0)
if frame < 0 then frame = 0 end
if frame >= self.frameCount then frame = self.frameCount - 1 end
return {
frame = frame,
x = 0,
y = frame * self.frameHeight,
width = self.frameWidth,
height = self.frameHeight,
anchorX = self.anchorX,
anchorY = self.anchorY,
quad = self.frames[frame],
}
end
-- Return the frame geometry selected by the ordinary 2D pose rules, plus the
-- horizontal mirror state that :draw applies. This is the supported hook for
-- custom render pipelines that need to draw actors with the same pose/flip.
function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip)
local frame, flip = pose(self, facing, walkPhase, stepFlip)
local geometry = self:getFrameGeometry(frame)
geometry.facing = facing
geometry.walkPhase = walkPhase
geometry.stepFlip = stepFlip
geometry.mirror = flip
return geometry
end
-- Screen-space top-left for the actor's current world anchor. World-facing
-- effects such as fishing can use this instead of assuming a 16x16 frame.
function SpriteRenderer:getScreenOrigin(px, py, camX, camY)
local baseX = math.floor(px - camX) + WORLD_ANCHOR_X
local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y
return math.floor(baseX - self.anchorX),
math.floor(baseY - self.anchorY)
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
@@ -125,27 +226,32 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
local function blitFrame(image, quad, x, y, flip, redraw, frameWidth)
frameWidth = frameWidth or DEFAULT_FRAME_WIDTH
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
if redraw then
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
end
else
love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end
end
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
-- bottom tile row of the standing frames with the fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384)
-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
-- overwrites that row of the standing frames with fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
-- frames therefore still draw 8 rows, while taller frames keep their larger
-- body and reserve only the overlay row.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass
-- True-color sheets bypass every palette bake; the screen-space exemption
-- is recorded below once the final frame/height is known.
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16)
image = self.image
elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded
@@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
-- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj())
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- Single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
blitFrame(image, self.frames[0], x, y, false, redraw)
return
end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if topHalf then
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
local quad = self.frames[frame]
local drawHeight = self.frameHeight
if topHalf and self.frameCount > 1 then
self.halfFrames = self.halfFrames or {}
if not self.halfFrames[frame] then
local iw, ih = self.image:getDimensions()
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
self.halfFrames[frame] = love.graphics.newQuad(
0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih)
end
quad = self.halfFrames[frame]
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
end
blitFrame(image, quad, x, y, flip, redraw)
-- Full-color art claims exactly the portion of the frame that was drawn.
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight)
end
blitFrame(image, quad, x, y, flip, redraw, self.frameWidth)
end
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
@@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip)
self.tileQuads = self.tileQuads or {}
self.tileQuads[path] = self.tileQuads[path]
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw)
end
return SpriteRenderer
+13 -1
View File
@@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return
end
if result == "kept" then
if battle then
list:close()
showMessages(game, payload, function()
battle:itemUsed({})
end)
else
showMessages(game, payload, closePicker)
end
return
end
if result == "consumed" then
consume(game, id)
-- refresh counts in the list
@@ -404,7 +416,7 @@ local function useItem(game, battle, id, list)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
if def and def.machine then
+4
View File
@@ -67,6 +67,7 @@ local function withdraw(game)
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_withdraw",
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -114,6 +115,7 @@ local function deposit(game)
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_deposit",
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
@@ -160,6 +162,7 @@ local function release(game)
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
@@ -193,6 +196,7 @@ local function changeBox(game)
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_change",
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
+65 -59
View File
@@ -1,29 +1,5 @@
-- Boot splash + attract movie, a faithful port of PlayIntro
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
-- using the real extracted art (data/generated/field.lua `intro` manifest).
--
-- Three frame-counted phases:
-- 1. copyright card, 180 frames (intro.asm:311-312).
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
-- the big star streaks down-left for 40 frames while the studio logo
-- sits centered in the letterbox band (the GAME FREAK logo + letter
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
-- (intro.asm:329-331).
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
-- (GBFadeOutToWhite, home/fade.asm:26-40).
--
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
-- Pops itself and calls onDone() when finished or skipped. All art loads
-- through pcall and every missing graphic degrades to a text/rect
-- fallback, so the movie stays headless-safe.
-- ..(engine/movie/intro.asm ln 8)
-- ..(engine/movie/splash.asm ln 27)
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -76,15 +52,15 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
-- cancel)
-- ..(engine/movie/splash.asm ln 211)
local LOGO_X, LOGO_Y = 72, 56
local TEXT_X, TEXT_Y = 40, 80
-- ..(engine/movie/title.asm ln 390)
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 }
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 }
-- The studio logo (assets/logo/minilogo.png) stands in for both the
-- GAME FREAK logo and its letter row, so it gets the whole band between
-- the letterbox bars (y 32..112) down to where the star waves spawn
-- (y=88): fit it inside this box, centered, aspect preserved.
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
-- the 4 waves of small stars: screen X positions, all spawning at y=88
@@ -155,10 +131,19 @@ function IntroMovie.new(game, onDone)
self.studio = intro.studio or {}
self.skipAll = intro.skip and true or false
local function img(e) return tryImage(e and e.path) end
self.copyright = tryImage("assets/generated/title/copyright.png")
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
-- extracted logo stays as the fallback if the asset is missing
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
local titleCfg = game.data.field and game.data.field.title or {}
self.copyright = img(titleCfg.copyright)
or tryImage("assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyright then
local iw, ih = self.copyright:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = img(titleCfg.gamefreakInc)
or tryImage("assets/generated/title/gamefreak_inc.png")
self.studioLogo = tryImage(self.studio.logo)
if self.studioLogo then
self.studioLogo:setFilter("nearest", "nearest")
local iw, ih = self.studioLogo:getDimensions()
@@ -306,24 +291,12 @@ function IntroMovie:drawSplash()
if self.studioLogo then
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
0, self.studioScale, self.studioScale)
elseif self.logo then
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
else
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
end
love.graphics.setColor(1, 1, 1, 1)
end
if t >= STAR_START and t < FLASH_START then
-- big star: from OAM (160,0) moving +4Y/-4X per frame
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
if t >= WAVES_START then
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
-- +1px per 3-frame substep until the wave loop ends; the lower star in
@@ -351,6 +324,18 @@ function IntroMovie:drawSplash()
end
end
drawBars()
if t >= STAR_START and t < FLASH_START then
-- ..(engine/movie/splash.asm ln 32)
local n = t - STAR_START + 1
local sx, sy = 152 - 4 * n, -16 + 4 * n
if self.bigStar then
love.graphics.draw(self.bigStar, sx, sy)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
end
function IntroMovie:drawFight()
@@ -379,17 +364,38 @@ function IntroMovie:drawFight()
end
end
function IntroMovie:drawCopyright()
if self.studio.card or self.studio.credit then
local card = self.studio.card or ""
local credit = self.studio.credit or ""
love.graphics.setColor(0, 0, 0, 1)
Font.draw(card, (160 - #card * 8) / 2, 64)
Font.draw(credit, (160 - #credit * 8) / 2, 80)
elseif self.copyright and self.gfInc then
local function row(seq, x, y)
for _, t in ipairs(seq) do
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
x = x + 8
end
end
for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end
row(COPY_NINTENDO, 80, 56)
row(COPY_CREATURES, 80, 72)
love.graphics.draw(self.gfInc, 80, 88)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Nintendo"), 80, 56)
Font.draw(Strings("Creatures inc."), 80, 72)
Font.draw(Strings("GAME FREAK inc."), 16, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
function IntroMovie:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == 1 then
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1)
local credit = self.studio.credit or Strings("bois club")
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
self:drawCopyright()
elseif self.phase == 2 then
self:drawSplash()
else
+1
View File
@@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts)
local self = setmetatable({}, ListMenu)
self.game = game
self.title = title
self.kind = opts.kind or title
self.items = items
self.index = 1
self.scroll = 0
+3
View File
@@ -72,6 +72,7 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
kind = "pc_item_withdraw",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
@@ -110,6 +111,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
kind = "pc_item_deposit",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
@@ -131,6 +133,7 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
kind = "pc_item_toss",
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
+169 -46
View File
@@ -103,7 +103,31 @@ local YELLOW_CYCLE_SPECIES = {
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
-- ..(engine/movie/title.asm ln 227)
local HOLD_FRAMES = 200
local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true }
-- ..(engine/movie/title2.asm ln 13)
local function scrollFrames(steps, offset)
local frames = {}
for _, step in ipairs(steps) do
for _ = 1, step[2] do
frames[#frames + 1] = offset
offset = offset - step[1]
end
end
return frames
end
local OUT_FRAMES = scrollFrames(
{ { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 },
{ 8, 3 }, { 9, 3 } }, 0)
local IN_FRAMES = scrollFrames(
{ { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 },
{ 1, 1 } }, 120)
-- ..(engine/movie/title2.asm ln 85)
local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
local BALL_REST = 100
local function tryImage(path)
if not path then return nil end
@@ -178,6 +202,27 @@ function TitleState.new(game, opts)
or "assets/generated/title/red_version.png")
self.player = tryImage(imagePath(title.player)
or "assets/generated/title/player.png")
-- ..(engine/movie/title2.asm ln 85)
if self.player then
local pw, ph = self.player:getDimensions()
self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph)
self.playerQuads = {
{ love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 },
{ love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 },
{ love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 },
}
end
self.copyImg = tryImage(imagePath(title.copyright)
or "assets/generated/title/copyright.png")
self.copyQuads = {}
if self.copyImg then
local iw, ih = self.copyImg:getDimensions()
for t = 0, 18 do
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
end
end
self.gfInc = tryImage(imagePath(title.gamefreakInc)
or "assets/generated/title/gamefreak_inc.png")
self.blue = GameVersion.isBlue()
self.yellow = GameVersion.isYellow()
or title.layout == "yellow_pikachu"
@@ -203,7 +248,10 @@ function TitleState.new(game, opts)
self.blinkTimer = 0
self.blinkAt = nil
else
self.phase = "loop"
-- ..(engine/movie/title.asm ln 28)
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = true
end
local defaultCycle = self.yellowLayout and { "PIKACHU" }
@@ -216,14 +264,15 @@ function TitleState.new(game, opts)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
self.scrollPhase = "hold"
self.scrollFrame = 1
self.monOffset = 0
self.ballY = BALL_REST
return self
end
function TitleState:enter()
-- Yellow defers the title theme until after the logo drop and
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
-- WaitForSoundToFinish on PikachuCry1)
if self.yellowLayout then return end
if self.phase ~= "loop" then return end
self:startMusic()
end
@@ -240,6 +289,11 @@ end
local DROP_STEPS = {
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
}
local SETTLE_FRAMES = 36
-- ..(engine/movie/title.asm ln 201)
local RIBBON_FRAMES = {}
for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end
-- the boot cinematic up to the interactive loop; one call per frame
function TitleState:updateSequence()
@@ -263,12 +317,23 @@ function TitleState:updateSequence()
self.dropLeft = nil
end
elseif self.phase == "settle" then
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
self.timer = self.timer + 1
if self.timer >= 36 then
if self.timer >= SETTLE_FRAMES then
Sound.play(data, "Intro_Whoosh")
self.showBubble = true
self.phase = "bubble"
self.phase = self.yellowLayout and "bubble" or "ribbon"
self.ribbonOffset = RIBBON_FRAMES[1]
self.timer = 0
end
elseif self.phase == "ribbon" then
self.timer = self.timer + 1
local offset = RIBBON_FRAMES[self.timer + 1]
if offset then
self.ribbonOffset = offset
else
self.ribbonOffset = nil
self:startMusic()
self.phase = "loop"
self.timer = 0
end
elseif self.phase == "bubble" then
@@ -432,12 +497,63 @@ function TitleState:openMenu()
game.stack:push(menu)
end
-- ..(engine/movie/title.asm ln 271)
function TitleState:pickNewMon()
if #self.cycleSpecies < 2 then return end
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
function TitleState:setCyclePhase(phase)
self.scrollPhase = phase
self.scrollFrame = 1
self.timer = 0
if phase == "in" then
self:pickNewMon()
self.monOffset = IN_FRAMES[1]
elseif phase == "out" then
self.monOffset = OUT_FRAMES[1]
elseif phase == "ball" then
self.ballY = BALL_FRAMES[1]
else
self.monOffset = 0
end
end
function TitleState:updateCycle()
local phase = self.scrollPhase
if phase == "hold" then
if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end
return
end
local frames = phase == "out" and OUT_FRAMES
or phase == "ball" and BALL_FRAMES or IN_FRAMES
self.scrollFrame = self.scrollFrame + 1
local value = frames[self.scrollFrame]
if value then
if phase == "ball" then self.ballY = value else self.monOffset = value end
return
end
if phase == "out" then
-- ..(engine/movie/title.asm ln 235)
self:setCyclePhase(
STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in")
elseif phase == "ball" then
self:setCyclePhase("in")
else
self:setCyclePhase("hold")
end
end
function TitleState:update(dt)
if self.phase ~= "loop" then
self:updateSequence()
return
end
if self.yellowLayout then
if self.phase ~= "loop" then
self:updateSequence()
return -- input is ignored until the cinematic lands (title.asm)
end
self:updateBlink()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
@@ -452,21 +568,7 @@ function TitleState:update(dt)
end
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
self.timer = 0
-- random pick that never repeats the current one
if #self.cycleSpecies > 1 then
local pick = self.cycleIndex
while pick == self.cycleIndex do
pick = love.math.random(1, #self.cycleSpecies)
end
self.cycleIndex = pick
end
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
end
if self.slideIn and self.slideIn > 0 then
self.slideIn = self.slideIn - 1
end
self:updateCycle()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting);
@@ -478,15 +580,14 @@ function TitleState:update(dt)
end
end
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
-- ..(engine/movie/title.asm ln 28)
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
local scrollY = -(self.scy or 0)
-- ..(engine/movie/title.asm ln 28)
local preRibbon = not self.yellowLayout
and (self.phase == "drop" or self.phase == "settle")
if self.logo then
love.graphics.draw(self.logo, 16, 8 + scrollY)
else
@@ -514,27 +615,29 @@ function TitleState:draw()
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
-- the Yellow fallback layout draws no ribbon at all.
if self.version and not self.yellow then
if self.version and not self.yellow and not preRibbon then
local iw, ih = self.version:getDimensions()
local rx = self.ribbonOffset or 0
if self.versionFull then
-- a continuous ribbon (versionRibbon) centers as one piece
love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64)
love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64)
elseif self.blue then
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64)
else
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64)
end
end
local sprite, spriteTrueColor = self:currentSprite()
local sprite, spriteTrueColor
if self.scrollPhase ~= "ball" then
sprite, spriteTrueColor = self:currentSprite()
end
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
local x = 40 + math.floor((56 - w) / 2) + slide
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
@@ -550,13 +653,33 @@ function TitleState:draw()
end
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
elseif self.player then
love.graphics.draw(self.player, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
end
-- ..(engine/movie/title.asm ln 117)
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
function TitleState:drawCopyright(y)
if not self.title.copyrightText and self.copyImg and self.gfInc then
local x = 16
for _, t in ipairs(COPY_PREFIX) do
love.graphics.draw(self.copyImg, self.copyQuads[t], x, y)
x = x + 8
end
love.graphics.draw(self.gfInc, x, y)
return
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
1, 136 + scrollY)
Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y)
love.graphics.setColor(1, 1, 1, 1)
end
+37 -9
View File
@@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps
-- after a wild battle before another random battle can start.
local WILD_ENCOUNTER_GRACE_STEPS = 3
-- Fly animation coord paths (engine/overworld/player_animations.asm):
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
-- Delay3). The port anchors a path on the player's own position instead
@@ -81,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- above (screen = tile*8 + pixel - 8/16), measured against the player
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
-- is the delta from the sprite's top-left, which the vanilla
-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that
-- origin while keeping these offsets frame-relative. `tile` indexes the
-- three stacked 8x8 tiles of
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
@@ -224,6 +230,8 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false
self.emote = nil
-- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose
-- exit mat is a LAST_MAP warp
self.lastOutdoor = Game.save.lastOutdoor
@@ -451,8 +459,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
self.keepMusicOnce = nil
if not keepMusic then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
-- ..(home/overworld.asm ln 2346)
local Music = require("src.core.Music")
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
end
-- forced bike/surf tiles fire the moment the player is placed on the
@@ -1093,8 +1103,10 @@ function OverworldState:update(dt)
local mapId = self.pendingSeamMusic
self.pendingSeamMusic = nil
if mapId == self.map.id then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
-- ..(home/overworld.asm ln 677)
local Music = require("src.core.Music")
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
end
end
if stepped and not scripted then
@@ -3472,6 +3484,10 @@ end
function OverworldState:onStepComplete()
local p = self.player
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
if suppressWildEncounter then
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
end
self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save)
@@ -3584,6 +3600,9 @@ function OverworldState:onStepComplete()
-- wild encounters in grass, on water while surfing, or -- on indoor
-- maps whose tileset is not FOREST -- on EVERY tile
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps.
if suppressWildEncounter then return end
local encDef = Game.data.encounters[self.map.id]
local enc
local indoor = Game.data.field.indoorEncounters
@@ -3970,6 +3989,9 @@ end
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle)
if battle and battle.kind == "wild" then
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
end
local lead = Game.save.party[1]
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
lead and lead.species or "-", lead and lead.hp or 0,
@@ -4799,9 +4821,15 @@ function OverworldState:drawWorld()
end
end
local quad = self.rodQuads[oam.tile]
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
local rx = p.px - cam.x + oam.dx
local ry = p.py - cam.y - 4 + oam.dy
-- Place the rod against the active sprite's anchored top-left. The
-- vanilla result is still (px-cam, py-cam-4), while custom larger
-- sheets keep the rod attached to their feet.
-- Fishing always uses the on-foot player sheet; read its fields
-- directly so this FX pass does not advance pose-side animation.
local sprite, px, py = p.sprite, p.px, p.py
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
local rx = sx + oam.dx
local ry = sy + oam.dy
love.graphics.setColor(1, 1, 1, 1)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
+7 -2
View File
@@ -335,8 +335,13 @@ function Player:draw(camX, camY)
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
if fishTile then
sprite:draw(px, py, camX, camY, facing, 0, false, true)
sprite:drawTile(fishTile, math.floor(px - camX),
math.floor(py - camY) - 4 + 8, facing == "right")
-- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
-- actual anchored frame origin so larger/custom sheets keep the pose at
-- their feet instead of falling back to the vanilla 16x16 top-left.
local sx, sy = sprite:getScreenOrigin(px, py, camX, camY)
sprite:drawTile(fishTile, sx,
sy + math.max(0, sprite.frameHeight - 8),
facing == "right")
return
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
+97 -15
View File
@@ -8,6 +8,7 @@
local Logger = require("src.core.Logger")
local Assets = require("src.render.Assets")
local MapLoader = require("src.world.MapLoader")
local Party = require("src.pokemon.Party")
local Runtime = require("src.mods.Runtime")
local WorldAPI = {}
@@ -18,6 +19,11 @@ local overviewShades = {}
Assets.register(function() overviewShades = {} end)
local function shadeDigit(sum, pixelCount)
return tostring(math.max(0, math.min(3,
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
end
local function mapTileRows(map)
local tileset = map.tileset
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
@@ -28,30 +34,39 @@ local function mapTileRows(map)
cached = { pixels = pixels, shades = {} }
overviewShades[tileset.image] = cached
end
local rows, perRow = {}, tileset.tilesPerRow
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
for ty = 0, map.heightCells * 2 - 1 do
local row = {}
local row, detailTop, detailBottom = {}, {}, {}
for tx = 0, map.widthCells * 2 - 1 do
local tile = map:tileAt(tx, ty)
local shade = cached.shades[tile]
if shade == nil then
local sum = 0
local shades = cached.shades[tile]
if shades == nil then
local sums = { 0, 0, 0, 0 }
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
sums[quadrant] = sums[quadrant]
+ r * 0.2126 + g * 0.7152 + b * 0.0722
end
end
shade = tostring(math.max(0, math.min(3,
math.floor((1 - sum / 64) * 3 + 0.5))))
cached.shades[tile] = shade
shades = {
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
}
cached.shades[tile] = shades
end
row[#row + 1] = shade
row[#row + 1] = shades[1]
detailTop[#detailTop + 1] = shades[2] .. shades[3]
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
end
rows[#rows + 1] = table.concat(row)
detailRows[#detailRows + 1] = table.concat(detailTop)
detailRows[#detailRows + 1] = table.concat(detailBottom)
end
return rows
return rows, detailRows
end
function WorldAPI.new(game, modId)
@@ -86,10 +101,12 @@ end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify
-- exits and item spots that are still active without exposing world internals.
function WorldAPI:mapOverview()
local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end
local map, rows = ow.map, {}
local map, rows, markers = ow.map, {}, {}
for y = 0, map.heightCells - 1 do
local row = {}
for x = 0, map.widthCells - 1 do
@@ -99,11 +116,33 @@ function WorldAPI:mapOverview()
end
rows[#rows + 1] = table.concat(row)
end
local tileRows = mapTileRows(map)
local def = map.def or {}
for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
end
local game, save = self.game, self.game.save or {}
for _, obj in ipairs(def.objects or {}) do
if obj.item and obj.item ~= "0" and obj.item ~= 0
and ow.objectVisible(save, map.id, obj) then
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
end
end
local hidden = game.data and game.data.field and game.data.field.hiddenItems
for _, item in ipairs(hidden and hidden[map.id] or {}) do
local key = map.id .. "_" .. item.x .. "_" .. item.y
if not (save.hiddenTaken and save.hiddenTaken[key]) then
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end
end
local tileRows, tileDetailRows = mapTileRows(map)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, tileRows = tileRows,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tileRows,
tileWidth = tileRows and map.widthCells * 2,
tileHeight = tileRows and map.heightCells * 2 }
tileHeight = tileRows and map.heightCells * 2,
tileDetailRows = tileDetailRows,
tileDetailWidth = tileDetailRows and map.widthCells * 4,
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
end
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
@@ -227,6 +266,49 @@ function WorldAPI:queueScript(rows, extra)
return true
end
-- The supported way to start a wild encounter. Hand-rolling this -- build a
-- BattleState, push it -- silently costs evolutions and blackout-on-loss
-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle
-- theme (both owned by pushBattle). Nothing raises when they are missing.
function WorldAPI:startWildBattle(species, level)
local ow = self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self.game.data.pokemon[species] then
return nil, "unknown species: " .. tostring(species)
end
-- Pokemon.new writes the level through verbatim -- into level, the stat
-- calc and the exp curve -- so a fraction has to be refused here rather
-- than round somewhere downstream. The % test also catches NaN, which
-- passes both range comparisons.
level = tonumber(level)
if not level or level % 1 ~= 0 or level < 1 or level > 100 then
return nil, "level must be a whole number 1..100"
end
-- overworld() resolves the world from UNDER whatever sits on top of it,
-- so from a battle hook this would otherwise stack a second battle over
-- the live one -- and on a loss its afterBattle blacks out and warps
-- with the outer battle still on the stack.
local BattleTransition = require("src.render.BattleTransition")
for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do
if state.awardExp or getmetatable(state) == BattleTransition then
return nil, "a battle is already running"
end
end
if ow.transitioning then return nil, "the world is mid-warp" end
-- BattleState.newWild marks the species SEEN before it reports an empty
-- party, so the party check comes first: a refused call must not leave a
-- Pokedex entry behind.
local save = self.game.save
if not (save and Party.firstHealthy(save.party or {})) then
return nil, "no healthy party"
end
local battle = require("src.battle.BattleState")
.newWild(self.game, species, level)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
ow:pushBattle(battle)
return true
end
-- drop a map's cached instance so the next load re-reads its record; when
-- it is the active map the world reloads around the player in place
function WorldAPI:invalidateMap(mapId)
+4 -2
View File
@@ -195,7 +195,7 @@ return function(game)
ow:queueScript(slice, { npc = rival })
local startY = ow.player.cellY
local minY, walkShot = startY, false
local minY, walkShot, sharedCell = startY, false, false
local hof
for i = 1, 5000 do
local top = game.stack:top()
@@ -205,8 +205,9 @@ return function(game)
end
local w = game.overworld
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
local y = w.player.cellY
local x, y = w.player.cellX, w.player.cellY
if y < minY then minY = y end
if x == rival.cellX and y == rival.cellY then sharedCell = true end
if y <= 2 and not walkShot then
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
end
@@ -215,6 +216,7 @@ return function(game)
end
check("the player walked out of the room before the warp (#704)",
minY < startY)
check("the player routed around the rival", not sharedCell)
check("walk-out screenshot", walkShot)
check("the induction started", hof ~= nil)
if not hof then
+66
View File
@@ -0,0 +1,66 @@
-- ..(engine/movie/title.asm ln 28)
-- ..(engine/movie/title2.asm ln 13)
-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local shot = 0
local function grab(tag)
shot = shot + 1
U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag))
end
U.wait(30)
grab("copyright")
-- ..(engine/movie/splash.asm ln 230)
local movie = game.stack:top()
while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end
grab("star_topbar")
while movie.timer < 88 do U.wait(1) end
grab("star_middle")
while movie.timer < 100 do U.wait(1) end
grab("star_lowbar")
while movie.timer < 130 do U.wait(1) end
grab("gamefreak")
U.tap(game, "start")
U.wait(2)
local title = game.stack:top()
U.log("top is", tostring(title and title.screenId))
if not (title and title.scrollPhase) then
U.log("no TitleState on top; nothing below can run")
while true do coroutine.yield() end
end
grab("drop_early")
U.wait(14)
grab("drop_late")
while title.phase == "drop" do U.wait(1) end
grab("settle")
while title.phase == "settle" do U.wait(1) end
grab("ribbon_start")
U.wait(10)
U.shot(game, DIR .. "/title_ribbon_mid.png")
while title.phase ~= "loop" do U.wait(1) end
grab("landed")
title.cycleIndex = 1
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
title.monOffset = 0
while title.scrollPhase == "hold" do U.wait(1) end
grab("out_a")
U.wait(6)
grab("out_b")
while title.scrollPhase == "out" do U.wait(1) end
U.log("after the scroll out the phase is", title.scrollPhase)
for _ = 1, 5 do
grab("ball")
U.wait(1)
end
while title.scrollPhase == "ball" do U.wait(1) end
grab("in")
U.wait(30)
grab("next_mon")
U.log("captured", DIR)
while true do coroutine.yield() end
end
+6 -2
View File
@@ -65,8 +65,12 @@ function U.newGame(game)
U.wait(5)
U.tap(game, "start") -- skip intro movie
U.wait(10)
U.tap(game, "a") -- title -> menu
U.wait(5)
local title = game.stack:top()
for _ = 1, 60 do
U.tap(game, "a")
U.wait(5)
if game.stack:top() ~= title then break end
end
-- menu: CONTINUE may or may not exist; NEW GAME is first without a save
U.tap(game, "a")
U.wait(10)
+103
View File
@@ -0,0 +1,103 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local data = { audio = {} }
local function pulseSong()
return ChipAsm.song{
channels = { { hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 15, fade = 0 } },
{ octave = 4 },
{ note = "C", len = 15 },
} } },
}
end
local function noiseSong()
return ChipAsm.sfx{
channels = { { hw = 4, program = {
{ noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } },
} } },
}
end
do
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
local sawNeg, sawPos = false, false
for _ = 1, 512 do
local v = engine.channels[1]:sample()
if v < -1e-12 then sawNeg = true end
if v > 1e-12 then sawPos = true end
end
check(sawPos and not sawNeg,
"pulse DAC is unipolar (high = volume, low = 0)")
end
do
local engine = ChipSynth.newEngine(data, noiseSong(), {
sfx = true, allowLoops = false,
})
local sawNeg, sawPos = false, false
for _ = 1, 2048 do
local v = engine.channels[1]:sample()
if v < -1e-12 then sawNeg = true end
if v > 1e-12 then sawPos = true end
end
check(sawPos and not sawNeg,
"noise DAC is unipolar (LFSR high = volume, low = 0)")
end
local function crossingsAndSign(engine, frames)
local count, prev = 0, nil
local sawNeg, sawPos = false, false
for _ = 1, frames do
local sample = engine:sample()
if sample < -1e-12 then sawNeg = true end
if sample > 1e-12 then sawPos = true end
if prev and prev * sample < 0 then count = count + 1 end
prev = sample
end
return count, sawNeg, sawPos
end
do
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
local count, sawNeg, sawPos = crossingsAndSign(engine, 4000)
check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0")
check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count))
end
do
local engine = ChipSynth.newEngine(data, noiseSong(), {
sfx = true, allowLoops = false,
})
local count, sawNeg, sawPos = crossingsAndSign(engine, 8000)
check(sawNeg and sawPos, "HPF centers noise / drums around analog 0")
check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count))
end
do
local song = pulseSong()
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
local a = ChipSynth.newEngine(data, song, { allowLoops = false })
local base = a.channels[1]:sample()
ChipSynth.setChannelVolume(1, 0.25)
local b = ChipSynth.newEngine(data, song, { allowLoops = false })
local quarter = b.channels[1]:sample()
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9,
"channelVolume still quarters the unipolar DAC level")
end
eq(type(ChipSynth.newEngine), "function", "engine factory still exported")
T.finish("chip analog path")
+82
View File
@@ -0,0 +1,82 @@
-- ..(audio/engine_1.asm ln 197)
-- ..(audio/sfx/noise_instrument01_1.asm ln 1)
-- luajit tests/engine/drum_envelope_ring.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local snare = ChipAsm.song{
channels = {
{ hw = 4, program = {
{ notetype = { speed = 12 } },
{ drum = 1, len = 2 },
{ rest = 16 },
} },
},
drums = {
[1] = {
{ len = 1, volume = 12, fade = 1, parameter = 0x33 },
},
},
}
local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false })
local segs = engine:noiseInstrument(1)
local last = segs[#segs]
local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000
check(ringMs > 150 and ringMs < 220,
("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs))
local energyEarly, energyLate, energyEnd = 0, 0, 0
local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25)
for i = 1, total do
local s = engine:sample()
local e = s * s
local t = i / ChipSynth.SAMPLE_RATE
if t < 0.02 then
energyEarly = energyEarly + e
elseif t > 0.05 and t < 0.12 then
energyLate = energyLate + e
elseif t > 0.20 then
energyEnd = energyEnd + e
end
end
check(energyEarly > 0, "snare attack is audible")
check(energyLate > energyEarly * 0.05,
("snare body still sounds at 50-120ms (early=%.4f late=%.4f)")
:format(energyEarly, energyLate))
check(energyEnd < energyLate * 0.1,
"snare has decayed by 200ms")
local hats = ChipAsm.song{
channels = {
{ hw = 4, program = {
{ notetype = { speed = 12 } },
{ drum = 1, len = 2 },
{ drum = 1, len = 2 },
} },
},
drums = {
[1] = {
{ len = 1, volume = 8, fade = 1, parameter = 0x10 },
},
},
}
local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false })
local hits = 0
local prev = 0
for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do
local s = math.abs(hatEngine:sample())
if prev < 0.01 and s >= 0.01 then hits = hits + 1 end
prev = s
end
check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits))
T.finish("drum envelope ring")
+94
View File
@@ -0,0 +1,94 @@
-- ..(home/audio.asm ln 9)
-- ..(home/fade_audio.asm ln 36)
-- luajit tests/engine/map_music_fade.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
function Source:getDuration() return 1 end
local made = {} -- file -> the last source built for it
love.audio = {
newSource = function(file, mode)
made[file] = setmetatable({ file = file, mode = mode }, Source)
return made[file]
end,
}
local Music = require("src.core.Music")
local data = { audio = {
songs = {
Music_Pallet = { file = "pallet.wav" },
Music_Routes1 = { file = "routes1.wav" },
Music_Pewter = { file = "pewter.wav" },
},
mapSongs = {
PALLET_TOWN = "Music_Pallet",
ROUTE_1 = "Music_Routes1",
PEWTER_CITY = "Music_Pewter",
},
} }
local function frames(n)
for _ = 1, n do Music.update(data) end
end
local function playing()
for file, src in pairs(made) do
if src.playing then return file end
end
return "(silence)"
end
local FADE = 7 * Music.MAP_FADE -- 7 volume levels x 10 frames
Music.stop()
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
eq(playing(), "pallet.wav", "the first map after boot starts at once")
local fullVolume = made["pallet.wav"].volume
Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE)
eq(playing(), "pallet.wav", "the new theme waits while the old one fades")
frames(FADE - 1)
eq(playing(), "pallet.wav", "still fading one frame short of silence")
check(made["pallet.wav"].volume < fullVolume,
"the old theme has been ramped down by then")
frames(1)
eq(playing(), "routes1.wav", "the queued theme takes over after 7 * 10 frames")
eq(made["routes1.wav"].volume, fullVolume,
"the new theme starts at full volume, not where the ramp ended")
Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE)
eq(playing(), "routes1.wav", "the same theme keeps playing")
frames(FADE)
eq(playing(), "routes1.wav", "and no fade was armed for it")
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
frames(3 * Music.MAP_FADE)
Music.playMap(data, "PEWTER_CITY", false, false, Music.MAP_FADE)
eq(playing(), "routes1.wav", "the retargeted fade keeps ramping the old theme")
frames(4 * Music.MAP_FADE)
eq(playing(), "pewter.wav", "the ramp lands on the newest map's theme")
Music.playMap(data, "PALLET_TOWN", false, false)
eq(playing(), "pallet.wav", "a fadeless map cue swaps immediately")
T.finish("map_music_fade")
+2 -1
View File
@@ -37,7 +37,7 @@ end
local Y_TITLE = {
"pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png",
"player.png", "copyright.png", "yellow_version.png",
"player.png", "copyright.png", "gamefreak_inc.png", "yellow_version.png",
}
for _, name in ipairs(Y_TITLE) do
seed("yellow/assets/generated/title/" .. name)
@@ -266,6 +266,7 @@ GameVersion.set("blue")
seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes")
seed("blue/assets/generated/title/player.png")
seed("blue/assets/generated/title/copyright.png")
seed("blue/assets/generated/title/gamefreak_inc.png")
local B_INTRO = {
"gf_logo.png", "gf_text.png", "big_star.png",
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
+48
View File
@@ -0,0 +1,48 @@
-- Stable ListMenu identities for screen.render_visible and companion UIs.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local PlayerPC = require("src.ui.PlayerPC")
local Boxes = require("src.pokemon.Boxes")
local pushed
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, state) pushed = state end },
}
local species = T.fixtures.ids.species[1]
Boxes.ensure(game.save)[1][1] = { species = species, level = 5 }
game.save.party[1] = { species = species, level = 5 }
game.save.party[2] = { species = species, level = 6 }
game.save.pcItems = { FIX_POTION = 2 }
game.save.inventory.FIX_POTION = 2
local generic = ListMenu.new(game, "VISIBLE TITLE", {}, {})
T.eq(generic.kind, "VISIBLE TITLE", "generic lists fall back to their title")
local explicit = ListMenu.new(game, "Localized title", {}, { kind = "stable_id" })
T.eq(explicit.kind, "stable_id", "explicit list kind is preserved")
local box = BoxMenu.new(game)
for i, kind in ipairs({ "pc_box_withdraw", "pc_box_deposit",
"pc_box_release", "pc_box_change" }) do
pushed = nil
box.items[i].onSelect()
T.eq(pushed and pushed.kind, kind, kind .. " is stable")
end
local items = PlayerPC.new(game)
for i, kind in ipairs({ "pc_item_withdraw", "pc_item_deposit",
"pc_item_toss" }) do
pushed = nil
items.items[i].onSelect()
T.eq(pushed and pushed.kind, kind, kind .. " is stable")
end
T.finish("pc_list_kinds")
+109
View File
@@ -0,0 +1,109 @@
-- ..(engine/movie/title.asm ln 227)
-- ..(engine/movie/title2.asm ln 13)
-- luajit tests/engine/title_mon_cycle.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
love.math = love.math or {}
local nextPick = 1
love.math.random = function(lo, hi)
nextPick = nextPick % hi + 1
return math.max(lo, nextPick)
end
local TitleState = require("src.ui.TitleState")
local title = TitleState.new(
{ data = {}, input = { wasPressed = function() return false end } }, {})
title.sprites = setmetatable({}, { __index = function() return false end })
eq(title.phase, "drop", "Red/Blue boot into the logo drop, not the loop")
local ribbonSeen = {}
for _ = 1, 400 do
if title.phase == "loop" then break end
title:update(1 / 60)
if title.phase == "ribbon" then
ribbonSeen[#ribbonSeen + 1] = title.ribbonOffset
end
end
eq(title.phase, "loop", "the cinematic lands within 400 frames")
eq(ribbonSeen[1], 112,
"the ribbon is parked off the right edge on its first drawn frame")
eq(ribbonSeen[#ribbonSeen], 4, "and walks in 4px a frame to its rest")
title.cycleIndex = 1 -- CHARMANDER: a starter, so the ball juggle runs
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
title.monOffset = 0
local frames = {}
for _ = 1, 260 do
title:update(1 / 60)
frames[#frames + 1] = {
phase = title.scrollPhase, offset = title.monOffset,
ball = title.ballY, mon = title.cycleSpecies[title.cycleIndex],
}
end
local HOLD_FRAMES = 200
local function span(phase)
local first, count = nil, 0
for i, f in ipairs(frames) do
if f.phase == phase then
if not first then first = i end
if first + count == i then count = count + 1 end
end
end
return first, count
end
local holdAt, holdLen = span("hold")
local outAt, outLen = span("out")
local ballAt, ballLen = span("ball")
local inAt, inLen = span("in")
eq(holdAt, 1, "the cycle opens on the hold")
eq(holdLen, HOLD_FRAMES - 1, "ld c, 200 / CheckForUserInterruption")
eq(outAt, HOLD_FRAMES, "the scroll out begins as the 200th hold frame ends")
eq(outLen, 18, "TitleScroll_Out is 2+2+2+2+2+2+3+3 frames")
eq(ballLen, 10, "TitleScroll_WaitBall is two runs of 5")
eq(inLen, 17, "TitleScroll_In is 2+4+4+3+2+1+1 frames")
check(outAt < ballAt and ballAt < inAt, "out, then the ball, then in")
local OUT = { 0, -1, -2, -4, -6, -9, -12, -16, -20, -25, -30, -36, -42,
-50, -58, -66, -75, -84 }
for i, want in ipairs(OUT) do
eq(frames[outAt + i - 1].offset, want,
"TitleScroll_Out offset at frame " .. i)
end
local IN = { 120, 110, 100, 91, 82, 73, 64, 56, 48, 40, 32, 26, 20, 14, 9,
4, 1 }
for i, want in ipairs(IN) do
eq(frames[inAt + i - 1].offset, want, "TitleScroll_In offset at frame " .. i)
end
local BALL = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
for i, want in ipairs(BALL) do
eq(frames[ballAt + i - 1].ball, want, "TitleBallYTable entry " .. i)
end
local outgoing = frames[outAt].mon
eq(outgoing, "CHARMANDER", "the starter is the one that scrolls out")
for i = outAt, inAt - 1 do
eq(frames[i].mon, outgoing,
"the pick does not change before the scroll in, at frame " .. i)
end
local incoming = frames[inAt].mon
check(incoming ~= outgoing, "TitleScreenPickNewMon never repeats the pick")
for i = inAt, inAt + inLen - 1 do
check(frames[i].offset > 0,
"the incoming mon is only ever drawn right of rest, at frame " .. i)
end
eq(frames[inAt + inLen].offset, 0, "and settles at its resting column")
T.finish("title_mon_cycle")
+21
View File
@@ -40,6 +40,27 @@ T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace")
-- ------------------------------------------------- the ttf takes over
-- Font rasterizers must use the same 1x pixel grid as PixelCanvas, rather
-- than inheriting Android's window density. Keep this local fake so the
-- contract is testable without requiring a real LÖVE window.
do
local g, oldNewFont = love.graphics, love.graphics.newFont
local args
g.newFont = function(...)
args = { ... }
return oldNewFont(Font.PLAINPIXEL, Font.PLAINPIXEL_SIZE)
end
local loaded, err = pcall(Font.load, {
font = { charmap = CHARMAP, ttf = { file = "custom.ttf", size = 13 } },
})
g.newFont = oldNewFont
if not loaded then error(err, 0) end
T.eq(args[1], "custom.ttf", "rasterizer uses the configured file")
T.eq(args[2], 13, "rasterizer uses the configured size")
T.eq(args[3], "mono", "rasterizer keeps the pixel hinting mode")
T.eq(args[4], 1, "rasterizer stays on the pixel canvas scale")
end
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
T.check(Font.ttfActive(), "an empty ttf table loads the bundled font")
+43 -7
View File
@@ -5,8 +5,9 @@ local Assets = require("src.render.Assets")
local WorldAPI = require("src.world.WorldAPI")
Assets.imageData = function()
return { getPixel = function(_, x)
local shade = x < 8 and 1 or 0
return { getPixel = function(_, x, y)
local shade = x >= 8 and 0 or ({ 1, 0, 2 / 3, 1 / 3 })[
math.floor(y / 4) * 2 + math.floor(x / 4) + 1]
return shade, shade, shade, 1
end }
end
@@ -16,15 +17,34 @@ local overview, err = api:mapOverview()
T.eq(overview, nil, "map overview is unavailable outside the overworld")
T.eq(err, "no overworld", "map overview reports why it is unavailable")
local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 }
local map = {
id = "TEST_MAP", widthCells = 2, heightCells = 2,
def = {
warps = { { x = 1, y = 0 } },
objects = { { index = 1, x = 0, y = 1, item = "POTION" } },
},
}
function map:isWarpTileCell(x, y) return x == 1 and y == 0 end
function map:isWaterCell(x, y) return x == 0 and y == 1 end
function map:isWalkableCell(x, y) return x == 0 and y == 0 end
function map:tileAt(x) return x % 2 end
api = WorldAPI.new({ stack = { states = {
{ isOverworld = true, map = map },
} } }, "tester")
local save = {}
local world = {
isOverworld = true,
map = map,
objectVisible = function(s, mapId, obj)
return not (s.itemsTaken and s.itemsTaken[mapId .. "_obj_" .. obj.index])
end,
}
local game = {
save = save,
data = { field = { hiddenItems = {
TEST_MAP = { { x = 1, y = 1, item = "NUGGET" } },
} } },
stack = { states = { world } },
}
api = WorldAPI.new(game, "tester")
overview = api:mapOverview()
T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map")
T.eq(overview.width, 2, "map overview reports its width")
@@ -32,11 +52,27 @@ T.eq(overview.height, 2, "map overview reports its height")
T.eq(overview.rows[1], ".+", "walkable land and warps are distinct")
T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct")
T.eq(overview.tileRows, nil, "tile overview is optional")
T.eq(#overview.markers, 3, "active exits and untaken items are marked")
T.eq(overview.markers[1].kind, "warp", "warp marker is semantic")
T.eq(overview.markers[2].kind, "item", "visible item marker is semantic")
T.eq(overview.markers[3].kind, "hidden", "hidden item marker is semantic")
save.itemsTaken = { TEST_MAP_obj_1 = true }
save.hiddenTaken = { TEST_MAP_1_1 = true }
overview = api:mapOverview()
T.eq(#overview.markers, 1, "collected items disappear from the overview")
T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items")
map.tileset = { image = "test.png", tilesPerRow = 2 }
overview = api:mapOverview()
T.eq(overview.tileWidth, 4, "tile overview reports its width")
T.eq(overview.tileHeight, 4, "tile overview reports its height")
T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading")
T.eq(overview.tileRows[1], "2323", "tile overview preserves average shading")
T.eq(overview.tileDetailWidth, 8, "detail overview reports its width")
T.eq(overview.tileDetailHeight, 8, "detail overview reports its height")
T.eq(overview.tileDetailRows[1], "03330333",
"detail overview preserves top tile quadrants")
T.eq(overview.tileDetailRows[2], "12331233",
"detail overview preserves bottom tile quadrants")
T.finish("world map overview")
+47
View File
@@ -428,16 +428,42 @@ local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites)
spriteReg:register("SPRITE_TITLE_LOGO",
{ image = "mods/logo/logo.png", frames = 1,
trueColor = true }, "logo_mod")
spriteReg:register("SPRITE_LARGE_ACTOR",
{ image = "mods/actor/actor.png", frames = 6,
walker = true, frameWidth = 32, frameHeight = 24,
anchorX = 16, anchorY = 24, trueColor = true },
"actor_mod")
local logoDef = spriteReg:get("SPRITE_TITLE_LOGO")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO",
logoDef, "register"),
"a trueColor sprites record validates against the catalog schema")
check(logoDef.trueColor == true, "and keeps the flag through the merge")
local largeDef = spriteReg:get("SPRITE_LARGE_ACTOR")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_LARGE_ACTOR",
largeDef, "register"),
"a variable-size sprites record validates against the catalog schema")
Renderer:init()
local plainSprite = SpriteRenderer.new(
{ image = "assets/generated/sprites/red.png", frames = 1 })
local litSprite = SpriteRenderer.new(logoDef)
local largeSprite = SpriteRenderer.new(largeDef)
check(plainSprite.frameWidth == 16 and plainSprite.frameHeight == 16
and plainSprite.anchorX == 8 and plainSprite.anchorY == 16,
"legacy sprite definitions keep the vanilla frame geometry")
local frameGeometry = largeSprite:getFrameGeometry(5)
check(frameGeometry.frame == 5 and frameGeometry.x == 0
and frameGeometry.y == 120 and frameGeometry.width == 32
and frameGeometry.height == 24 and frameGeometry.anchorX == 16
and frameGeometry.anchorY == 24,
"frame geometry exposes a larger sheet rectangle and anchor")
local poseGeometry = largeSprite:getPoseGeometry("right", 1, true)
check(poseGeometry.frame == 5 and poseGeometry.mirror == true
and poseGeometry.quad == largeSprite.frames[5],
"pose geometry follows walker frame selection and right mirroring")
local originX, originY = largeSprite:getScreenOrigin(32, 32, 0, 0)
check(originX == 24 and originY == 20,
"a custom anchor keeps a larger sprite grounded at its cell")
Renderer:beginFrame(true)
check(#PaletteFX.trueColorRects("ui") == 0
@@ -471,6 +497,27 @@ check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits")
check(worldDrawn[1].shader and worldDrawn[2].shader == false,
"the colorized pass runs first, then the sprite's rect with no shader")
-- Larger true-color frames claim their actual extent, and fishing's top-half
-- path reserves only the bottom 8-pixel tile for the overlay.
Renderer:beginFrame(true)
Renderer:beginWorldPass()
largeSprite:draw(32, 32, 0, 0, "down", 0, false)
local largeRects = PaletteFX.trueColorRects("world")
check(#largeRects == 1 and largeRects[1].x == 24 and largeRects[1].y == 20
and largeRects[1].w == 32 and largeRects[1].h == 24,
"a larger trueColor sprite reports its full anchored extent")
Renderer:endWorldPass()
Renderer:beginFrame(true)
Renderer:beginWorldPass()
largeSprite:draw(32, 32, 0, 0, "down", 0, false, true)
local topRects = PaletteFX.trueColorRects("world")
check(#topRects == 1 and topRects[1].h == 16
and largeSprite.halfFrames[0].y == 0
and largeSprite.halfFrames[0].h == 16,
"the fishing overlay keeps a larger frame's bottom tile clear")
Renderer:endWorldPass()
-- the same path on the UI canvas, which is where a full-color title logo
-- or menu portrait lands
Renderer:beginFrame(false)
+32
View File
@@ -271,6 +271,38 @@ do
"and nothing is disabled off a failed migration")
end
-- ------- force_enable_env: an env var can override a saved disable
-- (src/mods/Loader.lua's enable-resolution block, added for a mod that
-- cannot function disabled on the one build where its env var is set --
-- e.g. a platform-launcher bridge mod).
do
local forceFiles = {
["options.lua"] = "return { mods = { forced = false } }",
["mods/forced/manifest.json"] =
[[{"id":"forced","name":"forced","version":"1.0.0","entry":"main.lua",]]
.. [["force_enable_env":"SOME_TEST_ENV"}]],
["mods/forced/main.lua"] = "return function(mod) end",
}
local realGetenv = os.getenv
os.getenv = function(name)
if name == "SOME_TEST_ENV" then return "1" end
return realGetenv(name)
end
local onLoader = Loader.new({ fs = memfs(forceFiles) })
check(onLoader:load({ pokemon = {} }) == true,
"force_enable_env: load succeeds with the env var set")
check(onLoader.mods.forced.enabled == true,
"a matching force_enable_env re-enables a mod saved as disabled")
os.getenv = realGetenv
local offLoader = Loader.new({ fs = memfs(forceFiles) })
check(offLoader:load({ pokemon = {} }) == true,
"force_enable_env: load succeeds with the env var unset")
check(offLoader.mods.forced.enabled == false,
"with the env var unset, the saved disable is left alone")
end
-- leave shared singletons the way we found them for later chained tests
local StateStack = require("src.core.StateStack")
while StateStack:top() do StateStack:pop() end
+2
View File
@@ -105,6 +105,7 @@ local full = Manifest.validate({
api = 2, profile = "overhaul", permissions = { "network" },
dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" },
options_schema = "options.lua", assets_transforms = "transforms.lua",
force_enable_env = "SOME_ENV",
}, "mods/full")
check(full.api == 2 and full.profile == "overhaul", "api and profile parse")
check(full.affects_link == true, "overhaul defaults to affecting link play")
@@ -115,6 +116,7 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range
"a bare conflict entry has no range")
check(full.options_schema == "options.lua"
and full.assets_transforms == "transforms.lua", "declared files are kept")
check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept")
-- ------- github / experimental / incompatible
local gh = Manifest.validate({
+93
View File
@@ -823,6 +823,29 @@ do
and caught.enemy.mon.species == "TANGELA",
"and it is the species the authored encounter table names")
-- Trainer battles do not arm the wild-encounter cooldown.
walker.wildEncounterGraceSteps = 0
walker:afterBattle("win", { kind = "trainer" })
check(walker.wildEncounterGraceSteps == 0,
"trainer battles do not start the wild encounter grace period")
-- pokered grants three completed steps after a wild battle before the
-- next random battle can start (end_of_battle.asm + home/overworld.asm).
local finishedWild = caught
walker:afterBattle("run", finishedWild)
caught = nil
withBuses(function(_, hooks)
hooks:wrap("encounter.roll", function()
return { species = "TANGELA", level = 5 }
end, 0, "grace-period")
for step = 1, 3 do
pcall(walker.onStepComplete, walker)
check(caught == nil, "wild encounter grace period blocks step " .. step)
end
pcall(walker.onStepComplete, walker)
check(caught ~= nil, "wild encounter is eligible on step 4")
end)
-- the same walk with an encounter.roll wrapper never starts a battle
withBuses(function(_, hooks)
hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke")
@@ -893,6 +916,76 @@ do
check(value == nil and err == "no overworld", "npc() off the world")
value, err = api:queueScript({})
check(value == nil and err == "no overworld", "queueScript() off the world")
value, err = api:startWildBattle("PIDGEY", 5)
check(value == nil and err == "no overworld", "startWildBattle() off the world")
end
-- startWildBattle: what regresses is the handoff, not the battle. A mod that
-- builds a BattleState and pushes it itself still fights and still levels; it
-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and
-- pushBattle (entry wipe, battle theme). Shipped mods have hit exactly this.
do
-- the real dataset, not fixture(): a battle reaches for type_chart, items,
-- battle_anims and more, and this block only reads
local data = Data
local state, game = liveWorld(data)
-- the handoff runs through these three, so each needs the live game
for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do
check(bindGame(OW[fn], game), fn .. " binds Game")
end
state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" })
local Pokemon = require("src.pokemon.Pokemon")
local api = WorldAPI.new(game, "tester")
local value, err = api:startWildBattle("NOT_A_MON", 5)
check(value == nil and err:find("unknown species", 1, true),
"an unknown species refuses and names it")
-- Pokemon.new writes the level through into the stat calc and the exp curve
-- verbatim, so a fraction has to be refused rather than rounded downstream
for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do
check(api:startWildBattle("PIDGEY", lv) == nil,
"level " .. tostring(lv) .. " refuses")
end
local caterpie = Pokemon.new(data, "CATERPIE", 6)
game.save.party = { caterpie }
check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts")
-- pushBattle pushes the entry transition, which pushes the battle from its
-- own callback; awardExp is the BattleState marker (screenId would not work,
-- only Screens.push stamps that and pushBattle pushes the battle directly)
check(game.stack:top() ~= nil and game.stack:top().awardExp == nil,
"the entry transition goes on first")
-- overworld() resolves the world from UNDER the battle, so a second call
-- while one is up has to refuse rather than stack another
check(api:startWildBattle("PIDGEY", 5) == nil,
"a battle already running refuses")
local battle
for _ = 1, 400 do
local t = game.stack:top()
if t and t.awardExp then battle = t break end
if t and t.update then t:update(1 / 60) else break end
end
check(battle ~= nil, "the transition hands off to the battle")
battle.participants = { [caterpie] = true }
battle:awardExp()
check(caterpie.level >= 7, "the mon levels past its evolution threshold")
check(battle.leveledUp and battle.leveledUp[caterpie],
"awardExp records the level-up for EvolveAfterBattle")
game.stack:pop()
battle.onFinish("win")
for _ = 1, 12 do
local t = game.stack:top()
if not t or t.screenId == "EvolutionState" then break end
game.stack:pop()
if t.onDone then t.onDone() end
end
check(game.stack:top() and game.stack:top().screenId == "EvolutionState",
"the win reaches the evolution screen")
end
do
@@ -0,0 +1,93 @@
-- core.update / core.quit_to_launcher through the public mod API: a
-- platform-launcher integration can pause the simulation and veto the
-- return-to-launcher decision from a mod, with no main.lua patch.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local PlatformHooks = require("src.core.PlatformHooks")
local FIXTURE = {
["mods/fix_platform_bridge/manifest.json"] = [[{
"id": "fix_platform_bridge",
"name": "Fixture Platform Bridge",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_platform_bridge/main.lua"] = [[
local mod = ...
local paused = false
local extraPolls = 0
mod.hooks:wrap("core.update", function(nextFn, game, dt)
extraPolls = extraPolls + 1
if not paused then nextFn(game, dt) end
end)
mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end
return nextFn()
end)
-- test-only knobs, read back through mod.storage-free globals since
-- this fixture never leaves the process
_G.__fixturePlatformBridge = {
setPaused = function(v) paused = v end,
extraPolls = function() return extraPolls end,
}
]],
}
-- core.update: a subscriber can pause (skip vanilla) and still run every frame
do
local run = T.sdk.loadMods({ "mods/fix_platform_bridge" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local calls = 0
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
_G.__fixturePlatformBridge.setPaused(false)
PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "unpaused: vanilla Game:update runs")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1,
"the subscriber's wrapper runs every frame")
_G.__fixturePlatformBridge.setPaused(true)
PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "paused: vanilla Game:update is skipped")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2,
"the subscriber keeps polling every frame while paused")
run.release()
_G.__fixturePlatformBridge = nil
end
-- core.quit_to_launcher: a subscriber can veto without the vanilla
-- condition ever running, or pass it through unchanged
do
local run = T.sdk.loadMods({ "mods/fix_platform_bridge" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local realGetenv = os.getenv
os.getenv = function(name)
if name == "FIXTURE_VETO_QUIT" then return "1" end
return realGetenv(name)
end
local vanillaCalls = 0
local vetoed = PlatformHooks.quitToLauncher(function()
vanillaCalls = vanillaCalls + 1
return true
end)
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
os.getenv = realGetenv
local passed = PlatformHooks.quitToLauncher(function() return true end)
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
run.release()
end
T.finish("platform_lifecycle_hooks")
+11
View File
@@ -60,6 +60,17 @@ for _, r in ipairs(rows) do
end
check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame")
-- The post-battle walk takes the right-hand detour before heading north, so
-- the player does not visibly pass through the rival at (4,2).
local route = {}
for _, r in ipairs(rows) do
if r[1] == "move_player" then route[#route + 1] = r end
end
eq(route[#route - 1] and route[#route - 1][2], "right",
"walk-out route first moves right around the rival")
eq(route[#route] and route[#route][2], "up",
"walk-out route then heads north to Hall of Fame")
-- (3) Commands.face_player_dir sets the player's facing
local Commands = require("src.script.Commands")
check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function")
+13
View File
@@ -75,6 +75,19 @@ do
check(anyText(tb, "SUBSTITUTE"), "the failure text still prints")
end
-- Exact quarter HP is also not enough: accepting it would leave the user at
-- 0 HP with substituteHP set, so the next trainer-battle turn cannot advance.
do
local tb = freshBattle()
local cost = math.floor(tb.enemy.mon.stats.hp / 4)
tb.enemy.mon.hp = cost
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
check(tb.enemy.substituteHP == nil and tb.enemy.mon.hp == cost,
"exact quarter HP cannot create a zero-HP substitute")
check(not anyAnim(tb, "SUBSTITUTE"),
"the exact-boundary failure plays no animation")
end
-- .alreadyHasSubstitute: same, with a doll already standing
do
local tb = freshBattle()
+1
View File
@@ -93,6 +93,7 @@ local titleGame = {
field = { title = { cycleSpecies = { "PIKACHU" } } } },
}
local title = TitleState.new(titleGame, {})
title.phase, title.scy = "loop", 0
local titleSprite, titleTrueColor = title:currentSprite()
check(titleSprite and titleTrueColor,
"title cache keeps a Pokemon sprite's trueColor flag")
+9 -8
View File
@@ -446,16 +446,17 @@ check(misted2.stages.attack == nil
and mistMsgs[1]:find("MIST", 1, true) ~= nil,
"primary stat drop still blocked by MIST")
-- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP
-- (substitute.asm only fails on subtraction underflow)
-- Substitute boundary: the move must fail when its quarter-HP cost would
-- consume all current HP, preventing a zero-HP user with a live substitute.
local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" }
MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser)
check(subUser.substituteHP ~= nil and subUser.mon.hp == 0,
"substitute built at exactly 1/4 max HP leaves 0 HP")
local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" }
local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2)
check(subUser2.substituteHP == nil
local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser)
check(subUser.substituteHP == nil and subUser.mon.hp == 10
and subMsgs[1]:find("weak", 1, true) ~= nil,
"substitute fails at exactly 1/4 max HP")
local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" }
local subMsgs2 = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2)
check(subUser2.substituteHP == nil and subUser2.mon.hp == 9
and subMsgs2[1]:find("weak", 1, true) ~= nil,
"substitute fails below 1/4 max HP")
-- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of
+3 -3
View File
@@ -716,7 +716,7 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
driver_path = handle.name
try:
proc = subprocess.run([LUAJIT, driver_path], cwd=repo,
capture_output=True, text=True, timeout=120)
capture_output=True, text=True, encoding="utf-8", timeout=120)
except FileNotFoundError:
findings.append(Finding("MK100", "error",
f"cannot run {LUAJIT} (install luajit or "
@@ -1056,7 +1056,7 @@ def check_data_dump(repo, path, base, rel):
driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla))
try:
proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo,
capture_output=True, text=True, timeout=60)
capture_output=True, text=True, encoding="utf-8", timeout=60)
except FileNotFoundError:
# the gate must fail closed: a missing interpreter is a broken
# environment, not a clean mod
@@ -1416,7 +1416,7 @@ def dump_dataset(repo, base):
handle.close()
try:
proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name],
cwd=repo, capture_output=True, text=True)
cwd=repo, capture_output=True, text=True, encoding="utf-8")
finally:
os.unlink(handle.name)
if proc.returncode != 0: