Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.
Co-authored-by: Cursor <cursoragent@cursor.com>
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.
1. Music.lua: applyVolume built its `music.volume` hook context from the
private `state` table, but was defined *above* `local state = {...}`, so
those reads bound to the nil global `state`. Any mod registering the
music.volume hook crashed with "attempt to index a nil value (global
'state')" the first time a volume was applied. Forward-declare `state`
above applyVolume. Regression test drives a file-backed song through the
hook and asserts the context resolves.
2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
had two defects. The and/or idiom truncates a call to its first result,
so `why` (the specific failure reason) was always dropped and every link
failure showed the generic "Link battle can't start" instead of e.g.
"same mods on both games". Worse, when a host's newHost() returned nil,
the `or` fell through and wrongly called newGuest() as the host. Split
into an explicit if/else so the reason is preserved and each role calls
its own constructor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.
The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.
- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
device auto-detect (ARM handhelds -> low, phones -> balanced, normal
desktops -> high), per-tier caps, and the option-row cycle. Zero
requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
without rewriting stored options, so a lower tier hides the player's
TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
golden updated for the spliced row.
AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
love.filesystem looks for "mods/" in two places: the save directory, and
-- portable installs only -- the game folder, which CacheFs mounts. So a
player who unzips a mod next to the executable of an ordinary install,
which is where very nearly every other game would want it, gets no error
and no mod. The panel just comes up empty, with nothing on screen to
suggest the files are sitting in the wrong folder twenty centimetres away.
That is a hard failure to self-diagnose, and it is worse behind a
launcher: the install lives somewhere the player never opens, so "the
game's mods folder" is a guess to begin with.
The mods panel now looks in those folders before its first listing and
copies what it finds into the tree the game really reads, reporting what
it took in the notice line. It happens on open rather than behind a
button because the failure being fixed is one where nothing suggests
there is anything to press.
Looking is scoped: CacheFs.withMounted puts the folder on the read path
at its own mount point, runs the scan, and takes it straight back off.
Nothing a stray folder contains can shadow a game file or change what the
running game resolves, which is what makes it safe to point at a folder
whose contents nobody has validated. Adoption skips ids the game can
already see, so it is idempotent and never nags twice, and it leaves the
loose folder alone -- deleting files outside the save directory on the
player's behalf is not this code's call to make.
Which strays are worth taking is pure (LauncherMods.pickStrays), matching
how deriveList and locateRoot are already split out, so the engine tier
covers the rules without needing love. SaveData.gameFolders is the old
detectPortable candidate list lifted out unchanged -- portable mode is
just the case where one of those folders holds the marker.
Claude-Session: https://claude.ai/code/session_01JvEthuoNBPfxpvHUD9Pd4N
The reporter labels runs by abusing the in-game player name; give slots
a real label instead. SaveData.renameSlot persists a trimmed label in
the options registry (options.saveSlots[version].names) -- never in the
save file, so renaming needs no save rewrite and an empty slot can be
labeled too -- listSlots rows carry it as `label`, and deleteSlot
drops it with the slot.
In the launcher, right-clicking a slot row opens an inline rename modal
(Enter commits, Esc cancels, empty clears; 24 whole-codepoint cap via
local UTF-8 helpers, since plain luajit has no utf8 library). The row
title shows the label over the player name; badges/time/caught stay on
the meta line. Desktop-only: touch has no secondary button. main.lua
now forwards love.textinput to the importer while it is up.
Backend covered by a new renameSlot block in tests/engine/save_slots.lua
(78/78); docs/launcher.md's registry section documents the label.