diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 230d6713..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Feature request -description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod. -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Use this for **engine / launcher / platform** work (ports, video options, save - tooling, networking, mod API seams, docs). - - If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod - could ship — running shoes, alternate sprites, day/night, shiny indicators, - Gen 2-like battle toggles, soundtrack packs — open a - **[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)** - instead. - - "Can we add X" on its own is hard to act on. Say what you want, why you want it, - and how you picture it working. - - - type: input - id: summary - attributes: - label: One line summary - description: What you want, in a sentence. - placeholder: Add Linux AppImage releases next to the macOS and Windows builds - validations: - required: true - - - type: dropdown - id: game - attributes: - label: Which game is this about - description: Pick every version it applies to. Use N/A if it isn't game-specific. - multiple: true - options: - - Red - - Blue - - Yellow - - Gold - - N/A - validations: - required: true - - - type: input - id: discord - attributes: - label: Discord username (optional) - description: > - So maintainers can ping you on Discord if they need a quick follow-up. - Leave blank if you'd rather keep everything on GitHub. - placeholder: yourname - validations: - required: false - - - type: textarea - id: what - attributes: - label: What do you want - description: > - Describe it properly. What is it, where does it live (launcher, options, - engine), what does the player see or do. If it changes something that already - exists, say what it does today and what it should do instead. - placeholder: | - Ship a Linux AppImage on each release, same version as the macOS/Windows builds, - with the same save folder layout and mod discovery path. - validations: - required: true - - - type: textarea - id: why - attributes: - label: Why is this worth doing - description: > - What's annoying or missing right now. What does this fix. If it's just because you - think it would be fun, say that, it's a real answer. - placeholder: | - LÖVE already runs on Linux; without a packaged build, players have to assemble - it themselves and miss release notes / update checks. - validations: - required: true - - - type: textarea - id: how - attributes: - label: How should it work - description: > - The specifics. Which menu, what happens in the edge cases. If you don't - know, say what you'd expect as a player and leave the rest open. - placeholder: | - - GitHub Releases asset next to the .dmg / .exe - - Same options.lua / mods/ layout as desktop - - Documented in the README install section - validations: - required: true - - - type: dropdown - id: scope - attributes: - label: Does this change how the original game plays - description: > - Some requests are quality of life, some change the actual game. Both are fine, - it just helps to know which one you're asking for. - options: - - Quality of life, original game is untouched - - Changes how the game plays - - Not sure - validations: - required: true - - - type: textarea - id: extra - attributes: - label: Anything else - description: > - Reference screenshots, how another game does it, related issues. Leave blank - if nothing comes to mind. - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/mod_request.yml b/.github/ISSUE_TEMPLATE/mod_request.yml deleted file mode 100644 index abf7fa7b..00000000 --- a/.github/ISSUE_TEMPLATE/mod_request.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: Mod request -description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod. -labels: ["mod request"] -body: - - type: markdown - attributes: - value: | - This tracker is for ideas that should ship as **mods**, not as core engine - features — alternate sprites, running shoes, day/night, shiny indicators, - soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc. - - The engine already exposes a lot of this through registries and hooks - ([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)). - Use a **Feature request** instead for launcher / ports / video options / - networking / save tooling / new API seams. - - - type: input - id: summary - attributes: - label: One line summary - description: What the mod should do, in a sentence. - placeholder: Hold B to run at bike speed on the overworld - validations: - required: true - - - type: dropdown - id: game - attributes: - label: Which game is this for - description: Pick every version the mod should cover. Use N/A if it isn't game-specific. - multiple: true - options: - - Red - - Blue - - Yellow - - Gold - - N/A - validations: - required: true - - - type: input - id: discord - attributes: - label: Discord username (optional) - description: > - So maintainers or mod authors can ping you on Discord if they pick this up. - Leave blank if you'd rather keep everything on GitHub. - placeholder: yourname - validations: - required: false - - - type: textarea - id: what - attributes: - label: What should the mod do - description: > - Describe the player-facing behavior. What changes, where, what does the - player see or press. If it toggles from Options or a START-menu entry, say so. - placeholder: | - Hold B while walking outdoors to move at bike speed. Release to walk again. - Same places the bike is allowed; no effect in battles or menus. - validations: - required: true - - - type: textarea - id: why - attributes: - label: Why is this worth doing as a mod - description: > - Why optional/modded rather than a core option. Who wants it on, who wants - vanilla left alone. - placeholder: | - Great for replaying and backtracking, but some people want a strict Gen 1 - pace. A mod (or an opt-in mod option) keeps both camps happy. - validations: - required: true - - - type: textarea - id: how - attributes: - label: How should it work - description: > - Buttons, menus, edge cases, whether it needs new art/audio. If you know a - hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …), - mention it — otherwise leave it open. - placeholder: | - - Hold B on the overworld - - Same step timing as the bike - - Disabled where the bike is disabled - - Prefer hooks:wrap("movement.speed") if that still fits - validations: - required: true - - - type: dropdown - id: vanilla - attributes: - label: With the mod off, is vanilla unchanged - options: - - Yes — parity when disabled - - No — it would replace something always-on - - Not sure - validations: - required: true - - - type: dropdown - id: category - attributes: - label: Best-fit mod category - description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …). - options: - - GAMEPLAY / QoL - - GRAPHICS - - AUDIO - - BALANCE / ruleset - - CONTENT (maps, encounters, trainers) - - UI / TOOL - - TOTAL_CONVERSION-ish - - Not sure - validations: - required: true - - - type: textarea - id: extra - attributes: - label: Anything else - description: > - Reference screenshots, other games/hacks that do it, related issues, or - "I'd like to try writing this myself." Leave blank if nothing comes to mind. - validations: - required: false diff --git a/README.md b/README.md index d3657bf4..630c6fc6 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ Maps can be edited in our own build of [Tiled](https://www.mapeditor.org), and exported back out as a mod; see [docs/tiled-map-editing.md](docs/tiled-map-editing.md). -## Bugs and Ideas +## Bugs Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math that looks wrong, text in the wrong box, anything that does not match the @@ -341,12 +341,6 @@ original game. Attach a screenshot if you can. It saves a lot of back and forth, and if you can't get one, the form asks you to describe what you saw instead. -Thought of a feature that could be good, or a way to improve one that already -exists? -[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml). -Say what you want, why it is worth doing, and how you picture it working. A -request with real detail is one that can actually get built. - ## More - [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play) diff --git a/docs/tiled-map-editing.md b/docs/tiled-map-editing.md new file mode 100644 index 00000000..6ec9f7dd --- /dev/null +++ b/docs/tiled-map-editing.md @@ -0,0 +1,65 @@ +# Tiled map editing (mod authoring) + +`tools/tiled_export.py` turns the imported ROM cache into a +[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a +real map editor and exported back out as a mod. The original had no map +editor at all; the port's own map data is plain Lua, which is what makes +this a data path rather than an asset path. + +Editing is done in our own Tiled build, +[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases), +which ships the `gen1-mod-export` extension the workspace relies on. Grab it +from that repo's releases; upstream Tiled opens the workspace but cannot +export a mod out of it. + +```sh +python3 tools/tiled_export.py # -> build/tiled/ (gitignored) +``` + +Then open `build/tiled/gen1.tiled-project` in that build of Tiled. + +- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and + `kanto.world` places the 36 connected overworld maps at their real + connection offsets. That world is pre-loaded (seeded into the workspace's + Tiled session), so opening any one overworld map draws its neighbors around + it and you scroll and edit straight across the seams. Everything else is a + double-click away in Tiled's project panel. +- **Extending Kanto wires both ends.** A connection lives on both maps, so + hooking a new map onto a base map also emits the return connection as a + patch on that base map, keeping its other directions intact. The return + offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy + `back.offset == -offset`. +- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled + tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet, + so a tile layer *is* the map's `blocks` array. Warps, signs and objects + sit on the 16px cell grid in object layers, which is the grid the engine + addresses them on. +- **Collision is visible.** View > Show Tile Collision Shapes draws the real + walkability: a rectangle covers each cell whose feet tile is not in the + tileset's `walkable` list, which is the rule `src/world/Map.lua` applies. +- **Maps are shown in their real colors.** Each map is atlased in the SGB + palette it renders with, so Cerulean is blue and Lavender is purple in the + editor exactly as in game. Vanilla resolves that through a cascade with + interiors inheriting the last outdoor map, so the workspace mirrors the + cascade and walks the warp graph to colour interiors. Changing a map's + `palette` exports `palette = "..."` on the record, which beats the cascade, + and the editor offers the real palette names as a dropdown. +- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks + as raw 8x8 tiles, four by four, so new blocks can be composed there; + per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`, + `doorTiles` and the rest. +- **Export is a diff, not a fork of the data.** The `gen1-mod-export` + extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole + loadable mod folder. An edited vanilla map diffs against the imported data + and emits `mod.content.maps:patch` carrying *only* the fields that moved, so + a mod covers the parts it changes and leaves the rest to the base game; a + new map gets `:register` at an index of 1000 or above. An unchanged map + exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`. +- **Or the whole record, on request.** Ticking `exactExport` on a map switches + it to `mod.content.maps:override`, pinning the map to exactly what the + editor shows. It is off by default because an override wins outright over + any other mod patching that map, where a patch composes. + +No ROM-derived art travels into an exported mod: a tileset still drawing on +the player's own imported sheet references that path rather than shipping the +pixels, and only a sheet the author supplied is copied in. diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 2b16e6b6..c020301e 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -183,6 +183,9 @@ end -- --------------------------------------------------------------------------- local musicGen = 0 +-- bumps when SOUND flips so already-queued PCM (old pan) is dropped rather +-- than playing out the ~6s stall-tolerance queue (#1471) +local stereoEpoch = 0 function ChipAudio.playMusic(data, header, allowLoops) if not ensureWorker() then @@ -204,9 +207,11 @@ function ChipAudio.playMusic(data, header, allowLoops) allowLoops = allowLoops, audio = slimAudio(data), channelVolumes = ChipSynth.getChannelVolumes(), channelPitches = ChipSynth.getChannelPitches(), - stereo = ChipSynth.getStereo() }) + stereo = ChipSynth.getStereo(), + stereoEpoch = stereoEpoch }) currentMusic = { source = source, gen = gen, threaded = true, - started = false, finished = false } + started = false, finished = false, + stereoEpoch = stereoEpoch } -- playback starts in update() once the first buffer arrives (~1 frame) return source end @@ -236,6 +241,9 @@ local function updateThreaded() if not buf then break end if buf.gen ~= m.gen then -- stale buffer from a superseded song: drop it + elseif buf.stereoEpoch ~= nil and m.stereoEpoch ~= nil + and buf.stereoEpoch ~= m.stereoEpoch then + -- stale pan mix from before a live SOUND toggle (#1471) elseif buf.done then m.finished = true elseif buf.error then @@ -354,9 +362,45 @@ function ChipAudio.shutdown() workerReady = false end +function ChipAudio.currentSource() + return currentMusic and currentMusic.source +end + function ChipAudio.setStereo(enabled) + enabled = not not enabled + if ChipSynth.getStereo() == enabled then return end ChipSynth.setStereo(enabled) - pushChannelMix() + stereoEpoch = stereoEpoch + 1 + local m = currentMusic + if m and m.engine then + ChipSynth.applyStereo(m.engine) + end + if workerReady and cmdCh then + cmdCh:push({ cmd = "channelMix", + volumes = ChipSynth.getChannelVolumes(), + pitches = ChipSynth.getChannelPitches(), + stereo = enabled, + stereoEpoch = stereoEpoch }) + end + if not m then return end + pendingBuf = nil + if outCh then outCh:clear() end + m.stereoEpoch = stereoEpoch + -- QueueableSource cannot unqueue; swap so the ~6s stall-tolerance buffers + -- (mixed under the previous pan) do not have to play out first (#1471) + if not love.audio then return end + local ok, source = pcall( + love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT) + if not ok or not source then return end + local old = m.source + m.source = source + m.started = false + if old then pcall(old.stop, old) end + if not m.threaded then + fillSync(MUSIC_FILL_INITIAL) + if not musicHeld then pcall(source.play, source) end + m.started = true + end end function ChipAudio.getStereo() diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index f870ddb5..df9173b5 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -294,6 +294,10 @@ function Channel.new(engine, spec, options) noiseSampling = false, -- Gen 2 toggle_noise condition = 0, -- Gen 2 set_condition / sound_jump_if tracks = tracks, -- Gen 2 CHANNEL_TRACKS (NR51 bits for this channel) + -- last Music_StereoPanning byte; remembered even while MONO so a live + -- SOUND toggle can re-apply it without restarting the song (#1471) + stereoPanning = nil, + forcePanning = false, -- ForceStereoPanning ($e4) ignores the SOUND option waveInstrument = 0, waveLevel = 1, perfectPitch = false, @@ -406,6 +410,20 @@ function Channel:pan() bit.band(self.engine.pan, mask) ~= 0 end +-- Recompute CHANNEL_TRACKS from the remembered panning byte and the live +-- STEREO flag. ForceStereoPanning stays put either way. +function Channel:applyStereoMix() + local mask = bit.lshift(1, self.hardware - 1) + local default = bit.bor(bit.lshift(mask, 4), mask) + if self.forcePanning then + return + elseif stereoEnabled and self.stereoPanning then + self.tracks = bit.band(self.stereoPanning, default) + else + self.tracks = default + end +end + function Channel:tone(ticks, register, volume, fade) if register >= 0x800 then return self:timedEvent({ silence = true }, ticks) @@ -756,6 +774,7 @@ function Channel:nextEventGen2() local mask = bit.lshift(1, self.hardware - 1) local default = bit.bor(bit.lshift(mask, 4), mask) self.tracks = bit.band(packed, default) + self.forcePanning = true elseif command == 0xE5 then -- volume (global master; ignored for mix) self:byte() elseif command == 0xE6 then -- pitch_offset (big-endian) @@ -778,8 +797,12 @@ function Channel:nextEventGen2() elseif command == 0xEE then -- unknownmusic0xee self:word() elseif command == 0xEF then - -- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on + -- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on. + -- The packed byte is kept either way so ChipSynth.applyStereo can honour + -- a live SOUND toggle mid-song (#1471). local packed = self:byte() + self.stereoPanning = packed + self.forcePanning = false if stereoEnabled then local mask = bit.lshift(1, self.hardware - 1) local default = bit.bor(bit.lshift(mask, 4), mask) @@ -1298,14 +1321,30 @@ function Engine:sampleStereo() local left, right = 0, 0 for _, channel in ipairs(self.channels) do local value = channel:sample() - local event = channel.event - if not event or event.panLeft ~= false then left = left + value end - if not event or event.panRight ~= false then right = right + value end + local panLeft, panRight + if self.generation == 2 then + -- live CHANNEL_TRACKS, not the pan baked into the current note, so a + -- SOUND toggle reaches the next synthesized sample (#1471) + panLeft, panRight = channel:pan() + else + local event = channel.event + panLeft = not event or event.panLeft ~= false + panRight = not event or event.panRight ~= false + end + if panLeft then left = left + value end + if panRight then right = right + value end end return analogOut(self, left, "hpfCapLeft", "lpfLeft"), analogOut(self, right, "hpfCapRight", "lpfRight") end +function Engine:applyStereo() + if self.generation ~= 2 then return end + for _, channel in ipairs(self.channels) do + channel:applyStereoMix() + end +end + function Engine:sampleChannel(number) local selected = 0 for _, channel in ipairs(self.channels) do @@ -1360,4 +1399,10 @@ ChipSynth.newEngine = Engine.new ChipSynth.soundData = soundData ChipSynth.renderEffectData = renderEffectData +function ChipSynth.applyStereo(engine) + if type(engine) == "table" and engine.applyStereo then + engine:applyStereo() + end +end + return ChipSynth diff --git a/src/core/Music.lua b/src/core/Music.lua index 142bf78f..920e8360 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -507,7 +507,18 @@ function Music.applyOptions(opts) Music.setVolumeLevel(opts and opts.musicVol or 7) Music.setFilterLevel(opts and opts.musicFilter or 0) -- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit) - require("src.core.ChipAudio").setStereo(opts and opts.sound == "STEREO") + local ChipAudio = require("src.core.ChipAudio") + ChipAudio.setStereo(opts and opts.sound == "STEREO") + -- setStereo may swap the queueable source so the new pan is not sitting + -- behind already-mixed buffers; re-bind so volume/filter follow (#1471) + if state.chip then + local src = ChipAudio.currentSource() + if src then + state.source = src + applyVolume(src) + applyFilter(src) + end + end end local function sourceStopped(src) diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index c3bce302..21c59f8b 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -6,9 +6,10 @@ -- Protocol -- main thread pushes command tables onto the "chipaudio_cmd" -- channel and drains produced buffers off "chipaudio_out": -- cmd = "play" { gen, header, allowLoops, audio, --- channelVolumes?, channelPitches? } +-- channelVolumes?, channelPitches?, stereo?, stereoEpoch? } -- cmd = "stop" halt production --- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch +-- cmd = "channelMix" { volumes, pitches, stereo?, stereoEpoch? } +-- stereoEpoch present: live SOUND toggle; drop lookahead -- cmd = "invalidate" drop the bank cache -- cmd = "quit" end the thread -- out buffers are tagged with the play's `gen` so the main thread can @@ -39,6 +40,7 @@ local gen = nil -- active song generation, or nil when stopped local engine = nil -- the ChipSynth engine producing the current song local finished = false -- the current song ran out (non-looping) local data = nil -- { audio = } for ROM bank/wave reads +local stereoEpoch = 0 -- matches ChipAudio; stale pan buffers are dropped local function handle(cmd) if cmd.cmd == "play" then @@ -56,6 +58,7 @@ local function handle(cmd) if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end + if cmd.stereoEpoch ~= nil then stereoEpoch = cmd.stereoEpoch end local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header, { allowLoops = cmd.allowLoops }) if ok then @@ -73,6 +76,11 @@ local function handle(cmd) if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end + if engine and cmd.stereo ~= nil then ChipSynth.applyStereo(engine) end + if cmd.stereoEpoch ~= nil then + stereoEpoch = cmd.stereoEpoch + outCh:clear() + end elseif cmd.cmd == "invalidate" then ChipSynth.invalidateBanks() elseif cmd.cmd == "quit" then @@ -95,12 +103,13 @@ while true do local activeGen = gen local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2) if not ok then - outCh:push({ gen = activeGen, error = tostring(sd) }) + outCh:push({ gen = activeGen, error = tostring(sd), + stereoEpoch = stereoEpoch }) finished = true else - outCh:push({ gen = activeGen, sd = sd }) + outCh:push({ gen = activeGen, sd = sd, stereoEpoch = stereoEpoch }) if engine:finished() then - outCh:push({ gen = activeGen, done = true }) + outCh:push({ gen = activeGen, done = true, stereoEpoch = stereoEpoch }) finished = true end end diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 0ef01840..901f4d2a 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -355,6 +355,11 @@ function OptionsMenu:cycle(row, delta) if next_ < 1 then next_ = #row.values end if next_ > #row.values then next_ = 1 end self.options[row.key] = row.values[next_] + -- MUSIC VOL applies itself as it steps; SOUND has to as well, or the + -- pan sits on the current song until the next map change (#1471) + if row.key == "sound" then + require("src.core.Music").applyOptions(self.options) + end end function OptionsMenu:leave_()