This commit is contained in:
bryanthaboi
2026-08-19 06:41:12 -04:00
66 changed files with 1716 additions and 64 deletions
+1 -5
View File
@@ -2527,11 +2527,7 @@ end
-- the HUD label drawn in place of the level for a statused mon
function BattleState:statusLabel(mon)
local record = Status.recordFor(self.data.statuses, mon.status)
if record then
return record.hudLabel or record.label or mon.status
end
return mon.status
return Status.hudLabelFor(self.data.statuses, mon.status)
end
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
+20 -5
View File
@@ -54,6 +54,13 @@ end
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
-- hudLabel before label, and Registry:patch only overrides the fields a
-- mod actually passes, so a label-only translation patch would be
-- shadowed by this hudLabel forever. Nothing in this codebase gives
-- hudLabel a value different from label -- setting it here only recreates
-- that trap for no observed benefit.
--
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -61,7 +68,7 @@ end
-- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = {
SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP",
id = "SLP", label = "SLP",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40,
beforeMove = function(battler, _, battle)
@@ -82,7 +89,7 @@ Status.RECORDS = {
end,
},
FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
id = "FRZ", label = "FRZ",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler, _, battle)
@@ -96,7 +103,7 @@ Status.RECORDS = {
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
id = "PSN", label = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("_HurtByPoisonText",
Strings.source("%s's\nhurt by poison!")),
@@ -112,7 +119,7 @@ Status.RECORDS = {
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
id = "BRN", label = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("_HurtByBurnText",
@@ -124,7 +131,7 @@ Status.RECORDS = {
end,
},
PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR",
id = "PAR", label = "PAR",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10,
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
return (statuses or Status.RECORDS)[id]
end
-- the HUD label for a status id: a mod's patched hudLabel/label if the
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
function Status.hudLabelFor(statuses, id)
local record = Status.recordFor(statuses, id)
return record and (record.hudLabel or record.label) or id
end
local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses
end
+1
View File
@@ -176,6 +176,7 @@ function Game:makeTitleState()
self:restoreSave(loaded, recovered, { freshBoot = true })
end
end,
onExit = self.onExit,
})
title.screenId = title.screenId or "TitleState"
return title
+1
View File
@@ -320,6 +320,7 @@ function Game2:showMainMenu()
onNewGame = function() self:newGame() end,
onContinue = function(save) self:continueGame(save) end,
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
onExit = self.onExit,
})
end
+219
View File
@@ -0,0 +1,219 @@
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml"
local function clean(value)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
if text == "" or text == "unknown" or text == "Unknown" then return nil end
return text
end
local function call(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, a, b, c, d, e = pcall(fn, ...)
if not ok then return nil end
return a, b, c, d, e
end
local function invoke(fn, ...)
if type(fn) ~= "function" then return false end
local ok, result = pcall(fn, ...)
return ok, result
end
local function commandValue(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*l")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value)
local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
return ("%%%02X"):format(char:byte())
end))
end
local function formOS(raw)
local values = {
["OS X"] = "macOS",
macOS = "macOS",
Windows = "Windows",
Linux = "Linux",
Android = "Android",
iOS = "iOS",
NX = "Nintendo Switch",
UWP = "Xbox",
Xbox = "Xbox",
}
return values[raw] or ""
end
local function loveVersion()
local major, minor, revision, codename = call(love and love.getVersion)
if not major then return "" end
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
return result
end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
return version
end
local function deviceModel(rawOS, system)
local model = clean(call(system.getModel))
if model then return model end
if rawOS == "OS X" or rawOS == "macOS" then
return commandValue("sysctl -n hw.model 2>/dev/null")
end
if rawOS == "Windows" then
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
end
if rawOS == "Linux" then
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
end
if rawOS == "Android" then
return commandValue("getprop ro.product.model 2>/dev/null")
end
return nil
end
local function modRows(context)
if context and type(context.mods) == "table" then return context.mods end
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
if ok and LauncherMods and LauncherMods.list then
local listed = call(LauncherMods.list)
if type(listed) == "table" then return listed end
end
return {}
end
local function modNames(rows, safeMode)
local enabled = {}
for _, mod in ipairs(rows or {}) do
if type(mod) == "table" then
local name = clean(mod.name or mod.id)
if name and not safeMode and mod.enabled == true then
enabled[#enabled + 1] = name
end
end
end
table.sort(enabled)
return enabled
end
local function metadata(options, context)
local system = love and love.system or {}
local graphics = love and love.graphics or {}
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system)
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode)
local safeMode = SaveData.isSafeMode(options)
local rows = modRows(context or {})
local enabledMods = modNames(rows, safeMode)
local lines = { "Diagnostics:" }
local function add(label, value)
value = clean(value)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end
add("Platform", formOS(rawOS))
local hardware = model
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
end
add("Renderer", rendererDetails)
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
if displayWidth and displayHeight then
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
end
if pixelWidth and pixelHeight
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion()
add("App", version ~= "" and Version.title() or "gen1recomp")
add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end
return {
rawOS = rawOS,
os = formOS(rawOS),
device = model,
version = version,
safeMode = safeMode,
enabledMods = enabledMods,
metadata = table.concat(lines, "\n"),
}
end
function IssueReport.build(options, context)
options = options or SaveData.loadOptions()
context = context or {}
local info = metadata(options, context)
local fields = {
summary = "",
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
version = info.version or "",
location = "",
screenshot = "",
steps = "",
expected = "",
extra = info.metadata,
}
local params = {
"template=" .. percentEncode(TEMPLATE),
"title=" .. percentEncode("bug: replace this with a meaningful title"),
}
local order = { "summary", "mods_which",
"version", "location", "screenshot", "steps", "expected", "extra" }
for _, key in ipairs(order) do
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
end
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
end
function IssueReport.open(options, context)
local url = IssueReport.build(options, context)
local system = love and love.system or {}
local opened, openResult = invoke(system.openURL, url)
if opened and openResult ~= false then
return true, url
end
local copied, copyResult = invoke(system.setClipboardText, url)
if copied and copyResult ~= false then
return true, url, "Issue URL copied to the clipboard."
end
local filesystem = love and love.filesystem or {}
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
if written and writeResult ~= false then
return true, url, "Issue URL saved to issue-report-url.txt."
end
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
end
IssueReport.percentEncode = percentEncode
IssueReport.metadata = metadata
return IssueReport
+13
View File
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
return false
end
local cachedIntentGame = nil
-- Returns version, slotId (either may be nil). Command line wins over env,
-- so a shortcut can override a machine-wide default.
function LaunchOptions.resolve(argv)
if cachedIntentGame == nil then
if love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.getLaunchGame then
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
else
cachedIntentGame = false
end
end
local intentGame = cachedIntentGame or nil
local game = normalizeVersion(argValue(argv, "game"))
or intentGame
or normalizeVersion(os.getenv("POKEPORT_GAME"))
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
+11
View File
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
safeMode = false,
-- Mods the player forced past the target gate (Loader:_gateGeneration).
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
-- pre-per-game shape and means the Gen 2 games only (see modForced).
@@ -386,6 +387,16 @@ function SaveData.mergeOptions(loaded)
return opts
end
function SaveData.isSafeMode(options)
return type(options) == "table" and options.safeMode == true
end
function SaveData.setSafeMode(options, enabled)
if type(options) ~= "table" then return false end
options.safeMode = enabled == true
return options.safeMode
end
function SaveData.encode(data)
return SaveSerializer.encode(data)
end
+45 -1
View File
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
-- except experimental mods, which stay off until opted in.
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
local enabled = flag == true or (flag == nil and not m.experimental)
if enabled then
if enabled and not SaveData.isSafeMode(opts) then
local chunk = fs.load(path .. "/" .. m.options_schema)
if chunk then
local okR, schema = pcall(chunk)
@@ -521,9 +521,49 @@ local function modRows(opts, mod)
return true
end }
end
for _, row in ipairs(rows) do
row.safeModeBlocked = true
if row.step then
local step = row.step
row.step = function(dir)
if SaveData.isSafeMode(opts) then return false end
return step(dir)
end
end
if row.setText then
local setText = row.setText
row.setText = function(text)
if SaveData.isSafeMode(opts) then return false end
return setText(text)
end
end
end
return rows
end
local function troubleshootingRows(opts, hooks)
return {
{
label = Strings("SAFE MODE"),
actionLabel = function()
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
end,
action = function()
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
return true
end,
},
{
label = Strings("REPORT ISSUE"),
actionLabel = Strings("Report bug"),
action = function()
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
return false
end,
},
}
end
-- ------- Gen 2 (Gold)
--
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
@@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return {
opts = opts,
version = version,
+31 -11
View File
@@ -682,6 +682,7 @@ end
local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end
if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
if status == "conflict" then return Strings("Conflict"), PAL.red end
-- not a fault: the mod is intact, this is simply not a game it is for
@@ -1814,10 +1815,11 @@ end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id)
local color = cartColor(game)
local focused = Kit.focusable(id, x, y, size, size)
local hot = focused or Kit.hover(x, y, size, size)
local function modGameCheckbox(x, y, size, checked, game, id, enabled)
enabled = enabled ~= false
local color = enabled and cartColor(game) or PAL.steel
local focused = enabled and Kit.focusable(id, x, y, size, size)
local hot = enabled and (focused or Kit.hover(x, y, size, size))
if love.graphics then
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
if checked then
@@ -1829,12 +1831,13 @@ local function modGameCheckbox(x, y, size, checked, game, id)
hot and Theme.A.focus or Theme.A.hairline, 1)
end
end
return Kit.press(x, y, size, size) or Kit._activateId == id
return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id)
end
local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate")
local safeMode = imp.safeMode == true
local mods = imp.mods or {}
local gap = m.gap
local cy = y
@@ -1866,9 +1869,11 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end })
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small",
enabled = not safeMode,
action = function() imp:_setAllMods(false) end })
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small",
enabled = not safeMode,
action = function() imp:_setAllMods(true) end })
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
font = "small",
@@ -1917,7 +1922,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line
local noticeText, noticeCol
if imp.modNotice then
if safeMode then
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
elseif imp.modNotice then
noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
else
@@ -2043,7 +2050,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey) then
game, togKey, not safeMode) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
@@ -3079,6 +3086,7 @@ local function buildProfilesModal(imp, m)
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
Strings("Switch"), {
kind = "good", font = "micro",
enabled = not imp.safeMode,
action = function()
LauncherMods.applyProfile(p.name, options)
if imp._refreshMods then imp:_refreshMods() end
@@ -3105,8 +3113,10 @@ local function buildModHeaderActionsModal(imp, m)
local btns = {
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
{ label = Strings("Enable all mods"), kind = "good", enabled = not imp.safeMode,
action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode,
action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
}
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
@@ -3119,6 +3129,7 @@ local function buildModHeaderActionsModal(imp, m)
for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
kind = b.kind or "ghost", font = "small",
enabled = b.enabled,
action = function()
imp._modHeaderActionsPopup = nil
b.action()
@@ -3737,6 +3748,7 @@ end
local function buildSettingsModal(imp, m)
local model = imp._settings
local SaveData = require("src.core.SaveData")
local pad = math.floor(18 * m.s)
local w = math.floor(640 * m.s)
local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9))
@@ -3826,6 +3838,8 @@ local function buildSettingsModal(imp, m)
item.header)
else
local row = item.row
local rowEnabled = not row.safeModeBlocked
or not SaveData.isSafeMode(model.opts)
local key = "set-" .. i
Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline")
local ix = px + pad + math.floor(12 * m.s)
@@ -3855,6 +3869,7 @@ local function buildSettingsModal(imp, m)
ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail)
btn(imp, rx - ew, ctlY, ew, m.btnH,
key .. "-edit", Strings("Edit"), { kind = "accent", font = "small",
enabled = rowEnabled,
action = function()
imp._settingsText = { row = row, text = tostring(row.value() or ""),
maxLen = row.editText.maxLen }
@@ -3863,12 +3878,14 @@ local function buildSettingsModal(imp, m)
elseif row.action then
-- A plain action row (Reset rebinds, Touch controls): the whole right
-- side is one button rather than a value ladder.
local aw = Kit.textWidth("small", row.actionLabel or Strings("Run"))
local actionLabel = type(row.actionLabel) == "function"
and row.actionLabel() or row.actionLabel or Strings("Run")
local aw = Kit.textWidth("small", actionLabel)
+ math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label,
labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text)
btn(imp, rx - aw, ctlY, aw, m.btnH,
key .. "-act", row.actionLabel or Strings("Run"), {
key .. "-act", actionLabel, {
kind = row.danger and "danger" or "ghost", font = "small",
action = function()
if row.action() ~= false then model.save() end
@@ -3884,12 +3901,14 @@ local function buildSettingsModal(imp, m)
or valW
btn(imp, rx - stepW, ctlY, stepW, m.btnH,
key .. "-next", ">", { font = "small",
enabled = rowEnabled,
action = function() if row.step and row.step(1) then model.save() end end })
Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw),
rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw,
PAL.heading)
btn(imp, rx - stepW - vw - stepW, ctlY, stepW,
m.btnH, key .. "-prev", "<", { font = "small",
enabled = rowEnabled,
action = function() if row.step and row.step(-1) then model.save() end end })
end
end
@@ -4046,6 +4065,7 @@ local function buildDepResolverModal(imp, m)
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
kind = "warn", font = "small",
enabled = not imp.safeMode,
action = function()
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.setEnabled(dep.id, false, imp.modScope)
+80 -3
View File
@@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png",
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
-- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
},
}
@@ -326,6 +329,32 @@ function RomImporter.isReady(version)
return marker == markerFor(version) and allRequiredFilesExist(version)
end
function RomImporter.syncAndroidShortcuts(activeVersion)
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.updateShortcuts) then
return false
end
local allVersions = { "red", "blue", "yellow", "gold" }
local ready = {}
local seen = {}
if activeVersion and RomImporter.isReady(activeVersion) then
table.insert(ready, activeVersion)
seen[activeVersion] = true
end
for _, v in ipairs(allVersions) do
if not seen[v] and RomImporter.isReady(v) then
table.insert(ready, v)
seen[v] = true
if #ready >= 4 then break end
end
end
return love.system.updateShortcuts(ready)
end
-- Load the import manifest for a version and confirm it matches that ROM.
local function sha1(data)
local digest = love.data.hash("sha1", data)
@@ -1393,6 +1422,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab()
self:_queueBaseRomScan()
@@ -1787,6 +1817,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
RomImporter.syncAndroidShortcuts(version)
-- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then
@@ -3598,6 +3629,10 @@ function RomImporter:_openSettings()
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
@@ -3615,11 +3650,13 @@ function RomImporter:_openSettings()
-- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
local version = self.tab
local ok, model = pcall(function()
return require("src.import.LauncherSettings").open(hooks, version)
end)
if ok and model then self._settings = model end
if ok and model then
self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end
end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
@@ -3630,8 +3667,38 @@ function RomImporter:_quitApp()
end
function RomImporter:_closeSettings()
if self._settings then self._settings.save() end
local model = self._settings
if model then
model.save()
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
if safeMode ~= self._settingsSafeModeAtOpen then
self.mods = nil
self.safeMode = safeMode
self._modSortCache = nil
self._modInfoFetch = nil
end
end
self._settings = nil
self._settingsSafeModeAtOpen = nil
end
function RomImporter:_reportIssue(options, version)
local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
return false
end
local opened, url, reason = IssueReport.open(options, {
version = version,
mods = self.mods,
})
if not opened then
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
return false
end
self._lastIssueReportURL = url
if reason then self.modNotice = { ok = true, text = reason } end
return true
end
function RomImporter:_commitSettingsText()
@@ -3971,6 +4038,8 @@ end
-- so a still list costs nothing after the first paint.
function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install
-- has no way to read. It happens here rather than behind a button because
@@ -4110,6 +4179,10 @@ end
-- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do
@@ -4150,6 +4223,10 @@ end
-- must not be the way around it. Disabling needs no confirm -- it is the
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
+20 -7
View File
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local safeMode = SaveData.isSafeMode(options)
-- the override is one answer per game (SaveData.modForced), the same scope
-- the loader resolves it under
@@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version)
-- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads for the selected game.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
if not safeMode then
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local enabled = not safeMode and enabledSet[m.id] == true
local forced = forcedFor(m.id)
local status, detail =
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
local status, detail
if safeMode then
status, detail = "safe_mode", "Disabled by safe mode"
else
status, detail =
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
end
-- nil, not false, when the panel is showing every game at once
local here = nil
if version then here = ModTargets.runsHere(m, version, nil, forced) end
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
local answers = {}
for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental)
answers[game] = not safeMode
and (answer == true or (answer == nil and not m.experimental))
end
return answers
end)(),
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m),
targetsHere = here,
safeMode = safeMode,
}
end
return out
@@ -586,6 +596,7 @@ end
-- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
@@ -599,6 +610,7 @@ end
-- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do
if scope then
@@ -1184,6 +1196,7 @@ end
function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local profiles = options.modProfiles or {}
local targetProfile
for _, p in ipairs(profiles) do
+10 -1
View File
@@ -259,6 +259,7 @@ function Loader.new(opts)
modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the
-- active version is set once in main.lua's bootGame before anything
-- builds a loader, and a run never changes generation underneath one.
@@ -300,6 +301,8 @@ end
function Loader:_loadState()
self.disabled = {}
local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
local scope = self:_enableScope()
local ids = {}
for id in pairs(options.mods or {}) do ids[id] = true end
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
end
function Loader:setEnabled(id, enabled)
if self.safeMode then return false end
if not self.mods[id] then return false end
self.disabled[id] = not enabled
self.mods[id].enabled = enabled
@@ -427,6 +431,7 @@ end
-- choice could not be persisted for a game, so the caller does not promise a
-- restart will honour it.
function Loader:setGen2Forced(id, forced)
if self.safeMode then return false, false end
if not self.mods[id] then return false, false end
self.gen2Forced[id] = forced or nil
self:_saveState()
@@ -1539,6 +1544,9 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState()
self:_discover()
if self.safeMode then
for id in pairs(self.mods) do self.disabled[id] = true end
end
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
@@ -1574,7 +1582,7 @@ function Loader:load(data)
-- 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
if not self.safeMode and envName and os.getenv(envName) == "1" then
self.disabled[id] = nil
end
end
@@ -1740,6 +1748,7 @@ function Loader:status()
local manifest = {}
for key, value in pairs(mod.manifest) do manifest[key] = value end
manifest.enabled = mod.enabled ~= false
manifest.safeMode = self.safeMode == true
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
manifest.error = mod.failure
-- set instead of `error` when the mod was left out for a reason that is
+37 -4
View File
@@ -365,9 +365,13 @@ end
function ManagerState:detailRows(m)
local rows = {}
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
if self:schemaFor(m) then
if Runtime.safeMode then
rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
else
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
end
if not Runtime.safeMode and self:schemaFor(m) then
rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end }
end
@@ -383,7 +387,8 @@ function ManagerState:detailRows(m)
-- what this mod does.
local loader = self.game.mods
local version, gen = self:targetGame()
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then
if loader and loader.setGen2Forced and not Runtime.safeMode
and not ModTargets.supports(m, version, gen) then
rows[#rows + 1] = {
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
action = function() self:toggleGen2Force(m) end }
@@ -674,6 +679,10 @@ end
-- ------- the enable/disable flow
function ManagerState:beginToggle(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
if not m then return end
local want = not m.enabled
local loader = self.game.mods
@@ -711,6 +720,10 @@ end
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
-- memory only, which the notice says rather than promising a restart.
function ManagerState:toggleGen2Force(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods
if not (loader and loader.setGen2Forced) then return end
local want = not m.gen2Forced
@@ -743,6 +756,10 @@ function ManagerState:enableScope()
end
function ManagerState:commitToggle(apply)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods
local opts = self:optionsTable()
local scope = self:enableScope()
@@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply)
end
function ManagerState:discardChanges()
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods
local opts = self:optionsTable()
local scope = self:enableScope()
@@ -802,6 +823,10 @@ function ManagerState:persistOptions()
end
function ManagerState:applyProfile(p)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local mods = self:manifestMap()
local set = self:enabledSet()
local combined = {}
@@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row)
end
function ManagerState:setOption(modId, key, value)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return false
end
local save = self.game.save
if save and save.options then
save.options.modOptions = save.options.modOptions or {}
@@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema)
end
function ManagerState:openOptions(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local schema = self:schemaFor(m)
if not schema then
self:notify("NO OPTIONS")
+10
View File
@@ -33,11 +33,21 @@ Runtime.currentMod = nil
-- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil
Runtime.safeMode = false
function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors
end
function Runtime.reset()
Runtime.events = NullEvents
Runtime.hooks = NullHooks
Runtime.errors = nil
Runtime.currentMod = nil
Runtime.modRequire = nil
end
-- attribute a runtime failure to the mod that owns the offending record.
-- "base" is the engine's own owner id: a vanilla record that fails is a
-- console line, not something the manager can ask the player to disable.
+2 -2
View File
@@ -180,8 +180,8 @@ local function release(game)
and mon.ot == game.save.player.name then
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name)))
((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name))))
return
end
game.stack:push(TextBox.new(game,
+2 -1
View File
@@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local Strings = require("src.core.Strings")
local Status = require("src.battle.Status")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
@@ -821,7 +822,7 @@ function PartyMenu:draw()
if mon.hp <= 0 then
Font.draw(Strings("FNT"), 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y)
end
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
-- tinting the fill AND running it through the row's zone
+2 -1
View File
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
@@ -145,7 +146,7 @@ function SummaryMenu:draw()
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw(Strings("STATUS/"), 72, 48)
Font.draw(mon.status or "OK", 128, 48)
Font.draw(Status.hudLabelFor(data.statuses, mon.status) or "OK", 128, 48)
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
Font.drawBox(0, 8, 10, 10)
+4 -1
View File
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
self.game = game
self.onNewGame = opts.onNewGame
self.onContinue = opts.onContinue
self.onExit = opts.onExit
-- branding comes from field.title with the shipped art as fallback, so
-- a total conversion rebrands the title without replacing the screen
local title = (game.data.field and game.data.field.title) or {}
@@ -514,7 +515,9 @@ function TitleState:openMenu()
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
if love.event and love.event.quit then
if self.onExit then
self.onExit()
elseif love.event and love.event.quit then
love.event.quit()
end
end })
+3
View File
@@ -2290,6 +2290,7 @@ function BattleState:openParty(forced)
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
prompt = forced and "which" or "choose",
battle = true,
battleSubmenu = not forced,
onCancel = function()
stack:pop()
@@ -2762,6 +2763,7 @@ function BattleState:openShiftParty()
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
prompt = "which",
battle = true,
onCancel = function()
stack:pop()
self.phase = "resolving"
@@ -3135,6 +3137,7 @@ function BattleState:useOnPartyMon(itemId, action)
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
prompt = "useItem",
battle = true,
party = self.battle.party or (self.save and self.save.party),
onCancel = function()
stack:pop()
+37 -1
View File
@@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11
-- HP bar is 6 tiles wide (48px) in the party list.
local function gridIndex(index, count, direction)
if count < 1 then return nil end
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local step = direction == "up" and -1 or direction == "down" and 1
if not step then return nil end
local rows = math.ceil(count / 2)
for offset = 1, rows do
local other = ((row + step * offset) % rows) * 2 + col + 1
if other <= count then return other end
end
return index
end
function PartyMenu:wantsFillScale() return true end
function PartyMenu:drawsWidescreen() return true end
@@ -114,6 +131,7 @@ function PartyMenu.new(game, opts)
self.wantsSubmenu = opts.submenu == true
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
self.wantsBattleSubmenu = opts.battleSubmenu == true
self.battle = opts.battle == true
self.submenu = nil
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
self.switchFrom = nil
@@ -146,6 +164,13 @@ function PartyMenu:isCancel()
return self.index > #self.party
end
function PartyMenu:gridNavigation()
if not self.battle
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
return Runtime.call("ui.party.grid_navigation", function() return false end,
self) == true
end
-- ------------------------------------------------------------- mon submenu
-- GetMonSubmenuItems, in its own order: every field move the mon knows first,
@@ -477,7 +502,18 @@ function PartyMenu:update(_dt)
return
end
local total = self:count()
if input:wasPressed("up") then
local grid
if self:gridNavigation() then
local direction = input:wasPressed("left") and "left"
or input:wasPressed("right") and "right"
or input:wasPressed("up") and "up"
or input:wasPressed("down") and "down"
grid = gridIndex(self.index, #self.party, direction)
end
if grid then
self.index = grid
self:storeCursor()
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or total
elseif input:wasPressed("down") then
self.index = self.index < total and self.index + 1 or 1