Compare commits

..

10 Commits

Author SHA1 Message Date
bryanthaboi 73fbaaa250 Merge pull request #1383 from bryanthaboi/dev
two absolut hogs doing hog level sh
2026-08-15 23:28:08 -04:00
bryanthaboi 9469e39926 Merge pull request #1379 from AverageConsumer/codex/mod-battle-intents
mods: add validated battle menu intents
2026-08-15 23:11:21 -04:00
bryanthaboi 179048a58e Merge pull request #1380 from AverageConsumer/codex/android-secondary-hotplug
android: rebind secondary displays after hotplug
2026-08-15 23:11:13 -04:00
bryanthaboi e1f5c2b217 Merge pull request #1382 from ShaneMcGovernIE/chore/postlog-body-limit
Raise postLog body ceiling to 512 KiB
2026-08-15 23:11:06 -04:00
bryanthaboi 1d8ac1e692 Merge pull request #1381 from ShaneMcGovernIE/fix/postlog-staging-temp-env
HostShell: stage postLog bodies via OS temp env, not tmpnam
2026-08-15 23:10:59 -04:00
Shane McGovern 39df5bdfa6 Raise postLog body ceiling to 512 KiB
A diagnostic ring (boot evidence + recent lines + status) routinely exceeds 64 KiB on a long session: a 651-line evidence ring measured ~90 KB and was rejected with "log body too large" (mod.postLog returned nil and the send was dropped).

The transport stages the body to a file and streams it via curl, so the ceiling is a budget, not a memory spike. 512 KiB is generous for real support logs while staying far under the 5 MiB the reference loghook endpoint accepts.
2026-08-16 03:31:36 +01:00
Shane McGovern 4b7a4daf2c HostShell: stage postLog bodies via OS temp env, not tmpnam
tmpnam() on the Windows CRT returns a bare, CWD-relative name (e.g. \sb4c.2), and io.open on it fails with Permission denied when the game's working directory is not writable -- a Program Files (or otherwise protected) install. postLog then dies before curl runs: the mod reports a send failure and no bytes leave the machine (confirmed on a Windows install: "could not create request body: \sb4c.2: Permission denied").

Stage the request body under the OS temp contract instead: TEMP/TMP on Windows (always set, always per-user writable), TMPDIR with a /tmp fallback on POSIX. The transport stays on plain io/os -- no love.filesystem dependency.

Tests updated to mock os.getenv and assert the staged path sits under the temp dir; 10/10 checks pass.
2026-08-16 03:29:33 +01:00
AverageConsumer e1d233d026 fix(android): rebind secondary displays after hotplug 2026-08-16 03:07:54 +02:00
AverageConsumer c22888a7fd feat(mods): add validated battle menu intents 2026-08-16 02:56:03 +02:00
github-actions 0e4fc3c54a chore(ios): update app-repo.json [skip ci] 2026-08-15 20:31:09 -04:00
12 changed files with 495 additions and 108 deletions
+19
View File
@@ -264,6 +264,25 @@ Gold currently returns an empty `items` list rather than guessing at its
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
optional ones. optional ones.
## Battle menu intents
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
just read. Every intent needs a mod-owned, strictly increasing positive
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
invalid choices return `nil` plus a reason without changing the battle.
The shared Red, Blue, Yellow, and Gold intents are:
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
other accepted choices)
- `{ kind = "move", slot = 1..4 }`
- `{ kind = "back" }` while the move menu is active
Menu choices and moves use the same engine methods as the native controls;
`party` and `item` open the native screens rather than exposing or duplicating
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
states refuse these core intents. Use `mod.input` for ordinary text advance.
## Rendering pipelines ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
@@ -366,6 +366,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
unregisterSecondaryDisplayListener();
onHostDestroy(); onHostDestroy();
super.onDestroy(); super.onDestroy();
} }
@@ -376,6 +377,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
unregisterSecondaryDisplayListener();
teardownSecondaryDisplay(); teardownSecondaryDisplay();
onHostPause(); onHostPause();
super.onPause(); super.onPause();
@@ -385,6 +387,7 @@ public class GameActivity extends SDLActivity {
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
onHostResume(); onHostResume();
if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay(); setupSecondaryDisplay();
} }
@@ -1405,6 +1408,7 @@ public class GameActivity extends SDLActivity {
// in src/jni/love/src/common/android.cpp. // in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation; private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false; private static volatile boolean secondaryEnabled = false;
private SecondaryDisplayMonitor secondaryDisplayMonitor;
private static final int MAX_SECONDARY_TOUCHES = 32; private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches = private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>(); new java.util.ArrayDeque<>();
@@ -1416,11 +1420,44 @@ public class GameActivity extends SDLActivity {
if (self == null) return; if (self == null) return;
self.runOnUiThread(new Runnable() { self.runOnUiThread(new Runnable() {
@Override public void run() { @Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay(); if (on) {
self.registerSecondaryDisplayListener();
setupSecondaryDisplay();
} else {
self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
}
} }
}); });
} }
private void registerSecondaryDisplayListener() {
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
if (monitor.register()) secondaryDisplayMonitor = monitor;
}
private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister();
}
private static void refreshSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled) return;
SecondaryPresentation current = secondaryPresentation;
Display display = current == null ? null : current.getDisplay();
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
if (current == null) {
setupSecondaryDisplay();
} else if (display == null || monitor == null
|| !monitor.hasDisplay(display.getDisplayId())) {
teardownSecondaryDisplay();
setupSecondaryDisplay();
}
}
private static void setupSecondaryDisplay() { private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return; if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
@@ -1466,6 +1503,35 @@ public class GameActivity extends SDLActivity {
} }
} }
@android.annotation.TargetApi(17)
private static class SecondaryDisplayMonitor
implements android.hardware.display.DisplayManager.DisplayListener {
private final android.hardware.display.DisplayManager manager;
SecondaryDisplayMonitor(GameActivity activity) {
manager = (android.hardware.display.DisplayManager)
activity.getSystemService(Context.DISPLAY_SERVICE);
}
boolean register() {
if (manager == null) return false;
manager.registerDisplayListener(this, new Handler(Looper.getMainLooper()));
return true;
}
void unregister() {
manager.unregisterDisplayListener(this);
}
boolean hasDisplay(int displayId) {
return manager.getDisplay(displayId) != null;
}
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
}
@Keep @Keep
public static boolean hasSecondaryDisplay() { public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null; return secondaryPresentation != null;
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.95",
"date": "2026-08-16",
"size": 11373796,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.95/gen1recomp++-0.1.95-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{ {
"version": "0.1.94", "version": "0.1.94",
"date": "2026-08-15", "date": "2026-08-15",
+57
View File
@@ -197,4 +197,61 @@ function BattleAPI:snapshot()
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex } mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
end end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local battle, top = activeBattle(self.game)
if not battle then return nil, "no battle" end
if intent.revision ~= self:_revision(battle, top) then
return nil, "stale battle context"
end
local kind = battle:battleKind()
if kind == "oldman" or kind == "link" or kind == "safari" then
return nil, "battle kind is not controllable"
end
if top ~= battle then return nil, "battle menu is covered" end
local ok, err
if intent.kind == "menu" then
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = battle:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if battle.phase ~= "moveSelect" then
return nil, "move menu is not active"
end
if battle.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.curMoves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle.player.disabledSlot == intent.slot then
return nil, "move is disabled"
end
ok, err = battle:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = battle:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI return BattleAPI
+74 -51
View File
@@ -1951,6 +1951,77 @@ function BattleState:playerHasPP()
return false return false
end end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if not self.player or not self.player.mon or self.player.mon.hp <= 0
or self:menuLockedAction(self.player) then
return nil, "battle menu is not ready"
end
self:clearTurnFlinches()
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- A scared turn still ticks the player's residual effects.
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- Trapping, Bide, and similar locks skip the move list.
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
elseif not self:playerHasPP() then
-- No usable PP goes straight to Struggle.
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
else
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
end
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
local move = self.player.curMoves[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
if self.player.disabledSlot == index then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif move.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = index
self:resolveTurn(move)
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
function BattleState:swapMoves(i, j) function BattleState:swapMoves(i, j)
if i == j then return end if i == j then return end
local moves = self.player.curMoves local moves = self.player.curMoves
@@ -2120,42 +2191,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1 self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- the scared turn still ticks the player's residual (PrintGhostText
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
return
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
else
self:openParty()
end
end end
return return
end end
@@ -2184,8 +2220,7 @@ function BattleState:update(dt)
end end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil self:cancelMove()
self.phase = "menu"
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB") require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then if self.moveSwapIndex then
@@ -2193,19 +2228,7 @@ function BattleState:update(dt)
self.moveSwapIndex = nil self.moveSwapIndex = nil
return return
end end
local mv = moves[self.moveIndex] self:chooseMove(self.moveIndex)
if self.player.disabledSlot == self.moveIndex then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
end end
return return
end end
+53
View File
@@ -116,4 +116,57 @@ function BattleAPI:snapshot()
items = {} } items = {} }
end end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local screen, top = activeBattle(self.game)
if not screen or not screen.battle then return nil, "no battle" end
if intent.revision ~= self:_revision(screen, top) then
return nil, "stale battle context"
end
if screen.tutorial then return nil, "battle kind is not controllable" end
if top ~= screen then return nil, "battle menu is covered" end
local battle = screen.battle
local ok, err
if intent.kind == "menu" then
if screen.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = screen:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if screen.phase ~= "moves" then return nil, "move menu is not active" end
if screen.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.moves and battle.player.moves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle:moveDisabled(battle.player, move.id) then
return nil, "move is disabled"
end
ok, err = screen:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = screen:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI return BattleAPI
+16 -2
View File
@@ -429,8 +429,22 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
userAgent = userAgent or "gen1recomp" userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then if HostShell.haveCurl() then
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never -- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
-- "rw". Stage the request body so the response can stay on a read pipe. -- "rw". Stage the request body so the response can stay on a read
local bodyPath = os.tmpname() -- pipe. The staging directory comes from the OS temp contract, never
-- tmpnam(): the CRT's tmpnam() can return a name relative to the process
-- working directory, and a game installed under Program Files has no
-- writable CWD -- io.open would fail before curl ever runs and postLog
-- would silently drop the send. TEMP/TMP are per-user writable on
-- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem:
-- the sandbox-era transport stays on plain io/os.
local function stagingPath()
local dir = os.getenv("TEMP") or os.getenv("TMP")
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
local sep = dir:find("\\") and "\\" or "/"
return dir .. sep .. ("gen1recomp-post-%d.tmp"):format(
(os.time() % 1000000) * 100 + math.random(0, 99))
end
local bodyPath = stagingPath()
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb") local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
if not bodyFile then if not bodyFile then
pcall(os.remove, bodyPath) pcall(os.remove, bodyPath)
+8 -3
View File
@@ -32,9 +32,14 @@ local Net = {}
Net.MAX_INFLIGHT = 4 Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely. -- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30 Net.MAX_SECONDS = 30
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no -- A log body ceiling. A diagnostic ring (boot evidence + recent lines +
-- reason to accept a mod uploading arbitrary megabytes to its endpoint. -- status) routinely exceeds 64 KiB on a long session, so the ceiling is
Net.MAX_BODY = 65536 -- 512 KiB: generous for real support logs, still far under the 5 MiB the
-- reference loghook endpoint accepts, and small enough that a misbehaving
-- mod cannot upload arbitrary megabytes. The body is staged to a file and
-- streamed by the transport, so the ceiling is a budget, not a memory
-- spike; callers that stay under it never notice it.
Net.MAX_BODY = 512 * 1024
local function fetch() local function fetch()
return require("src.net.Fetch") return require("src.net.Fetch")
+63 -43
View File
@@ -121,6 +121,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap -- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
-- $e1/$e2), which is what makes it fit a six-tile column. -- $e1/$e2), which is what makes it fit a six-tile column.
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" } local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
local MENU_ACTION = { FIGHT = "fight", ["<PK><MN>"] = "party",
PACK = "item", RUN = "run" }
local MENU_BOX_X = 8 local MENU_BOX_X = 8
local MENU_COL_SPACING = 6 local MENU_COL_SPACING = 6
@@ -1660,6 +1662,64 @@ function BattleState:playerMoves()
return (self.battle and self.battle.player and self.battle.player.moves) or {} return (self.battle and self.battle.player and self.battle.player.moves) or {}
end end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if choice == "fight" then
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
self:submit({ kind = "move", move = Battle.STRUGGLE })
else
self.phase = "moves"
-- MoveSelectionScreen reopens on the last used move, clamped if the
-- moveset shrank since then.
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
end
elseif choice == "run" then
self:submit({ kind = "run" })
elseif choice == "item" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moves" then return nil, "move menu is not active" end
local move = self:playerMoves()[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
self.moveSwapIndex = nil
if (move.pp or 0) <= 0 then
self:refuseMove(TEXT_NO_PP_LEFT)
elseif self.battle:moveDisabled(self.battle.player, move.id) then
self:refuseMove(TEXT_MOVE_DISABLED)
else
self:submit({ kind = "move", move = move.id })
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moves" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374). -- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under -- SELECT marks a slot, SELECT again swaps the marked slot with the one under
-- the cursor, and A or B clears the mark without swapping (the A arm opens -- the cursor, and A or B clears the mark without swapping (the A arm opens
@@ -1833,37 +1893,7 @@ function BattleState:update(_dt)
or self.menuIndex - 2 or self.menuIndex - 2
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2") self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex] self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
if choice == "FIGHT" then
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
-- :5058-5059): a mon with nothing to spend never sees the list.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
return self:submit({ kind = "move", move = Battle.STRUGGLE })
end
self.phase = "moves"
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
-- back, so the list reopens on the move used last turn; only
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
elseif choice == "RUN" then
self:submit({ kind = "run" })
elseif choice == "PACK" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
else
self:openParty()
end
end end
return return
end end
@@ -1884,22 +1914,12 @@ function BattleState:update(_dt)
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it -- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2") self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil self:cancelMove()
self.phase = "menu"
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move -- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it -- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2") self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil self:chooseMove(self.moveIndex)
local move = moves[self.moveIndex]
if not move then return end
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
if self.battle:moveDisabled(self.battle.player, move.id) then
return self:refuseMove(TEXT_MOVE_DISABLED)
end
self:submit({ kind = "move", move = move.id })
end end
return return
end end
@@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"),
check(position("onHostDestroy();") < position("super.onDestroy();"), check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction") "destroy hook runs before SDL destruction")
check(source:find("DisplayManager.DisplayListener", 1, true)
and source:find("registerDisplayListener", 1, true)
and source:find("unregisterDisplayListener", 1, true),
"secondary displays are monitored while the activity is active")
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
position("setupSecondaryDisplay();"),
"secondary display monitoring starts before initial discovery")
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
"a disconnected active display is rebound without replacing a live one")
check(not source:lower():find("openxr", 1, true), check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR") "generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and check(not source:find("QuestActivity", 1, true) and
+13 -7
View File
@@ -12,13 +12,13 @@ local check, eq = T.check, T.eq
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__" local MARK = "\n__gen1recomp_http__"
local BODY_PATH = "/tmp/gen1recomp-postlog-body-test" local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
local URL = "https://logs.example.com/logs" local URL = "https://logs.example.com/logs"
local BODY = "debug log body\n" local BODY = "debug log body\n"
local realOpen = io.open local realOpen = io.open
local realPopen = io.popen local realPopen = io.popen
local realTmpname = os.tmpname local realGetenv = os.getenv
local realRemove = os.remove local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl local realHaveCurl = HostShell.haveCurl
@@ -26,7 +26,12 @@ local openedPath, openedMode, writtenBody
local popenCommand, popenMode, removedPath local popenCommand, popenMode, removedPath
HostShell.haveCurl = function() return true end HostShell.haveCurl = function() return true end
os.tmpname = function() return BODY_PATH end os.getenv = function(name)
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
return STAGE_DIR
end
return realGetenv(name)
end
os.remove = function(path) os.remove = function(path)
removedPath = path removedPath = path
return true return true
@@ -55,21 +60,22 @@ local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test
io.open = realOpen io.open = realOpen
io.popen = realPopen io.popen = realPopen
os.tmpname = realTmpname os.getenv = realGetenv
os.remove = realRemove os.remove = realRemove
HostShell.haveCurl = realHaveCurl HostShell.haveCurl = realHaveCurl
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err)) eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
eq(openedPath, BODY_PATH, "the request body is written to a temporary file") check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
eq(openedMode, "wb", "the temporary request body is opened for binary writing") eq(openedMode, "wb", "the temporary request body is opened for binary writing")
eq(writtenBody, BODY, "the complete log body is staged") eq(writtenBody, BODY, "the complete log body is staged")
eq(popenMode, "r", "curl is opened in the supported read-only mode") eq(popenMode, "r", "curl is opened in the supported read-only mode")
check(popenCommand:find("--data-binary", 1, true) ~= nil, check(popenCommand:find("--data-binary", 1, true) ~= nil,
"curl reads the staged body with --data-binary") "curl reads the staged body with --data-binary")
check(popenCommand:find(BODY_PATH, 1, true) ~= nil, check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
"curl receives the temporary body path") "curl receives the temporary body path")
check(popenCommand:find(BODY, 1, true) == nil, check(popenCommand:find(BODY, 1, true) == nil,
"the log body is not placed directly in the command line") "the log body is not placed directly in the command line")
eq(removedPath, BODY_PATH, "the temporary request body is removed") eq(removedPath, openedPath, "the staged request body is removed")
T.finish("host shell postlog") T.finish("host shell postlog")
+108 -1
View File
@@ -4,7 +4,8 @@ love = love or require("tests.love_stub")
local S = require("tests.harness").suite("mod battle snapshot") local S = require("tests.harness").suite("mod battle snapshot")
local check, eq = S.check, S.eq local check, eq = S.check, S.eq
check(require("src.battle.BattleState").isBattleState == true, local Gen1BattleState = require("src.battle.BattleState")
check(Gen1BattleState.isBattleState == true,
"Gen 1 battle states carry the discovery marker") "Gen 1 battle states carry the discovery marker")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
@@ -50,6 +51,18 @@ local battle = {
function battle:battleKind() return "wild" end function battle:battleKind() return "wild" end
function battle:effectRecord() return { accuracyChecked = true } end function battle:effectRecord() return { accuracyChecked = true } end
function battle:visibleText() return { "Wild TESTMON appeared!" } end function battle:visibleText() return { "Wild TESTMON appeared!" } end
function battle:menuLockedAction() return nil end
function battle:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moveSelect" end
return true
end
function battle:chooseMove(slot)
self.chosenMove = slot
self.phase = "messages"
return true
end
function battle:cancelMove() self.phase = "menu" return true end
function battle:catchChance(ball) function battle:catchChance(ball)
return require("src.battle.Catching").chance(ball, self.enemy.mon, return require("src.battle.Catching").chance(ball, self.enemy.mon,
game.data.pokemon[self.enemy.mon.species]) game.data.pokemon[self.enemy.mon.species])
@@ -81,6 +94,35 @@ game.stack.states = {}
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle") check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
game.stack.states = { battle } game.stack.states = { battle }
local menu = api:snapshot()
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gen 1 rejects a stale intent")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gen 1 rejects an unknown menu choice")
check(api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" })
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
local moveMenu = api:snapshot()
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gen 1 rejects an invalid move slot")
check(api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
battle.phase = "moveSelect"
local back = api:snapshot()
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
"Gen 1 accepts move-menu back")
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
local player2 = { species = "CHIKORITA", level = 5, hp = 20, local player2 = { species = "CHIKORITA", level = 5, hp = 20,
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12, local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
@@ -90,6 +132,17 @@ local battle2 = { player = player2, enemy = enemy2, party = { player2 },
function battle2:moveDisabled() return false end function battle2:moveDisabled() return false end
local screen2 = { screenId = "Gen2BattleState", battle = battle2, local screen2 = { screenId = "Gen2BattleState", battle = battle2,
phase = "menu", menuIndex = 1, moveIndex = 1 } phase = "menu", menuIndex = 1, moveIndex = 1 }
function screen2:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moves" end
return true
end
function screen2:chooseMove(slot)
self.chosenMove = slot
self.phase = "resolving"
return true
end
function screen2:cancelMove() self.phase = "menu" return true end
local game2 = { local game2 = {
data = { data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" }, pokemon = { CHIKORITA = { name = "CHIKORITA" },
@@ -121,6 +174,60 @@ game2.stack.states = {}
check(api2:snapshot() == nil, "Gold returns nil outside a battle") check(api2:snapshot() == nil, "Gold returns nil outside a battle")
game2.stack.states = { screen2 } game2.stack.states = { screen2 }
screen2.message = nil
screen2.phase = "menu"
local menu2 = api2:snapshot()
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gold rejects a stale intent")
ok, err = api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gold rejects an unknown menu choice")
check(api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
local moveMenu2 = api2:snapshot()
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gold rejects an invalid move slot")
check(api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 1 }), "Gold accepts a valid move")
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
screen2.phase = "moves"
local back2 = api2:snapshot()
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
"Gold accepts move-menu back")
eq(screen2.phase, "menu", "Gold back restores the command menu")
do
local Data = require("tests.modkit").fixtures.fresh()
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local pressed = {}
local game3 = { data = Data, save = save, input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function() return false end,
}, stack = { states = {} } }
function game3.stack:top() return self.states[#self.states] end
function game3.stack:push(state) self.states[#self.states + 1] = state end
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
real.phase, real.queue, real.introSlide = "menu", {}, nil
game3.stack.states = { real }
pressed.a = true
real:update(1 / 60)
pressed.a = nil
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
pressed.b = true
real:update(1 / 60)
pressed.b = nil
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
end
local Loader = require("src.mods.Loader") local Loader = require("src.mods.Loader")
local fs = { read = function() end, getInfo = function() end, local fs = { read = function() end, getInfo = function() end,
getDirectoryItems = function() return {} end } getDirectoryItems = function() return {} end }