This commit is contained in:
bryanthaboi
2026-08-18 14:19:42 -04:00
parent abf95ce1c4
commit 7583ba8729
9 changed files with 193 additions and 268 deletions
-118
View File
@@ -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
-130
View File
@@ -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
+1 -7
View File
@@ -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)
+65
View File
@@ -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.
+47 -3
View File
@@ -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()
+49 -4
View File
@@ -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
+12 -1
View File
@@ -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)
+14 -5
View File
@@ -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 = <slim audio tables> } 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
+5
View File
@@ -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_()