diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 3b114d6b..61eb95df 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -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 = { diff --git a/docs/architecture.md b/docs/architecture.md index a1d58df6..09bbc60d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/modding.md b/docs/modding.md index 54a850f2..d7189ca9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 +renderer’s 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. diff --git a/docs/rfcs/0006-platform-lifecycle-hooks.md b/docs/rfcs/0006-platform-lifecycle-hooks.md new file mode 100644 index 00000000..9763fed1 --- /dev/null +++ b/docs/rfcs/0006-platform-lifecycle-hooks.md @@ -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`. diff --git a/main.lua b/main.lua index cb57bc37..f8c349e8 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 02d54fc3..15805293 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 93c24079..a96127fd 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -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 diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1ca31740..01f4f376 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -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 {} diff --git a/src/core/Music.lua b/src/core/Music.lua index 745d45a9..7ac621bc 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -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 diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 00000000..77bf83f5 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -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 diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 63f74228..2dd49e1d 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -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) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index daca6548..fa6f6a34 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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" diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 97bed0ac..9d1378a6 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -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, } diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index c834ce41..3c033bbe 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -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 diff --git a/src/render/Font.lua b/src/render/Font.lua index 760c91b2..3b59cffb 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -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 diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 3f39f1d9..36ebb36e 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -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 diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 650b364d..1a2c59ee 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -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 diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 332e90ef..7fed06b7 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -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 diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 15f14be2..fb66220b 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -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 diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 5c1f0eaa..b3856a0c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -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 diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 6fdb6b90..09b9e60b 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -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) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index aec5c71a..0c366afd 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1ac89816..d0b48898 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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) diff --git a/src/world/Player.lua b/src/world/Player.lua index 5fb257e8..65406b85 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -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) diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index dadf91d6..566afc9d 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -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) diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua index d46dcabe..69f4a2a4 100644 --- a/tests/drivers/hall_of_fame_bug704_test.lua +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -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 diff --git a/tests/drivers/title_cycle_test.lua b/tests/drivers/title_cycle_test.lua new file mode 100644 index 00000000..82f68d23 --- /dev/null +++ b/tests/drivers/title_cycle_test.lua @@ -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 diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 52a34344..33983c8a 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -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) diff --git a/tests/engine/chip_analog_path.lua b/tests/engine/chip_analog_path.lua new file mode 100644 index 00000000..e974c266 --- /dev/null +++ b/tests/engine/chip_analog_path.lua @@ -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") diff --git a/tests/engine/drum_envelope_ring.lua b/tests/engine/drum_envelope_ring.lua new file mode 100644 index 00000000..120f55f6 --- /dev/null +++ b/tests/engine/drum_envelope_ring.lua @@ -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") diff --git a/tests/engine/map_music_fade.lua b/tests/engine/map_music_fade.lua new file mode 100644 index 00000000..0ab6bab2 --- /dev/null +++ b/tests/engine/map_music_fade.lua @@ -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") diff --git a/tests/engine/nx_yellow_boot_test.lua b/tests/engine/nx_yellow_boot_test.lua index 764198ec..61d903e7 100644 --- a/tests/engine/nx_yellow_boot_test.lua +++ b/tests/engine/nx_yellow_boot_test.lua @@ -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", diff --git a/tests/engine/pc_list_kinds.lua b/tests/engine/pc_list_kinds.lua new file mode 100644 index 00000000..ad0238c3 --- /dev/null +++ b/tests/engine/pc_list_kinds.lua @@ -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") diff --git a/tests/engine/title_mon_cycle.lua b/tests/engine/title_mon_cycle.lua new file mode 100644 index 00000000..8b9c9039 --- /dev/null +++ b/tests/engine/title_mon_cycle.lua @@ -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") diff --git a/tests/engine/ttf_font_mode.lua b/tests/engine/ttf_font_mode.lua index 283f47a6..351f9a89 100644 --- a/tests/engine/ttf_font_mode.lua +++ b/tests/engine/ttf_font_mode.lua @@ -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") diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua index d0a4b825..32c540a2 100644 --- a/tests/engine/world_map_overview_test.lua +++ b/tests/engine/world_map_overview_test.lua @@ -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") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index b2b543a2..8e310e63 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -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) diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index 4b4633a6..e14d2b78 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -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 diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index d6c615e9..569479d2 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -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({ diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 60aefb97..4deb2640 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -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 diff --git a/tests/modkit/cases/platform_lifecycle_hooks.lua b/tests/modkit/cases/platform_lifecycle_hooks.lua new file mode 100644 index 00000000..88d95336 --- /dev/null +++ b/tests/modkit/cases/platform_lifecycle_hooks.lua @@ -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") diff --git a/tests/parity_B.lua b/tests/parity_B.lua index 3a0291a9..4c5876c0 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -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") diff --git a/tests/parity_substitute_anim.lua b/tests/parity_substitute_anim.lua index 8092c87b..1071d150 100644 --- a/tests/parity_substitute_anim.lua +++ b/tests/parity_substitute_anim.lua @@ -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() diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua index 415f470a..0bfa5015 100644 --- a/tests/parity_true_color_ui.lua +++ b/tests/parity_true_color_ui.lua @@ -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") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 2915cbbe..389cea06 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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 diff --git a/tools/modkit.py b/tools/modkit.py index 4eaf7f8b..09f868a5 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -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: