Compare commits

..

14 Commits

Author SHA1 Message Date
bryanthaboi 83463a5a59 Merge pull request #1605 from AverageConsumer/codex/android-secondary-lifecycle
Fix secondary display lifecycle cleanup
2026-08-20 13:04:55 -04:00
bryanthaboi d66a72ac95 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-20 12:43:05 -04:00
bryanthaboi 1b659dab01 CLOSES #1582, CLOSES #1583 2026-08-20 12:43:04 -04:00
AverageConsumer 9e01fe2c2c Fix secondary display lifecycle cleanup 2026-08-20 18:23:20 +02:00
bryanthaboi 5fa5005786 Merge pull request #1559 from thibautbus/fix/route-more-messages-through-romtext
Route more battle/overworld/menu messages through their real ROM text
2026-08-20 11:54:40 -04:00
bryanthaboi 1ac5b867bb for that one guy who has a mouse but for some reason it doesnt have a scroll wheel 2026-08-20 11:54:08 -04:00
github-actions 69ef1bfc77 chore(ios): update app-repo.json [skip ci] 2026-08-20 11:00:28 -04:00
thibautbus 72592665d7 Cover the romText fixes with targeted regression tests
Every existing test around these callsites only ever runs with an
empty/fixture Data.text, so none of them could tell a properly-wired
romText/t._X call apart from a literal that never looked at the
catalog at all -- every assertion passed either way, the same coverage
gap the museum ticket clerk and status abbreviation fixes hit earlier.

Seven new tests fake the real label for each fix and assert the pushed
or queued message uses the translated value, plus a vanilla case per
fix confirming the no-catalog fallback still matches the original
English literal exactly:
- box_release_confirmation_romtext.lua (BoxMenu _OnceReleasedText, via
  a TextBox.new spy so real pagination/choice behavior stays intact)
- slot_machine_lined_up_romtext.lua (SlotMachine _LinedUpText, symbol
  concatenated in front of the translated suffix)
- battle_fainted_message_romtext.lua (BattleState onFaint, both
  _PlayerMonFaintedText and _EnemyMonFaintedText, confirming the raw
  name reaches each without a duplicated "Enemy")
- battle_catch_messages_romtext.lua (BattleState storeCaughtMon,
  _ItemUseBallText06 plus both _ItemUseBallText07/08 branches on
  EVENT_MET_BILL)
- battle_ball_dodge_romtext.lua (BattleState throwBall,
  _ItemUseBallText00's \f-merge collapsing to exactly one queued
  message)
- overworld_field_faint_heal_romtext.lua (OverworldController
  applyFieldPoison's _PokemonFaintedText, and
  useSoftboiledFieldMove's _ItemUseNoEffectText/_PotionText including
  the recovered-amount slot the old literal never showed)
- overworld_hidden_item_romtext.lua (OverworldController
  tryHiddenObject's _FoundHiddenItemText, both the {PLAYER} token and
  the item name landing in the right slots)

Confirmed several of these fail against the pre-fix code and pass
against the current code, not just reasoned about it. Not every one of
the 15 fixed callsites has its own dedicated test -- the ShopMenu,
LinkBattle, trainer-withdraw/sent-out and the normal (non-hidden)
found-item sites share the same romText mechanism already proven
correct by the seven tests above, and building the heavier fixtures
each would need (a full mart flow, a link session, a trainer AI
switch, an object_event NPC) wasn't judged worth it for what would be
the same assertion shape again.
2026-08-20 09:18:56 +02:00
thibautbus 8f88d01cf2 Route the link battle's opening line through its real ROM text
LinkBattle.lua's "%s wants\nto fight!" intro was a plain Lua literal,
even though the comment right above it already named the real label
(_TrainerWantsToFightText). The battle object built at this point is
already a BattleState, so this reuses its self:romText convenience
method rather than requiring the module-level helper separately.
2026-08-20 09:18:56 +02:00
thibautbus 2279617b29 Route the mart's price confirmation prompts through their real ROM text
ShopMenu.lua's buy/sell price confirmations were plain Lua literals,
even though the comment on each line already named the real label
(_PokemartTellBuyPriceText, _PokemartTellSellPriceText). This file's
own txt(game, key, fallback) helper doesn't support substitution
arguments, so it can't be reused as-is; added the module-level romText
helper instead, same as every other file in this batch.
2026-08-20 09:18:56 +02:00
thibautbus 17fbf6cec4 Route the slot machine's lined-up message through the real ROM text
SlotMachine.lua built "%s lined up!\nScored %d coins!" as a plain Lua
literal, substituting the symbol id (sym) as if it were part of the
translatable sentence. The real extracted _LinedUpText label
(" lined up!\nScored {RAM:wStringBuffer} coins!") shows the original
never had a slot for the symbol at all -- it was drawn separately and
only this fixed suffix was ROM text. Concatenate sym in front of
romText's real, already-translated label instead of interpolating it
into an engine literal.
2026-08-20 09:18:56 +02:00
thibautbus 34c4481f96 Route the box-release confirmation through the real ROM text
BoxMenu.lua's "Once released,\n%s is\ngone forever. OK?" prompt and its
"%s was\nreleased outside.\fBye %s!" follow-up (shown after confirming)
were both plain Lua literals, bypassing the extracted _OnceReleasedText
and _MonWasReleasedText labels entirely even though both exist and are
already translated in a real corpus build. Wrapped both in the same
t._X or Strings(...) pattern already used four lines above for the
Pikachu-unhappy prompt in this same function, with the same trailing
gsub to fill the {RAM:wStringBuffer} token(s) either branch leaves in
place -- _MonWasReleasedText's real text repeats the token twice (the
name appears at both ends of the sentence), and gsub's default
replace-all handles that the same way a single occurrence does.

Independent code review flagged a separate issue on this line and the
pre-existing Pikachu one right above it: both pass the nickname as a
bare gsub replacement string, which Lua %-escapes ("%" followed by a
digit 1-9 crashes with "invalid capture index", confirmed directly).
Checked how reachable that actually is: the naming screen's charset
can't produce a literal "%", and neither can a real cartridge import
(the Gen 1/2 character-decode tables never map any ROM byte to "%"
either) -- the only way in is editing a save's plain-Lua-source
nickname field directly. Not fixed here, to stay consistent with the
separate branch (fix/gsub-percent-escape-crash) already carrying this
exact fix across every callsite that shares it, including this one --
splitting the same bug's fix across two branches by which one happened
to touch the line first isn't a real reason to fix it in one place and
not the other.
2026-08-20 09:18:56 +02:00
thibautbus bff40a5d90 Route OverworldController's field messages through their real ROM text
Four message families in OverworldController.lua were plain Lua
literals instead of their already-extracted, already-translated ROM
text labels:

- applyFieldPoison()'s faint message: the third of three collapsed
  "%s\nfainted!" ROM strings (the other two, in BattleState.lua, are
  fixed in the previous commit) -- routed through _PokemonFaintedText.
- useSoftboiledFieldMove()'s two outcome messages: _ItemUseNoEffectText
  and _PotionText, the exact labels ItemEffects.lua's real potion
  message already uses, including _PotionText's second slot (the
  actual amount healed) the old literal never showed at all.
- tryHiddenObject()'s two hidden-item finds: _FoundHiddenItemText.
- The normal item-ball pickup path's two finds (one Yellow-only
  bag-full variant): a comment already named _FoundItemText
  ("FoundItemText: text_far, sound_get_item_1, text_end").

Both found-item labels lead with a {PLAYER} token that romText
auto-fills from a 2-arg call (player name, item name) in the same
order the literal already used. The fallback text for both is plain
"%s found\n%s!", matching the original literal's shape exactly --
an earlier version of this fix used a "{PLAYER} found\n%s!" fallback
that relied on TextBox.new's later TextBox.substitute pass to resolve
{PLAYER}, which works but needlessly made the fallback path depend on
a downstream call instead of being self-contained.
2026-08-20 09:18:56 +02:00
thibautbus 354a8b476d Route BattleState's trainer/catch/faint messages through their real ROM text
Nine message families in BattleState.lua were plain Lua literals,
bypassing already-extracted, already-translated ROM text labels --
some with a comment right next to them already naming the real label:

- storeCaughtMon(): the new-Pokedex-data line (_ItemUseBallText06) and
  the box-transfer line, which used a hardcoded "BILL's PC"/"someone's
  PC" as if it were a substituted argument in one shared template --
  _ItemUseBallText07/08 are two full, independently translated ROM
  strings, not a template with a substituted PC name.
- throwBall(): the dodged-ball and can't-be-caught lines were two
  separate Strings() calls; _ItemUseBallText00 is one \f-paged ROM
  label covering both. Unlike TextBox.new() (which splits \f itself),
  sayNext() goes through the battle queue's own startMessage(), which
  only splits on \n/\v -- confirmed live in a real build (the second
  sentence overflowed off the box instead of starting a fresh page).
  Resolves the label once, splits it the same way TextBox.lua does,
  and queues one sayNext per page.
- onFaint(): displayName(battler) runs the enemy name through a
  separate Strings("Enemy %s", ...) call, then the shared "%s\nfainted!"
  literal added the rest -- but _EnemyMonFaintedText already carries
  its own "Enemy" wording, so this passes the raw battler.name and
  picks _PlayerMonFaintedText/_EnemyMonFaintedText by battler.isPlayer.
- enter()'s pre-battle black-out message (_PlayerBlackedOutText2, a
  \f-paged pair like _ItemUseBallText00 above).
- The AI switch-in withdraw/send-out line and the enemy trainer's
  first send-out (3 callsites, one shared by the link-battle intro
  path): _AIBattleWithdrawText and _TrainerSentOutText.

Also investigated folding _TrainerAboutToUseText's SHIFT-switch offer
(say() then sayChoice(), both plain Strings(), which the label also
\f-pages) into one romText + sayChoice call the same way. That does
NOT work: tests/engine/trainer_shift_prompt_bug565.lua caught that the
battle queue's own text renderer pages a sayChoice string differently
from TextBox.lua's \f handling that the say()+say() merges above rely
on. Left as two calls, unchanged, with a comment explaining why.
2026-08-20 09:18:56 +02:00
24 changed files with 1121 additions and 68 deletions
+1
View File
@@ -327,6 +327,7 @@ local function returnToLauncher()
if love.audio and love.audio.stop then if love.audio and love.audio.stop then
pcall(love.audio.stop) pcall(love.audio.stop)
end end
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get() local currentVersion = GameVersion.get()
@@ -398,11 +398,15 @@ public class GameActivity extends SDLActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
secondaryHostResumed = false;
if (vibrator != null) { if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
unregisterSecondaryDisplayListener(); unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
secondaryEnabled = false;
synchronized (secondaryFrameLock) { secondaryFrame = null; }
unregisterAudioDeviceCallback(); unregisterAudioDeviceCallback();
abandonAudioFocus(); abandonAudioFocus();
onHostDestroy(); onHostDestroy();
@@ -411,6 +415,7 @@ public class GameActivity extends SDLActivity {
@Override @Override
protected void onPause() { protected void onPause() {
secondaryHostResumed = false;
if (vibrator != null) { if (vibrator != null) {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
@@ -426,6 +431,7 @@ public class GameActivity extends SDLActivity {
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
secondaryHostResumed = true;
onHostResume(); onHostResume();
requestGameAudioFocus(); requestGameAudioFocus();
registerAudioDeviceCallback(); registerAudioDeviceCallback();
@@ -1933,6 +1939,7 @@ public class GameActivity extends SDLActivity {
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY; private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
private static volatile long secondaryRetryAfter; private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false; private static volatile boolean secondaryEnabled = false;
private static volatile boolean secondaryHostResumed = false;
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO; private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
private static volatile int dualScreenDisplayMode = -1; private static volatile int dualScreenDisplayMode = -1;
private static volatile byte[] secondaryFrame; private static volatile byte[] secondaryFrame;
@@ -1963,7 +1970,7 @@ 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) { if (on && secondaryHostResumed) {
self.refreshDualScreenDisplayMode(); self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener(); self.registerSecondaryDisplayListener();
rebindSecondaryDisplay(); rebindSecondaryDisplay();
@@ -2035,9 +2042,11 @@ public class GameActivity extends SDLActivity {
private static void rebindSecondaryDisplay() { private static void rebindSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return; if (self == null || !secondaryHostResumed || !secondaryEnabled
|| secondaryOutputIsPreferred(self)) return;
self.runOnUiThread(() -> { self.runOnUiThread(() -> {
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return; if (!secondaryHostResumed || !secondaryEnabled
|| secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay(); teardownSecondaryDisplay();
setupSecondaryDisplay(); setupSecondaryDisplay();
}); });
@@ -2045,7 +2054,8 @@ public class GameActivity extends SDLActivity {
private static void setupSecondaryDisplay() { private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null if (self == null || !secondaryHostResumed || !secondaryEnabled
|| secondaryPresentation != null
|| secondaryActivity != null || secondaryActivityPending || secondaryActivity != null || secondaryActivityPending
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return; || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
try { try {
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.11",
"date": "2026-08-20",
"size": 13735190,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
},
{ {
"version": "0.2.10", "version": "0.2.10",
"date": "2026-08-19", "date": "2026-08-19",
+48 -15
View File
@@ -1718,9 +1718,14 @@ function BattleState:enter()
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs -- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
-- playerMonFainted queues on the battle screen; there is no battle -- playerMonFainted queues on the battle screen; there is no battle
-- screen to queue them on here, so they print over the map. -- screen to queue them on here, so they print over the map.
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
-- a different ROM address and is unused anywhere in this engine -- not
-- a fallback for this one, just pokered printing the same paragraph
-- from a second call site elsewhere.
self.game.stack:push(require("src.render.TextBox").new(self.game, self.game.stack:push(require("src.render.TextBox").new(self.game,
Strings("%s is out of\nuseable POKéMON!", name) .. "\f" self:romText("_PlayerBlackedOutText2",
.. Strings("%s blacked\nout!", name), blackedOut)) "%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
blackedOut))
return return
end end
self.musicKind = self:computeMusicKind() self.musicKind = self:computeMusicKind()
@@ -1857,7 +1862,8 @@ function BattleState:enter()
self:slidePic("foe") self:slidePic("foe")
end) end)
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923) -- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name)) self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
foeName, self.enemy.name))
self:act(function() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -3638,12 +3644,13 @@ function BattleState:executeAction(user, target, action)
}) })
self.aiUses = self:aiUsesFor() self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species) markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!" self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) self.trainer.name, oldName))
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText, -- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434) -- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
self.enemySendingOut = true self.enemySendingOut = true
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
self.trainer.name, self.enemy.name))
self:actNext(function() self:actNext(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
@@ -4142,8 +4149,12 @@ function BattleState:onFaint(battler)
-- acknowledged core.asm:797-798 bug.) -- acknowledged core.asm:797-798 bug.)
self:actNext(function() self:playVictoryMusic() end) self:actNext(function() self:playVictoryMusic() end)
end end
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText -- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
self:sayNext(Strings("%s\nfainted!", displayName(battler))) -- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
-- would double it up
self:sayNext(battler.isPlayer
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
if battler.isPlayer then if battler.isPlayer then
self:act(function() self:playerMonFainted() end) self:act(function() self:playerMonFainted() end)
else else
@@ -4319,6 +4330,13 @@ function BattleState:enemyMonFainted()
-- "X is" off so "about to use" stays above the name, instead of the -- "X is" off so "about to use" stays above the name, instead of the
-- page ending on a bare nick (#565). Then para "Will PLAYER" / -- page ending on a bare nick (#565). Then para "Will PLAYER" /
-- "change POKéMON?" with YES/NO. -- "change POKéMON?" with YES/NO.
--
-- _TrainerAboutToUseText combines both \f-paged, but unlike
-- _ItemUseBallText00's say()+say() merge above, this is say()+
-- sayChoice(): tried merging into one romText/sayChoice call and
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
-- the battle queue's own \f handling (not TextBox.lua's) does not
-- page a sayChoice string the same way -- left as two calls.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName)) self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice( self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name), Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
@@ -4362,7 +4380,8 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762) -- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true self.enemySendingOut = true
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
self.trainer.name, self.enemy.name))
self:actNext(function() self:actNext(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
@@ -4878,7 +4897,8 @@ function BattleState:storeCaughtMon()
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
-- the box rather than firing when the dex bit is set -- the box rather than firing when the dex bit is set
self:sayNextWaitSfx( self:sayNextWaitSfx(
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name), self:romText("_ItemUseBallText06",
"New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end) function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
self:uiNext(function() self:uiNext(function()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
@@ -4899,9 +4919,12 @@ function BattleState:storeCaughtMon()
if boxNum then if boxNum then
askCaughtNickname() askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
and "BILL's PC" or Strings("someone's PC") self:sayNext(self:romText(
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc)) metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
metBill and "%s was\ntransferred to\nBILL's PC!"
or "%s was\ntransferred to\nsomeone's PC!",
self.enemy.name))
else else
self:sayNext(Strings("But every BOX\nis full!")) self:sayNext(Strings("But every BOX\nis full!"))
end end
@@ -5007,8 +5030,18 @@ function BattleState:throwBall(ball)
-- RESTLESS SOUL dodges balls even once the scope has revealed it, -- RESTLESS SOUL dodges balls even once the scope has revealed it,
-- so it is not a ghost battle any more (#444) -- so it is not a ghost battle any more (#444)
self:animNext(self:tossAnimFor(ball), true, nil, ball) self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext(Strings("It dodged the\nthrown BALL!")) -- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
self:sayNext(Strings("This POKéMON\ncan't be caught!")) -- TextBox.new() (which splits \f itself), the battle queue's own
-- startMessage() only splits on \n/\v -- confirmed live: the \f
-- landed mid-line and the second sentence overflowed off the box
-- instead of starting a fresh page. Resolve the label once, then
-- split it the same way TextBox.lua does and queue one sayNext per
-- page, so the two ROM sentences still render as two pages.
local dodgeText = self:romText("_ItemUseBallText00",
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
self:sayNext(page)
end
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) end)
+25 -11
View File
@@ -982,7 +982,11 @@ end
-- parked the player until every direction was re-pressed (#799). -- parked the player until every direction was re-pressed (#799).
function Game:focus(f) function Game:focus(f)
Input:reset() Input:reset()
if f then Input:reconcile() end if f then
Input:reconcile()
local eng = self:syncEngine()
if eng then pcall(eng.noteResumed, eng) end
end
TouchControls:reset() TouchControls:reset()
self:cancelPointers() self:cancelPointers()
end end
@@ -1002,6 +1006,8 @@ function Game:onResume()
Input:reconcile() Input:reconcile()
TouchControls:reset() TouchControls:reset()
self:cancelPointers() self:cancelPointers()
local eng = self:syncEngine()
if eng then pcall(eng.noteResumed, eng) end
-- Chip music may survive NX suspend as a duplicate stream; stop it and let -- Chip music may survive NX suspend as a duplicate stream; stop it and let
-- the active screen re-cue on the next frame (hardware audio check: T19). -- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music. -- Desktop/mobile window-visible flips must not kill overworld music.
@@ -1209,18 +1215,26 @@ end
function Game:syncEngine() function Game:syncEngine()
if self._syncOff then return nil end if self._syncOff then return nil end
if self._syncEngineRef then return self._syncEngineRef end local eng = self._syncEngineRef
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
local eng = SyncEngine.shared()
if not eng then if not eng then
self._syncOff = true local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
return nil if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
eng = SyncEngine.shared()
if not eng then
self._syncOff = true
return nil
end
self._syncEngineRef = eng
end
if type(eng.protectPlaythrough) == "function" then
local meta = self.save and self.save.meta
eng:protectPlaythrough(
(self.save and self.save.version) or require("src.core.GameVersion").get(),
type(meta) == "table" and meta.playthroughId or nil)
end end
self._syncEngineRef = eng
return eng return eng
end end
+86 -13
View File
@@ -124,12 +124,48 @@ function LauncherView.detach(imp)
Kit.clearCaches() Kit.clearCaches()
end end
local function markNoDrag(imp, x, y, w, h)
if Kit.blockClicks then return end
local t = imp._noDragRects
if not t then t = {}; imp._noDragRects = t end
local n = (imp._noDragN or 0) + 1
imp._noDragN = n
local r = t[n]
if not r then r = {}; t[n] = r end
r.x, r.y, r.w, r.h = x, y, w, h
end
local function noDragAt(imp, x, y)
local rects = imp._noDragRects
for i = 1, imp._noDragN or 0 do
if inRect(rects[i], x, y) then return true end
end
return false
end
local function armMouse(imp, x, y)
if noDragAt(imp, x, y) then
imp._clickPt = { x = x, y = y }
return
end
local shielded = imp._modalUpNow
imp._mouseAt = {
x = x, y = y,
region = not shielded and tabScrollMax(imp) > 0
and inRect(imp._tabRegionRect, x, y) or false,
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
}
Kit.dragBegin(x, y)
end
-- ---------------------------------------------------------------- input -- ---------------------------------------------------------------- input
-- The kit is polled, not evented: update() samples the mouse and turns a -- The kit is polled, not evented: update() samples the mouse. A press arms
-- rising edge into a click point that the next draw consumes. Host-forwarded -- a drag that scrolls like a finger, and the click dispatches on RELEASE so
-- mousepressed stays unused, exactly as before, so Android's synthesized -- the drag can disqualify it, exactly like the touch path below; only the
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the -- cartridge (which owns its own spin-drag) keeps the press-down click.
-- other half of that guarantee. -- Host-forwarded mousepressed stays unused, exactly as before, so Android's
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
-- below is the other half of that guarantee.
function LauncherView.update(imp, dt) function LauncherView.update(imp, dt)
if not imp._flex then return end if not imp._flex then return end
if imp._launchFade then return end if imp._launchFade then return end
@@ -154,7 +190,39 @@ function LauncherView.update(imp, dt)
if not touching and now >= (imp._suppressMouseUntil or 0) if not touching and now >= (imp._suppressMouseUntil or 0)
and now >= (imp._suppressClickUntil or 0) then and now >= (imp._suppressClickUntil or 0) then
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
imp._clickPt = { x = mx, y = my } armMouse(imp, mx, my)
end
elseif down and imp._mouseAt then
local start = imp._mouseAt
local mx, my = love.mouse.getPosition()
local ddx, ddy = mx - start.x, my - start.y
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
start.dragged = true
end
if start.dragged then
local last = start.lastY or start.y
local move = -(my - last)
if move ~= 0 and start.region then
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
tabScrollMax(imp), move)
setTabScroll(imp, at)
move = leftover
end
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
imp._pageScrollMax, move)
imp._pageScroll = at
move = leftover
end
if move ~= 0 then Kit.dragAdd(move) end
end
start.lastY = my
elseif not down and imp._mouseAt then
local start = imp._mouseAt
imp._mouseAt = nil
Kit.dragEnd()
if not start.dragged then
imp._clickPt = { x = start.x, y = start.y }
end end
end end
imp._prevMouseDown = down imp._prevMouseDown = down
@@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y)
imp._clickPt = { x = x, y = y } imp._clickPt = { x = x, y = y }
end end
-- Event-driven click: a macOS trackpad tap delivers press+release inside one -- Event-driven press: a macOS trackpad tap delivers press+release inside one
-- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click -- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
-- from the press event under the poll's own suppression rules, and mark the -- from the press event under the poll's own suppression rules -- the poll's
-- press seen so the poll cannot mint a second one when isDown does catch it. -- release branch then mints the tap, still within the same frame for a
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
-- one when isDown does catch it.
function LauncherView.mousepressed(imp, x, y) function LauncherView.mousepressed(imp, x, y)
if not imp._flex then return end if not imp._flex then return end
local now = love.timer.getTime() local now = love.timer.getTime()
@@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y)
or now < (imp._suppressClickUntil or 0) then or now < (imp._suppressClickUntil or 0) then
return return
end end
imp._clickPt = { x = x, y = y } if not imp._mouseAt then armMouse(imp, x, y) end
imp._prevMouseDown = true imp._prevMouseDown = true
end end
@@ -495,6 +565,7 @@ end
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local state = cartridgeState(imp, version) local state = cartridgeState(imp, version)
markNoDrag(imp, x, y, w, h)
local focused = Kit.focusable(key, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h) local hot = Kit.hover(x, y, w, h)
local active = state.active local active = state.active
@@ -4483,7 +4554,7 @@ local function buildSyncHome(imp, m, eng)
local linked = eng:linked() local linked = eng:linked()
local codes = eng.codes local codes = eng.codes
local body = linked local body = linked
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.") and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
or Strings(SYNC_HINT) or Strings(SYNC_HINT)
local innerW = w - 2 * pad local innerW = w - 2 * pad
local hintH = Kit.wrapHeight("small", body, innerW, 5) local hintH = Kit.wrapHeight("small", body, innerW, 5)
@@ -4868,13 +4939,15 @@ function LauncherView.draw(imp)
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
imp._clickPt = nil imp._clickPt = nil
imp._wheelY = 0 imp._wheelY = 0
imp._noDragN = 0
Theme.field() Theme.field()
-- Everything from here to buildModals sits UNDER any open modal, so the -- Everything from here to buildModals sits UNDER any open modal, so the
-- whole stage draws shielded (no clicks, no hover, no focus ring) while -- whole stage draws shielded (no clicks, no hover, no focus ring) while
-- one is up; buildModals lowers the shield for the modal's own controls. -- one is up; buildModals lowers the shield for the modal's own controls.
Kit.blockClicks = modalUp(imp) imp._modalUpNow = modalUp(imp)
Kit.blockClicks = imp._modalUpNow
local step = Kit.scrollStep(m.s) local step = Kit.scrollStep(m.s)
do do
+3
View File
@@ -1537,6 +1537,9 @@ function RomImporter:focus(f)
self._modPress = nil self._modPress = nil
return return
end end
if type(self._sync) == "table" then
pcall(self._sync.noteResumed, self._sync)
end
if not (f and self.android and self.workState ~= "working") then return end if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag. -- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then if love.filesystem.getInfo("export_done.flag", "file") then
+2 -1
View File
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
self.opponentName = theirName self.opponentName = theirName
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2 -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
-- takes PrintBeginningBattleText's .trainerBattle arm, link included -- takes PrintBeginningBattleText's .trainerBattle arm, link included
self.introText = Strings("%s wants\nto fight!", theirName) self.introText = self:romText("_TrainerWantsToFightText",
"%s wants\nto fight!", theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
+25 -2
View File
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
SyncEngine.UPLOAD_DEBOUNCE = 5 SyncEngine.UPLOAD_DEBOUNCE = 5
SyncEngine.AUTO_INTERVAL = 300 SyncEngine.AUTO_INTERVAL = 300
SyncEngine.RESUME_MIN_GAP = 60
SyncEngine.MAX_STEPS_PER_UPDATE = 8 SyncEngine.MAX_STEPS_PER_UPDATE = 8
local IDLE_STATUS = "Ready" local IDLE_STATUS = "Ready"
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
eng.modPlan = nil eng.modPlan = nil
eng.shareCode = nil eng.shareCode = nil
eng.clock = 0 eng.clock = 0
eng.autoAt = SyncEngine.AUTO_INTERVAL
eng.queue = {} eng.queue = {}
eng.pending = nil eng.pending = nil
eng.uploadAt = nil eng.uploadAt = nil
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
self.uploadAt = nil self.uploadAt = nil
if self.state.enabled and self:linked() then self:syncNow() end if self.state.enabled and self:linked() then self:syncNow() end
end end
if self.clock >= self.autoAt and not self:busy()
and (self.phase == "idle" or self.phase == "error")
and self.state.enabled and self:linked() then
self:syncNow()
end
local steps = 0 local steps = 0
while not self.pending and #self.queue > 0 while not self.pending and #self.queue > 0
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
eng.phase = "idle" eng.phase = "idle"
eng.status = "Sync account created" eng.status = "Sync account created"
eng:_persist() eng:_persist()
eng:syncNow()
end) end)
end end
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
return self.state.enabled return self.state.enabled
end end
function SyncEngine:protectPlaythrough(version, playthroughId)
self.protectedKey = SyncState.key(version, playthroughId)
end
function SyncEngine:noteResumed()
if not (self.state.enabled and self:linked()) then return end
if self:busy() or self.phase == "conflict" then return end
if self.now() - (tonumber(self.state.lastSyncAt) or 0)
< SyncEngine.RESUME_MIN_GAP then
return
end
self:syncNow()
end
function SyncEngine:syncNow() function SyncEngine:syncNow()
if not self:linked() then return false, "this device is not linked" end if not self:linked() then return false, "this device is not linked" end
if self.pending then return false, "sync is busy" end if self.pending then return false, "sync is busy" end
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
self.queue = {} self.queue = {}
self.conflicts = {} self.conflicts = {}
self.state.pendingConflicts = {} self.state.pendingConflicts = {}
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
self:_addConflict(entry, key, row) self:_addConflict(entry, key, row)
elseif localChanged then elseif localChanged then
self:_queueUpload(entry, key, false) self:_queueUpload(entry, key, false)
elseif remoteChanged then elseif remoteChanged and key ~= self.protectedKey then
self:_queueDownload(key, entry.version, entry.playthroughId, "replace") self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
end end
end end
end end
for key, row in pairs(remote) do for key, row in pairs(remote) do
if not seen[key] then if not seen[key] and key ~= self.protectedKey then
local version, id = SyncState.splitKey(key) local version, id = SyncState.splitKey(key)
if version and id then if version and id then
self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
+4 -2
View File
@@ -185,14 +185,16 @@ local function release(game)
return return
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, { (t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name))
:gsub("{RAM:wStringBuffer}", name), nil, {
defaultNo = true, noSound = true, defaultNo = true, noSound = true,
choice = function(yes) choice = function(yes)
if not yes then return end if not yes then return end
table.remove(box, list.index) table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("%s was\nreleased outside.\fBye %s!", name, name))) ((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name))
:gsub("{RAM:wStringBuffer}", name))))
list:removeCurrent() list:removeCurrent()
end, end,
})) }))
+5 -2
View File
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local QuantityBox = require("src.ui.QuantityBox") local QuantityBox = require("src.ui.QuantityBox")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local ShopMenu = {} local ShopMenu = {}
@@ -57,7 +58,8 @@ local function buy(game, stock)
end end
local cost = qty * def.price local cost = qty * def.price
-- _PokemartTellBuyPriceText + yes/no confirm -- _PokemartTellBuyPriceText + yes/no confirm
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost) list.footer = romText(game.data, "_PokemartTellBuyPriceText",
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
list.footer = greet list.footer = greet
@@ -147,7 +149,8 @@ local function sell(game)
return return
end end
-- _PokemartTellSellPriceText + yes/no confirm -- _PokemartTellSellPriceText + yes/no confirm
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty) list.footer = romText(game.data, "_PokemartTellSellPriceText",
"I can pay you\n¥%d for that.", unit * qty)
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
list.footer = greet list.footer = greet
+3 -1
View File
@@ -46,6 +46,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local SlotMachine = {} local SlotMachine = {}
SlotMachine.__index = SlotMachine SlotMachine.__index = SlotMachine
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
-- shows it in the box while the screen flashes. LinedUpText follows. -- shows it in the box while the screen flashes. LinedUpText follows.
self.yeah = (sym == "7") self.yeah = (sym == "7")
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay) self.message = sym .. romText(self.game.data, "_LinedUpText",
" lined up!\nScored %d coins!", pay)
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
-- credited until the player dismisses the "lined up" text (see startPayout). -- credited until the player dismisses the "lined up" text (see startPayout).
self.stage = "flash" self.stage = "flash"
+47 -5
View File
@@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows)
return math.max(minRows or 1, math.min(maxRows or 99, per)) return math.max(minRows or 1, math.min(maxRows or 99, per))
end end
Kit.dragX = nil
Kit.dragY = nil
Kit.dragAccum = 0
function Kit.dragBegin(x, y)
Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0
end
function Kit.dragAdd(dy)
if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end
end
function Kit.dragEnd()
Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0
end
local function dragOriginIn(x, y, w, h)
if not Kit.dragX then return false end
local x1, y1, x2, y2 = x, y, x + w, y + h
local c = Kit._clipRect
if c then
x1, y1 = math.max(x1, c.x), math.max(y1, c.y)
x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h)
end
return Kit.dragX >= x1 and Kit.dragX <= x2
and Kit.dragY >= y1 and Kit.dragY <= y2
end
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do -- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
-- something (users expect it), but it moves a bounded page index rather than -- something (users expect it), but it moves a bounded page index rather than
-- driving a pixel offset, so there is no scroll state and no interpolation. -- driving a pixel offset, so there is no scroll state and no interpolation.
function Kit.wheelPage(x, y, w, h, page, total, perPage) function Kit.wheelPage(x, y, w, h, page, total, perPage)
if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end if Kit.blockClicks then return page end
if not Kit.hit(x, y, w, h) then return page end
local pages = math.max(1, math.ceil(total / math.max(1, perPage))) local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages) local out = page
Kit.wheelY = 0 if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then
return math.floor(moved) out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1),
1, pages))
Kit.wheelY = 0
end
local acc = Kit.dragAccum or 0
if acc ~= 0 and dragOriginIn(x, y, w, h) then
local stepPx = math.max(1, math.floor((h or 0) / 2))
local flips = acc >= 0 and math.floor(acc / stepPx)
or -math.floor(-acc / stepPx)
if flips ~= 0 then
local want = (out or 1) + flips
out = math.floor(Theme.clamp(want, 1, pages))
Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx
end
end
return out
end end
function Kit.scrollExtent(contentH, viewH) function Kit.scrollExtent(contentH, viewH)
+16 -8
View File
@@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target)
if not user or not user.stats or not target or not target.stats if not user or not user.stats or not target or not target.stats
or target == user or target.hp <= 0 or target == user or target.hp <= 0
or target.hp >= target.stats.hp or user.hp <= heal then or target.hp >= target.stats.hp or user.hp <= heal then
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect."))) Game.stack:push(TextBox.new(Game,
romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect.")))
return false return false
end end
local before = target.hp
user.hp = user.hp - heal user.hp = user.hp - heal
target.hp = math.min(target.stats.hp, target.hp + heal) target.hp = math.min(target.stats.hp, target.hp + heal)
require("src.core.Sound").play(Game.data, "Heal_HP") require("src.core.Sound").play(Game.data, "Heal_HP")
local def = Game.data.pokemon[target.species] local def = Game.data.pokemon[target.species]
-- _PotionText's second slot is the recovered amount, same as
-- ItemEffects.lua's potion message -- the engine fallback never shows it
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s's HP\nwas restored!", target.nickname or def.name))) romText(Game.data, "_PotionText", "%s's HP\nwas restored!",
target.nickname or def.name, target.hp - before)))
return true return true
end end
@@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy)
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name) .. "\f" romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
save.player.name, name) .. "\f"
.. romText(Game.data, "_HiddenItemBagFullText", .. romText(Game.data, "_HiddenItemBagFullText",
"But, {PLAYER} has\nno more room for\vother items!"))) "But, {PLAYER} has\nno more room for\vother items!")))
return true return true
@@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy)
-- text_asm tail runs it as PlaySoundWaitForCurrent + -- text_asm tail runs it as PlaySoundWaitForCurrent +
-- WaitForSoundToFinish once the box has printed (hidden_items.asm) -- WaitForSoundToFinish once the box has printed (hidden_items.asm)
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name), romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
save.player.name, name),
nil, TextBox.soundOpts(Game, "Get_Item2"))) nil, TextBox.soundOpts(Game, "Get_Item2")))
return true return true
end end
@@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc)
"No more room for\nitems!") "No more room for\nitems!")
if GameVersion.isYellow() then if GameVersion.isYellow() then
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
noRoom = Strings("%s found\n%s!", Game.save.player.name, name) noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!",
.. "\f" .. noRoom Game.save.player.name, name) .. "\f" .. noRoom
end end
Game.stack:push(TextBox.new(Game, noRoom)) Game.stack:push(TextBox.new(Game, noRoom))
return return
@@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc)
local ddef = Game.data.items[d.item] local ddef = Game.data.items[d.item]
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", Game.save.player.name, name), nil, romText(Game.data, "_FoundItemText", "%s found\n%s!",
Game.save.player.name, name), nil,
TextBox.soundOpts(Game, TextBox.soundOpts(Game,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
return return
@@ -3745,7 +3753,7 @@ function OverworldState:applyFieldPoison()
local queue = {} local queue = {}
for _, mon in ipairs(fainted) do for _, mon in ipairs(fainted) do
local name = mon.nickname or Game.data.pokemon[mon.species].name local name = mon.nickname or Game.data.pokemon[mon.species].name
table.insert(queue, Strings("%s\nfainted!", name)) table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%s\nfainted!", name))
end end
local alive = false local alive = false
for _, mon in ipairs(save.party) do for _, mon in ipairs(save.party) do
@@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
"secondary display monitoring starts before initial discovery") "secondary display monitoring starts before initial discovery")
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true), check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
"a disconnected active display is rebound without replacing a live one") "a disconnected active display is rebound without replacing a live one")
check(source:find("private static volatile boolean secondaryHostResumed = false;",
1, true), "secondary output tracks the primary activity lifecycle")
check(source:find("if (on && secondaryHostResumed)", 1, true)
and source:find("self == null || !secondaryHostResumed || !secondaryEnabled",
1, true),
"paused hosts cannot reopen secondary output from a late mod frame")
local pause = position("protected void onPause()")
local paused = assert(source:find("secondaryHostResumed = false;", pause, true))
local teardown = assert(source:find("teardownSecondaryDisplay();", pause, true))
local pauseSuper = assert(source:find("super.onPause();", pause, true))
check(pause < paused and paused < teardown and teardown < pauseSuper,
"pause blocks secondary setup before dismissing its output")
local resume = position("public void onResume()")
local resumeSuper = assert(source:find("super.onResume();", resume, true))
local resumed = assert(source:find("secondaryHostResumed = true;", resume, true))
local resumeSetup = assert(source:find("setupSecondaryDisplay();", resume, true))
check(resume < resumeSuper and resumeSuper < resumed and resumed < resumeSetup,
"resume permits secondary setup only after the primary activity resumes")
local destroy = position("protected void onDestroy()")
local destroyTeardown = assert(source:find("teardownSecondaryDisplay();", destroy, true))
local destroySuper = assert(source:find("super.onDestroy();", destroy, true))
check(destroy < destroyTeardown and destroyTeardown < destroySuper,
"destroy always dismisses secondary output before SDL destruction")
local mainFile = assert(io.open("main.lua", "rb"))
local main = mainFile:read("*a")
mainFile:close()
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
"returning from a game disables mod-owned secondary output")
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")
@@ -0,0 +1,87 @@
-- BattleState:throwBall()'s can't-be-caught path used to queue two
-- separate Strings() literals ("It dodged the\nthrown BALL!" then "This
-- POKéMON\ncan't be caught!"). The real ROM label _ItemUseBallText00
-- combines both as one \f-paged string. TextBox.new() would split \f
-- itself, but throwBall() queues through self:sayNext(), which goes
-- through the battle queue's own BattleState:startMessage() -- and that
-- one only splits on \n/\v, not \f (confirmed live: the \f landed
-- mid-line and the second sentence overflowed off the box instead of
-- starting a fresh page). The fix resolves the label once, then splits
-- it the same way TextBox.lua does and queues one sayNext per page. This
-- test fakes the label and checks the two pages reach the queue as two
-- separate messages, in order, not merged into one with a raw \f still
-- inside it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function mkbattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 8)
battle.ghost = true -- forces the can't-be-caught path
return battle
end
-- throwBall defers its message-queuing work into self.queue via one
-- top-level self:act(fn); run only that one function to reach the
-- say() calls the fix touches. It's the last entry throwBall itself
-- appends (after the immediate sayAuto), and it further queues its own
-- self:act(function() self:executeAction(...) end) for the actual enemy
-- turn -- deliberately NOT run here (out of scope, and running the queue
-- generically after mutation risks looping into a real turn simulation)
local function runThrowBallAct(battle)
for i = #battle.queue, 1, -1 do
if battle.queue[i].fn then
battle.queue[i].fn()
return
end
end
end
local function textEntries(battle)
local out = {}
for _, entry in ipairs(battle.queue) do
if entry.text then out[#out + 1] = entry.text end
end
return out
end
-- translated: the faked label's two \f-separated pages reach the queue
-- as two separate messages, in order, and neither one still contains a
-- raw \f (which would mean the battle queue's own renderer has to deal
-- with it, and it can't)
do
local battle = mkbattle()
Data.text._ItemUseBallText00 = "FAKE-DODGE!\fFAKE-CANTCATCH!"
battle:throwBall("FIX_BALL")
runThrowBallAct(battle)
local texts = textEntries(battle)
T.eq(texts[1], "FAKE-DODGE!", "page 1 reaches the queue on its own")
T.eq(texts[2], "FAKE-CANTCATCH!", "page 2 follows right after, still in order")
for _, t in ipairs(texts) do
T.check(not t:find("\f", 1, true), "no queued message still carries a raw \\f")
end
Data.text._ItemUseBallText00 = nil
end
-- vanilla: no catalog entry still falls back to the two English pages,
-- split the same way
do
local battle = mkbattle()
battle:throwBall("FIX_BALL")
runThrowBallAct(battle)
local texts = textEntries(battle)
T.eq(texts[1], "It dodged the\nthrown BALL!", "vanilla page 1")
T.eq(texts[2], "This POKéMON\ncan't be caught!", "vanilla page 2")
end
T.finish("battle_ball_dodge_romtext")
@@ -0,0 +1,77 @@
-- BattleState:storeCaughtMon() queues up to two plain-Lua-literal
-- messages: the new-Pokedex-data line (_ItemUseBallText06) and, when the
-- party is full, the box-transfer line (_ItemUseBallText07/08, keyed on
-- EVENT_MET_BILL -- two full, independently-translated ROM strings, not
-- one template with a substituted PC name). This test fakes all three
-- labels and checks the queued messages use them, not the English
-- literals.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function findText(battle, needle)
for _, entry in ipairs(battle.queue) do
if entry.text and entry.text:find(needle, 1, true) then return entry.text end
end
return nil
end
local function mkbattle(partySize, metBill)
local save = SaveData.newGame()
save.party = {}
for i = 1, partySize do
save.party[i] = Pokemon.new(Data, "FIXMON_A", 5)
end
save.flags = save.flags or {}
save.flags.EVENT_MET_BILL = metBill
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
return BattleState.newWild(game, "FIXMON_C", 8)
end
-- empty party: Party.add succeeds, only the new-Pokedex-data message fires
do
local battle = mkbattle(0, false)
Data.text._ItemUseBallText06 = "FAKE-DEX {RAM:wEnemyMonNick} FAKE!"
battle:storeCaughtMon()
T.eq(findText(battle, "FAKE-DEX"), "FAKE-DEX " .. battle.enemy.name .. " FAKE!",
"a translated _ItemUseBallText06 reaches the new-Pokedex-data message")
Data.text._ItemUseBallText06 = nil
end
-- full party, EVENT_MET_BILL true: box transfer via _ItemUseBallText07
do
local battle = mkbattle(6, true)
Data.text._ItemUseBallText07 = "FAKE-BILL {RAM:wBoxMonNicks} FAKE!"
battle:storeCaughtMon()
T.eq(findText(battle, "FAKE-BILL"), "FAKE-BILL " .. battle.enemy.name .. " FAKE!",
"EVENT_MET_BILL true routes through the translated _ItemUseBallText07")
Data.text._ItemUseBallText07 = nil
end
-- full party, EVENT_MET_BILL false: box transfer via _ItemUseBallText08
do
local battle = mkbattle(6, false)
Data.text._ItemUseBallText08 = "FAKE-SOMEONE {RAM:wBoxMonNicks} FAKE!"
battle:storeCaughtMon()
T.eq(findText(battle, "FAKE-SOMEONE"), "FAKE-SOMEONE " .. battle.enemy.name .. " FAKE!",
"EVENT_MET_BILL false routes through the translated _ItemUseBallText08")
Data.text._ItemUseBallText08 = nil
end
-- vanilla, full party, EVENT_MET_BILL true: English literal, BILL's PC
do
local battle = mkbattle(6, true)
battle:storeCaughtMon()
T.eq(findText(battle, "transferred"),
battle.enemy.name .. " was\ntransferred to\nBILL's PC!",
"no catalog entry falls back to the English BILL's-PC literal")
end
T.finish("battle_catch_messages_romtext")
@@ -0,0 +1,64 @@
-- BattleState:onFaint's "%s\nfainted!" collapsed two distinct ROM
-- strings (_EnemyMonFaintedText already carries its own "Enemy" wording;
-- _PlayerMonFaintedText does not) into one literal, substituted with
-- displayName(battler) -- which itself runs the enemy name through a
-- SEPARATE Strings("Enemy %s", ...) call. The fix passes the raw
-- battler.name and lets each label supply its own wording. This test
-- fakes both labels and checks the raw name reaches the right one, with
-- no "Enemy" ever duplicated.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function mkbattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
return BattleState.newWild(game, "FIXMON_C", 8)
end
-- finds the queued say-message text (onFaint queues several entries: a
-- wait, then the say)
local function findText(battle)
for _, entry in ipairs(battle.queue) do
if entry.text then return entry.text end
end
return nil
end
-- enemy: translated _EnemyMonFaintedText reaches onFaint, raw name only
do
local battle = mkbattle()
Data.text._EnemyMonFaintedText = "FAKE-ENEMY {RAM:wEnemyMonNick} FAKE!"
battle:onFaint(battle.enemy)
T.eq(findText(battle), "FAKE-ENEMY " .. battle.enemy.name .. " FAKE!",
"a translated _EnemyMonFaintedText reaches the enemy faint message")
Data.text._EnemyMonFaintedText = nil
end
-- enemy vanilla: the English literal already carries "Enemy " itself
do
local battle = mkbattle()
battle:onFaint(battle.enemy)
T.eq(findText(battle), "Enemy " .. battle.enemy.name .. "\nfainted!",
"no catalog entry falls back to the English literal, Enemy included")
end
-- player: translated _PlayerMonFaintedText reaches onFaint
do
local battle = mkbattle()
Data.text._PlayerMonFaintedText = "FAKE-PLAYER {RAM:wBattleMonNick} FAKE!"
battle:onFaint(battle.player)
T.eq(findText(battle), "FAKE-PLAYER " .. battle.player.name .. " FAKE!",
"a translated _PlayerMonFaintedText reaches the player faint message")
Data.text._PlayerMonFaintedText = nil
end
T.finish("battle_fainted_message_romtext")
@@ -0,0 +1,129 @@
-- BoxMenu's RELEASE confirmation ("Once released,\n%s is\ngone forever.
-- OK?") used to be a bare Lua literal. tests/engine/pc_release.lua only
-- ever runs with an empty Data.text, so it can't tell a properly-wired
-- t._OnceReleasedText or "..." fallback apart from a literal that never
-- looked at t at all -- every assertion there passes either way. This
-- test drives the same interactive release flow with a faked
-- Data.text._OnceReleasedText and checks the pushed TextBox's raw text
-- (captured via a TextBox.new spy, so real pagination/choice behavior is
-- untouched) uses the translated value, not the English literal.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local ids = T.fixtures.ids
require("src.render.Font").load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local TextBox = require("src.render.TextBox")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local realCry, realPlay = Sound.playCry, Sound.play
Sound.playCry = function() end
Sound.play = function() end
-- spy: records every raw text TextBox.new receives, without disturbing
-- pagination/choice re-push, so the interactive flow behaves exactly like
-- pc_release.lua's
local captured
local realNew = TextBox.new
TextBox.new = function(game, text, onDone, opts)
captured[#captured + 1] = text
return realNew(game, text, onDone, opts)
end
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop()
local t = self.states[#self.states]
self.states[#self.states] = nil
return t
end
function stack:top() return self.states[#self.states] end
function stack:update(dt)
local t = self:top()
if t and t.update then t:update(dt) end
end
local pressed = {}
local function press(btn)
pressed = { [btn] = true }
stack:update(1 / 60)
pressed = {}
end
local function topMt() return getmetatable(stack:top()) end
local function mash(btn, cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
press(btn)
end
return false
end
local function mkGame()
stack.states = {}
captured = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local box = Boxes.active(game.save)
box[1] = Pokemon.new(Data, ids.species[1], 5)
return game, box
end
local function releaseFirstMon(game)
stack:push(BoxMenu.new(game))
press("down"); press("down"); press("a") -- open RELEASE list
T.check(topMt() == ListMenu, "RELEASE opens the box list")
press("a") -- choose the first (only) mon
T.check(mash("a", function() return topMt() == ChoiceBox end),
"confirm choice opens")
-- the confirmation TextBox is captured[1] the moment it was pushed,
-- before this mash even ran
return captured[1]
end
-- monName (BoxMenu.lua): mon.nickname or def.name
local function monName(box)
local mon = box[1]
local def = Data.pokemon[mon.species]
return mon.nickname or def.name
end
-- translated: the fake value must reach the pushed TextBox
do
local game, box = mkGame()
local name = monName(box)
Data.text._OnceReleasedText = "FAKE {RAM:wStringBuffer} released!"
local text = releaseFirstMon(game)
T.eq(text, "FAKE " .. name .. " released!",
"a translated _OnceReleasedText reaches the release confirmation")
Data.text._OnceReleasedText = nil
end
-- vanilla: with no catalog entry, the English literal still substitutes
do
local game, box = mkGame()
local name = monName(box)
local text = releaseFirstMon(game)
T.eq(text, "Once released,\n" .. name .. " is\ngone forever. OK?",
"no catalog entry still falls back to the English literal")
end
TextBox.new = realNew
Sound.playCry, Sound.play = realCry, realPlay
T.finish("box_release_confirmation_romtext")
+82
View File
@@ -300,6 +300,88 @@ LauncherView.draw(edgeImp)
check((edgeImp._tabScroll.skins or 0) > 0, check((edgeImp._tabScroll.skins or 0) > 0,
"which reaches the tab region even though the cursor is below it") "which reaches the tab region even though the cursor is below it")
Kit.blockClicks = false
Kit._clipRect = nil
Kit.wheelY = 0
Kit.mouseX, Kit.mouseY = 400, 400
Kit.dragBegin(50, 50)
Kit.dragAdd(120)
local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10)
eq(pg, 3, "a drag that crossed two half-heights turns two pages")
eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip")
Kit.dragAdd(-140)
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
eq(pg, 1, "dragging back down returns those pages")
eq(Kit.dragAccum, -20, "with the remainder's sign preserved")
Kit.dragAdd(-80)
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
eq(pg, 1, "a drag past the first page clamps to it")
eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant")
Kit.dragAdd(200)
pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10)
eq(pg, 1, "a drag that began outside the list is not the list's")
eq(Kit.dragAccum, 200, "and its travel stays queued")
Kit.dragEnd()
eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
window(360, 780)
local mouseImp = skinLauncher(12)
LauncherView.draw(mouseImp)
LauncherView.draw(mouseImp)
local mreg = mouseImp._tabRegionRect
local mmax = mouseImp._tabScrollMax.skins
check(mmax > 0, "the mouse-dragged panel has travel")
local mdown = false
love.mouse.isDown = function() return mdown end
pointer(mreg.x + 20, mreg.y + 40)
mdown = true
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt == nil,
"a press over a scrollable region mints no click")
check(mouseImp._mouseAt ~= nil, "it arms a drag instead")
pointer(mreg.x + 20, mreg.y + 40 - 200)
LauncherView.update(mouseImp, 0.016)
eq(mouseImp._tabScroll.skins, math.min(200, mmax),
"dragging the held mouse scrolls the panel by its travel")
eq(mouseImp._pageScroll or 0, 0,
"while the panel still has travel, the page waits")
pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2)
LauncherView.update(mouseImp, 0.016)
eq(mouseImp._tabScroll.skins, mmax,
"a longer mouse drag reaches the panel's bottom")
check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there")
mdown = false
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt == nil, "a released drag is not a click")
check(mouseImp._mouseAt == nil, "and the gesture is retired")
pointer(mreg.x + 20, mreg.y + 40)
mdown = true
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt == nil, "a fresh press still holds its click back")
mdown = false
LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt ~= nil,
"press and release without travel is still a tap, on release")
mouseImp._clickPt = nil
LauncherView.draw(gameImp)
LauncherView.draw(gameImp)
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
local cart = gameImp._noDragRects[1]
pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
mdown = true
LauncherView.update(gameImp, 0.016)
check(gameImp._clickPt ~= nil,
"a press on the cartridge clicks at once so its own spin-drag still owns "
.. "the gesture")
check(gameImp._mouseAt == nil, "and never arms the scroll drag")
gameImp._clickPt = nil
mdown = false
LauncherView.update(gameImp, 0.016)
love.mouse.isDown = nil
local function read(path) local function read(path)
local f = assert(io.open(path, "r")) local f = assert(io.open(path, "r"))
local src = f:read("*a") local src = f:read("*a")
@@ -0,0 +1,139 @@
-- Two OverworldController.lua messages used to be bare Lua literals:
-- applyFieldPoison()'s "%s\nfainted!" (the third of three collapsed
-- fainted-message ROM strings, _PokemonFaintedText) and
-- useSoftboiledFieldMove()'s "It won't have\nany effect."/"%s's HP\nwas
-- restored!" (the same _ItemUseNoEffectText/_PotionText labels
-- ItemEffects.lua's real potion message already uses -- _PotionText's
-- second slot is the actual amount healed, which the old literal never
-- showed at all).
--
-- Uses the debug.setupvalue technique already established in
-- oaks_pc_flow.lua to fake the module-level Game/TextBox upvalues
-- ROM-free, without going through the heavy OverworldState:enter().
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
local function mkGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
pushed = {}
return {
data = Data, save = save,
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
end
for _, name in ipairs({ "applyFieldPoison", "useSoftboiledFieldMove" }) do
T.check(setUpvalue(OW[name], "Game", mkGame()), ("Game upvalue on %s"):format(name))
T.check(setUpvalue(OW[name], "TextBox", textBoxStub), ("TextBox upvalue on %s"):format(name))
end
local fakeSelf = setmetatable({}, { __index = OW })
-- ---- applyFieldPoison: _PokemonFaintedText ----
do
local game = mkGame()
setUpvalue(OW.applyFieldPoison, "Game", game)
local mon = game.save.party[1]
mon.status = "PSN"
mon.hp = 1 -- one poison tick (1 dmg by default) faints it
game.save.poisonSteps = 3 -- (3+1) % 4 == 0: this step ticks poison
Data.text._PokemonFaintedText = "FAKE {RAM:wNameBuffer} FAKE!"
fakeSelf:applyFieldPoison()
T.eq(pushed[1] and pushed[1].text, "FAKE " .. (mon.nickname or "FIXMON A") .. " FAKE!",
"a translated _PokemonFaintedText reaches the field-poison faint message")
Data.text._PokemonFaintedText = nil
end
-- vanilla: no catalog entry, English literal
do
local game = mkGame()
setUpvalue(OW.applyFieldPoison, "Game", game)
local mon = game.save.party[1]
mon.status = "PSN"
mon.hp = 1
game.save.poisonSteps = 3
fakeSelf:applyFieldPoison()
T.eq(pushed[1] and pushed[1].text,
(mon.nickname or "FIXMON A") .. "\nfainted!",
"no catalog entry falls back to the English fainted literal")
end
-- ---- useSoftboiledFieldMove: _ItemUseNoEffectText / _PotionText ----
do
local game = mkGame()
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
local user = Pokemon.new(Data, "FIXMON_A", 20)
local target = Pokemon.new(Data, "FIXMON_B", 20)
target.hp = target.stats.hp -- already full: no effect
Data.text._ItemUseNoEffectText = "FAKE-NOEFFECT!"
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
T.check(ok == false, "a full-HP target reports no effect")
T.eq(pushed[1] and pushed[1].text, "FAKE-NOEFFECT!",
"a translated _ItemUseNoEffectText reaches the no-effect message")
Data.text._ItemUseNoEffectText = nil
end
do
local game = mkGame()
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
local user = Pokemon.new(Data, "FIXMON_A", 20)
local target = Pokemon.new(Data, "FIXMON_B", 20)
target.hp = target.stats.hp - 10 -- missing exactly 10 HP
Data.text._PotionText = "FAKE {RAM:wNameBuffer} healed {NUM:wHPBarHPDifference, 2, 3}!"
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
T.check(ok == true, "a damaged target heals successfully")
T.eq(pushed[1] and pushed[1].text,
"FAKE " .. (target.nickname or "FIXMON B") .. " healed 10!",
"a translated _PotionText reaches the heal message, amount included")
Data.text._PotionText = nil
end
-- vanilla: no catalog entry, the fallback's single %s slot still fills
-- correctly (the amount is silently dropped by design, same as
-- ItemEffects.lua's own _PotionText fallback -- not a regression, this
-- matches the pre-fix literal's behavior exactly)
do
local game = mkGame()
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
local user = Pokemon.new(Data, "FIXMON_A", 20)
local target = Pokemon.new(Data, "FIXMON_B", 20)
target.hp = target.stats.hp - 10
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
T.check(ok == true, "a damaged target heals successfully (vanilla)")
T.eq(pushed[1] and pushed[1].text,
(target.nickname or "FIXMON B") .. "'s HP\nwas restored!",
"no catalog entry falls back to the English literal (no amount shown)")
end
package.loaded["src.core.Sound"] = realSound
T.finish("overworld_field_faint_heal_romtext")
@@ -0,0 +1,85 @@
-- OverworldState:tryHiddenObject()'s "%s found\n%s!" message used to
-- substitute both the player name and the item name into one bare Lua
-- literal. The real _FoundHiddenItemText label leads with a {PLAYER}
-- named token, which romText auto-fills from a 2-arg call in the same
-- order the literal already used -- this test checks both slots land
-- correctly (an accidental argument swap is the easy mistake this shape
-- invites).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
soundOpts = function() return {} end,
}
local MAP_ID = "FIX_TOWN"
Data.field.hiddenItems[MAP_ID] = { { x = 3, y = 3, item = "FIX_BALL" } }
local function mkGame()
local save = SaveData.newGame()
save.player.name = "FAKEPLAYER"
pushed = {}
return {
data = Data, save = save,
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
end
T.check(setUpvalue(OW.tryHiddenObject, "Game", mkGame()), "Game upvalue on tryHiddenObject")
T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject")
local fakeSelf = setmetatable({ map = { id = MAP_ID } }, { __index = OW })
-- translated: player name and item name both land in the right slots
do
local game = mkGame()
setUpvalue(OW.tryHiddenObject, "Game", game)
Data.text._FoundHiddenItemText = "FAKE {PLAYER} found FAKE {RAM:wNameBuffer} FAKE!"
local found = fakeSelf:tryHiddenObject(3, 3)
T.check(found == true, "the hidden item at (3,3) is found")
T.eq(pushed[1] and pushed[1].text,
"FAKE FAKEPLAYER found FAKE FIX BALL FAKE!",
"a translated _FoundHiddenItemText fills both {PLAYER} and the item name")
Data.text._FoundHiddenItemText = nil
end
-- vanilla: no catalog entry, so romText falls back to plain
-- Strings(fallback, ...) -- the fallback literal is "%s found\n%s!" (both
-- slots plain %s, matching the pre-fix literal's own shape), not the
-- {PLAYER} token the real label uses, since Strings() never does
-- {TOKEN} substitution on its own. (A {PLAYER}-token fallback would
-- still render correctly too, since the real TextBox.new always runs
-- TextBox.substitute over whatever text it's given -- but this test
-- stubs TextBox without that call, and the fallback shouldn't lean on a
-- substitution pass happening downstream regardless.)
do
local game = mkGame()
setUpvalue(OW.tryHiddenObject, "Game", game)
game.save.hiddenTaken = {} -- fresh spot
local found = fakeSelf:tryHiddenObject(3, 3)
T.check(found == true, "the hidden item is found again in a fresh game")
T.eq(pushed[1] and pushed[1].text, "FAKEPLAYER found\nFIX BALL!",
"with no catalog entry, the fallback still fills both the player "
.. "and item name via plain %s substitution")
end
T.finish("overworld_hidden_item_romtext")
@@ -0,0 +1,39 @@
-- SlotMachine:resolveWin's "%s lined up!\nScored %d coins!" message used
-- to interpolate the symbol id AND the payout into one bare Lua literal.
-- The real _LinedUpText label has no slot for the symbol at all (the
-- original ROM drew it separately) -- only for the coin count. This test
-- fakes _LinedUpText and checks the symbol is concatenated in front of the
-- translated suffix, with the payout correctly substituted into it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local SlotMachine = require("src.ui.SlotMachine")
local function mkSelf()
local game = { data = Data, save = { coins = 0 } }
return setmetatable({ game = game, allowMatchesCounter = 0 }, SlotMachine)
end
-- translated: the fake suffix reaches self.message, with the symbol
-- concatenated in front and the payout substituted into the fake text
do
local self = mkSelf()
Data.text._LinedUpText = " FAKE-SUFFIX {RAM:wStringBuffer}!"
self:resolveWin({ symbol = "CHERRY", payout = 8 })
T.eq(self.message, "CHERRY FAKE-SUFFIX 8!",
"a translated _LinedUpText reaches the lined-up message")
Data.text._LinedUpText = nil
end
-- vanilla: with no catalog entry, the English literal still substitutes,
-- with its own leading space (the original had no slot for the symbol)
do
local self = mkSelf()
self:resolveWin({ symbol = "CHERRY", payout = 8 })
T.eq(self.message, "CHERRY lined up!\nScored 8 coins!",
"no catalog entry still falls back to the English literal")
end
T.finish("slot_machine_lined_up_romtext")
+101 -4
View File
@@ -94,20 +94,26 @@ do
local eng, transport = engine({ local eng, transport = engine({
["POST /sync/create"] = { code = 200, body = ["POST /sync/create"] = { code = 200, body =
'{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' },
}, {}, SyncState.defaults()) ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' },
}, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults())
T.eq(eng:linked(), false, "a fresh engine is not linked") T.eq(eng:linked(), false, "a fresh engine is not linked")
T.eq(eng.status, "Not set up", "and says so") T.eq(eng.status, "Not set up", "and says so")
eng:createAccount("laptop") eng:createAccount("laptop")
pump(eng, 3) pump(eng)
T.eq(eng:linked(), true, "creating an account links this device") T.eq(eng:linked(), true, "creating an account links this device")
T.eq(eng.state.account, "aa11", "and stores the account id") T.eq(eng.state.account, "aa11", "and stores the account id")
T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped")
T.eq(eng.codes.code2, "3333-4444", "and so is the second") T.eq(eng.codes.code2, "3333-4444", "and so is the second")
T.eq(eng.state.code1, nil, "codes never enter the persisted state") T.eq(eng.state.code1, nil, "codes never enter the persisted state")
T.eq(transport.sent[2].url, "http://sync.test/sync/state",
"creating the account starts a sync straight away")
T.eq(transport.sent[3].method, "PUT",
"so the saves that existed before setup are uploaded")
T.eq(SyncState.rev(eng.state, "red/abc"), 1, "and the served rev is remembered")
T.eq(eng.phase, "idle", "and the engine settles") T.eq(eng.phase, "idle", "and the engine settles")
T.eq(#transport.sent, 1, "one request was made")
end end
do do
@@ -293,14 +299,105 @@ do
T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing") T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing")
end end
do
local eng, transport = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {})
eng:update(SyncEngine.AUTO_INTERVAL - 1)
T.eq(#transport.sent, 0, "an idle linked engine does not poll early")
eng:update(1)
T.eq(#transport.sent, 1, "after the auto interval it checks the server")
pump(eng)
T.eq(eng.phase, "idle", "and settles")
eng:update(SyncEngine.AUTO_INTERVAL - 10)
T.eq(#transport.sent, 1, "the next poll waits a whole interval again")
eng:update(10)
T.eq(#transport.sent, 2, "then fires")
end
do
local eng, transport = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {}, SyncState.defaults())
eng:update(SyncEngine.AUTO_INTERVAL * 2)
T.eq(#transport.sent, 0, "an unlinked engine never polls on its own")
end
do
local calls = 0
local eng = engine({
["GET /sync/state"] = function()
calls = calls + 1
if calls == 1 then return { code = 500, body = '{"error":"down"}' } end
return { code = 200, body = '{"saves":{}}' }
end,
}, {})
eng:syncNow()
pump(eng, 3)
T.eq(eng.phase, "error", "the first sync fails")
eng:update(SyncEngine.AUTO_INTERVAL)
pump(eng, 3)
T.eq(eng.phase, "idle", "the auto interval retries and recovers")
end
do
local eng, transport = conflictEngine()
eng:syncNow()
pump(eng)
local sent = #transport.sent
eng:update(SyncEngine.AUTO_INTERVAL * 2)
T.eq(#transport.sent, sent, "a waiting conflict is never auto-synced over")
T.eq(eng.phase, "conflict", "the player still decides")
end
do
local eng, transport = engine({
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {})
eng:noteResumed()
T.eq(#transport.sent, 1, "regaining the app checks the server")
pump(eng)
eng:noteResumed()
T.eq(#transport.sent, 1, "but not twice in quick succession")
end
do
local eng, transport = engine({}, {}, SyncState.defaults())
eng:noteResumed()
T.eq(#transport.sent, 0, "an unlinked engine ignores a resume")
end
do
local state = linkedState()
SyncState.setRev(state, "red/abc", 2, 500)
local eng, transport, saves = engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"red/abc":{"rev":4,"meta":{"savedAt":900}},' ..
'"gold/xyz":{"rev":1,"meta":{"savedAt":900}}}}' },
["GET /sync/save"] = { code = 200,
body = '{"rev":1,"meta":{"savedAt":900},"blob":"return { player = {} }"}' },
}, { saveEntry("red", "abc", 500, 400) }, state)
eng:protectPlaythrough("red", "abc")
eng:syncNow()
pump(eng)
T.eq(#saves.writes, 1, "only the save that is not being played downloads")
T.eq(saves.writes[1].version, "gold", "the other playthrough still arrives")
T.eq(SyncState.rev(eng.state, "red/abc"), 2,
"the live playthrough keeps its rev so the launcher can fetch it later")
T.eq(eng.phase, "idle", "and the sync settles")
eng:protectPlaythrough("red", nil)
T.eq(eng.protectedKey, nil, "no live playthrough means no protection")
end
do do
local eng = engine({ local eng = engine({
["POST /sync/create"] = { code = 200, body = ["POST /sync/create"] = { code = 200, body =
'{"account":"aa11","code1":"11112222","code2":"33334444",' .. '{"account":"aa11","code1":"11112222","code2":"33334444",' ..
'"deviceToken":"tok","device":"0a1b2c3d"}' }, '"deviceToken":"tok","device":"0a1b2c3d"}' },
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
}, {}, SyncState.defaults()) }, {}, SyncState.defaults())
eng:createAccount("laptop") eng:createAccount("laptop")
pump(eng, 3) pump(eng)
T.eq(eng.state.deviceId, "0a1b2c3d", T.eq(eng.state.deviceId, "0a1b2c3d",
"creating an account records the id the server gave this device") "creating an account records the id the server gave this device")
T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d", T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",