diff --git a/docs/new-features.md b/docs/new-features.md index f8d879b7..fc183e2b 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -28,7 +28,7 @@ Features intentionally added to the games themselves, beyond what the original c * **Button remapping** on every game: OPTION -> CONTROLS (keys, pads, L/R on FireRed/LeafGreen/Emerald, fast-forward buttons) * **Full controller support**: both thumbsticks move and navigate menus, and stick directions, triggers, stick clicks and extra SDL buttons can be bound in CONTROLS * **Duplicate a save slot** from the launcher, a one-tap independent copy for backups or trimming a save before export -* **Bank-style Box migration** from Gen 1 and 2 to Gen 3: nature from EXP, three perfect IVs (five for Mew and Celebi), EVs reset, second ability when there is one +* **Bank-style Box migration** from Gen 1 and 2 to Gen 3: nature from EXP, IVs from DVs (Special fills both Sp. Atk and Sp. Def), EVs reset, second ability when there is one * **EXP-to-nature helper** in Box migration, showing how much EXP each nature needs before a Gen 1 or 2 Pokémon moves to Gen 3 * **Box item stash**: store bag items in Box, send them to another game's bag, give or take held items and change a Gen 3 Pokémon's ball * **Unown form picker** in the save editor on Gen 2 and Gen 3, keeping HP DV, nature and shininess where the cart allows diff --git a/src/box/Migration.lua b/src/box/Migration.lua index 1b3723ef..019eeab4 100644 --- a/src/box/Migration.lua +++ b/src/box/Migration.lua @@ -81,29 +81,11 @@ local function pidFor(mon,def,national,dvs,shiny,nature) pidCache[key]=found return found or nil end -local function roller(...) - local state=0x2545F491 - for _,value in ipairs({...}) do - state=bit.bxor(state,bit.tobit(math.floor(tonumber(value) or 0)%4294967296)) - state=bit.bxor(state,bit.lshift(state,13));state=bit.bxor(state,bit.rshift(state,17));state=bit.bxor(state,bit.lshift(state,5)) - end - if state==0 then state=1 end - return function(n) - state=bit.bxor(state,bit.lshift(state,13));state=bit.bxor(state,bit.rshift(state,17));state=bit.bxor(state,bit.lshift(state,5)) - return (state%4294967296)%n - end -end -Migration.PERFECT={[151]=5,[251]=5} -local IV_LABELS={hp="HP",atk="Attack",def="Defense",spe="Speed",spa="Sp. Atk",spd="Sp. Def"} -local function bankIvs(id,national) - local roll=roller(id,national) - local keys={"hp","atk","def","spe","spa","spd"} - for i=#keys,2,-1 do local j=roll(i)+1;keys[i],keys[j]=keys[j],keys[i] end - local ivs,perfect={}, {} - for i,key in ipairs(keys) do - if i<=(Migration.PERFECT[national] or 3) then ivs[key]=31;perfect[#perfect+1]=IV_LABELS[key] else ivs[key]=roll(32) end - end - return ivs,perfect +local function ivsFromDvs(dvs) + dvs=dvs or {} + local function iv(dv) return clamp(dv,0,15)*2+1 end + local special=iv(dvs.special) + return {hp=iv(dvs.hp or Mon.hpDV(dvs)),atk=iv(dvs.attack),def=iv(dvs.defense),spe=iv(dvs.speed),spa=special,spd=special} end local function dvsFor(mon,def,national,generation) local iv=mon.ivs or {} @@ -130,6 +112,43 @@ local function dvsFor(mon,def,national,generation) best.hp=Mon.hpDV(best) return best end +local IV_KEYS={"hp","atk","def","spe","spa","spd"} +local CAUGHT={"caughtTime","caughtLevel","caughtLocation","caughtByGender","caughtData"} +local ORIGIN3={"metLocation","metLevel","metGame","pokeball","otGender","language","ribbons","championRibbon","contest"} +local function lastOrigin(entry,generation) + local archives=type(entry.archives)=="table" and entry.archives or {} + for i=#archives,1,-1 do + local archive=archives[i] + if type(archive)=="table" and archive.generation==generation and type(archive.mon)=="table" then return archive end + end +end +local function sameDvs(a,b) + if type(a)~="table" or type(b)~="table" then return false end + for _,key in ipairs({"attack","defense","speed","special"}) do if a[key]~=b[key] then return false end end + return true +end +local function sameIvs(a,b) + if type(a)~="table" or type(b)~="table" then return false end + for _,key in ipairs(IV_KEYS) do if a[key]~=b[key] then return false end end + return true +end +local function statExpFromEffort(evs) + local out={} + for _,pair in ipairs(KEYS) do + local effort=math.floor(clamp((evs or {})[pair[1]],0,255)/4)*4 + out[pair[2]]=math.max(out[pair[2]] or 0,effort^2) + end + return out +end +local function statExpOf(statExp,key) + statExp=type(statExp)=="table" and statExp or {} + local value=statExp[key] + if value==nil and key=="special" then value=statExp.specialAttack end + return clamp(value,0,65535) +end +local function effortFromStatExp(statExp,key) + return math.min(255,math.ceil(math.sqrt(statExpOf(statExp,key)))) +end local function expAt(data,species,level) if data.generation==3 then return require("src.core.game3.summary_data").expForLevel(data.meta[species].growthRate,level) @@ -218,15 +237,43 @@ function Migration.convert(entry,version,opts) end elseif type(mon.personality)~="number" or type(mon.ivs)~="table" then return nil,"The native personality or IVs are missing." end if g2==3 then - local sourceExp=math.floor(tonumber(mon.exp or mon.experience) or expAt(from,mon.species,d.level)) - local nature=sourceExp%25 - local pid=pidFor(mon,def,national,mon.dvs,Mon.vanillaShiny(mon.dvs),nature) - if not pid then return nil,"No personality can preserve this Pokémon's gender, shininess and form." end - out.personality,out.otSecretId,out.nature=pid,0,nature - local perfect - out.ivs,perfect=bankIvs(entry.id,national) + local home=lastOrigin(entry,3) + local hm=home and home.mon + local shiny=Mon.vanillaShiny(mon.dvs) + local pid,nature,sourceExp + if hm and type(hm.personality)=="number" and hm.otId==out.otId then + local p,wanted=hm.personality,gender2({genderRatio=def.genderRatio},mon.dvs) + if gender3(def.genderRatio,p)==wanted + and Pokemon.isShiny({personality=p,otId=out.otId,otSecretId=hm.otSecretId or 0})==shiny + and (national~=201 or Pokemon.unownLetter(p)+1==Unown.letterFromDVs(mon.dvs)) then + pid,nature=p,p%25 + end + end + if pid then + out.personality,out.otSecretId,out.nature=pid,hm.otSecretId or 0,nature + else + sourceExp=math.floor(tonumber(mon.exp or mon.experience) or expAt(from,mon.species,d.level)) + nature=sourceExp%25 + pid=pidFor(mon,def,national,mon.dvs,shiny,nature) + if not pid then return nil,"No personality can preserve this Pokémon's gender, shininess and form." end + out.personality,out.otSecretId,out.nature=pid,0,nature + end + local restoredIvs + if hm and type(hm.ivs)=="table" then + local input=Store.copy(hm);input._boxVersion=home.version + if sameDvs(dvsFor(input,from.pokemon[mon.species],national,g1),mon.dvs) then restoredIvs=Store.copy(hm.ivs) end + end + out.ivs=restoredIvs or ivsFromDvs(mon.dvs) out.evs={} - for _,pair in ipairs(KEYS) do out.evs[pair[1]]=0 end + local forward=hm and statExpFromEffort(hm.evs) + for _,pair in ipairs(KEYS) do + local key,old=pair[1],hm and type(hm.evs)=="table" and clamp(hm.evs[pair[1]],0,255) or nil + if old==nil then out.evs[key]=0 + elseif statExpOf(mon.statExp,pair[2])==forward[pair[2]] then out.evs[key]=old + else out.evs[key]=math.max(old,effortFromStatExp(mon.statExp,pair[2])) end + end + local total=0;for _,key in ipairs(IV_KEYS) do total=total+out.evs[key] end + if total>510 then for _,key in ipairs(IV_KEYS) do out.evs[key]=math.floor(out.evs[key]*510/total) end end out.friendship=mon.isEgg and clamp(mon.happiness or mon.eggCycles,0,255) or clamp(mon.happiness or 70,0,255) out.eggCycles=mon.isEgg and out.friendship or nil out.heldItem,out.pp=targetItem or 0,pps @@ -234,47 +281,89 @@ function Migration.convert(entry,version,opts) out.markings=entry.markings or 0 local pair=to.abilities and to.abilities[species] or {} out.abilityNum=(pair[2] or 0)~=0 and 1 or 0 - out.language,out.otGender=2,mon.caughtGender or 0 + if hm and (hm.abilityNum==0 or hm.abilityNum==1 and (pair[2] or 0)~=0) then out.abilityNum=hm.abilityNum end + out.language,out.otGender=2,Mon.caughtGenderOf(mon.caughtByGender)=="girl" and 1 or 0 -- include/constants/region_map_sections.h:229 out.metLocation,out.metLevel,out.metGame,out.pokeball=255,out.level,MET_GAME[version],4 + out.modernFatefulEncounter=false + if hm then + for _,key in ipairs(ORIGIN3) do if hm[key]~=nil then out[key]=Store.copy(hm[key]) end end + out.modernFatefulEncounter=hm.modernFatefulEncounter==true + end -- src/battle_util.c:3898 - out.modernFatefulEncounter=national==151 + out.modernFatefulEncounter=out.modernFatefulEncounter or national==151 local ability=pair[out.abilityNum+1] or pair[1] local NATURES=require("src.box.Metadata").NATURES - note("Nature from EXP: "..sourceExp.." % 25 = "..nature..", "..NATURES[nature+1]..".") - lines.natureExp,lines.ivs,lines.perfect=sourceExp,Store.copy(out.ivs),perfect local up,down=math.floor(nature/5)+1,nature%5+1 local short={"Atk","Def","Spe","SpA","SpD"} - fact("Nature",NATURES[nature+1]..(up~=down and " +"..short[up].." -"..short[down] or ""),"new") - fact("Ability",ability and to.abilityNames and label(to.abilityNames[ability],ability) or "First slot","new") - fact("EVs","Reset to 0","lost") - fact("Ball","Poké Ball","new") - fact("Shiny",Mon.vanillaShiny(mon.dvs) and "Yes" or "No","kept") - if out.modernFatefulEncounter then fact("Mew","Obeys in battle","new") end - note("IVs: "..table.concat(perfect,", ").." set to 31; the rest rolled 0 to 31.") - note("EVs reset to 0.") - note("Ability: "..tostring(ability and to.abilityNames and label(to.abilityNames[ability],ability) or "first slot") - ..(out.abilityNum==1 and " (second slot)." or ".")) - note("DV shininess, gender and Unown letter are preserved. Secret ID 0.") - note("Origin: Poké Ball, met level "..out.level..", fateful encounter location." - ..(out.modernFatefulEncounter and " Fateful encounter flag set, so Mew obeys." or "")) + local abilityName=ability and to.abilityNames and label(to.abilityNames[ability],ability) + lines.ivs=Store.copy(out.ivs) + if sourceExp then + note("Nature from EXP: "..sourceExp.." % 25 = "..nature..", "..NATURES[nature+1]..".") + lines.natureExp=sourceExp + else + note("Personality, nature and Secret ID restored from the original Gen 3 record.") + end + fact("Nature",NATURES[nature+1]..(up~=down and " +"..short[up].." -"..short[down] or ""),sourceExp and "new" or "kept") + fact("Ability",abilityName or "First slot",hm and "kept" or "new") + fact("EVs",hm and "Restored" or "Reset to 0",hm and "kept" or "lost") + fact("Ball",hm and "Original" or "Poké Ball",hm and "kept" or "new") + fact("Shiny",shiny and "Yes" or "No","kept") + if out.modernFatefulEncounter and national==151 then fact("Mew","Obeys in battle",hm and "kept" or "new") end + if restoredIvs then note("IVs restored from the original Gen 3 record.") + else note("IVs: each DV doubled plus 1; Special fills both Sp. Atk and Sp. Def.") end + note(hm and "EVs restored from the original Gen 3 record; Stat Exp gained since raises them." or "EVs reset to 0.") + note("Ability: "..tostring(abilityName or "first slot")..(out.abilityNum==1 and " (second slot)." or ".")) + note("DV shininess, gender and Unown letter are preserved."..(hm and "" or " Secret ID 0.")) + if hm then note("Origin: met data, ball, ribbons and contest stats restored from the original Gen 3 record.") + else + note("Origin: Poké Ball, met level "..out.level..", fateful encounter location." + ..(out.modernFatefulEncounter and " Fateful encounter flag set, so Mew obeys." or "")) + end elseif g1==3 then - local input=Store.copy(mon);input._boxVersion=entry.version - local dvs,why=dvsFor(input,def,national,g2) - if not dvs then return nil,why end - out.dvs,out.statExp=dvs,{} - for _,pair in ipairs(KEYS) do - local effort=math.floor(clamp((mon.evs or {})[pair[1]],0,255)/4)*4 - out.statExp[pair[2]]=math.max(out.statExp[pair[2]] or 0,effort^2) + local home=lastOrigin(entry,g2) + local hm=home and home.mon + local dvs,why + if hm and type(hm.dvs)=="table" then + local was=Catalog.describe(home.version,hm) + if was and was.national and sameIvs(ivsFromDvs(hm.dvs),mon.ivs) then + dvs={attack=hm.dvs.attack,defense=hm.dvs.defense,speed=hm.dvs.speed,special=hm.dvs.special} + dvs.hp=Mon.hpDV(dvs) + end + end + local restored=dvs~=nil + if not dvs then + local input=Store.copy(mon);input._boxVersion=entry.version + dvs,why=dvsFor(input,def,national,g2) + if not dvs then return nil,why end + end + out.dvs,out.statExp=dvs,statExpFromEffort(mon.evs) + if hm then + for _,pair in ipairs(KEYS) do + out.statExp[pair[2]]=math.max(out.statExp[pair[2]],statExpOf(hm.statExp,pair[2])) + end end out.happiness=g2==2 and clamp(mon.friendship or mon.happiness or 70,0,255) or nil - out.caughtLevel,out.caughtLocation,out.caughtGender=g2==2 and out.level or nil,g2==2 and 0 or nil,g2==2 and (mon.otGender or 0) or nil - note("IVs → nearest DVs preserving shininess"..(g2==2 and ", gender and Unown letter." or " as a latent DV pattern.")) + if g2==2 then + local met=tonumber(mon.metLevel) or 0 + out.caughtTime,out.caughtLocation=0,0 + out.caughtLevel=math.min(63,met>=1 and math.floor(met) or out.level) + out.caughtByGender=mon.otGender==1 and "girl" or "boy" + if hm then for _,key in ipairs(CAUGHT) do if hm[key]~=nil then out[key]=Store.copy(hm[key]) end end end + end + if restored then + note("DVs restored from the original Gen "..g2.." record.") + else + note("IVs → nearest DVs preserving shininess"..(g2==2 and ", gender and Unown letter." or " as a latent DV pattern.")) + end note(("DVs: Attack %d · Defense %d · Speed %d · Special %d · HP %d"):format(dvs.attack,dvs.defense,dvs.speed,dvs.special,dvs.hp)) - note("EVs → Stat Exp; Special uses the greater Special Attack/Defense effort. HP DV is derived.") + note("EVs → Stat Exp; Special uses the greater Special Attack/Defense effort. HP DV is derived." + ..(hm and " The original Stat Exp is kept where it is higher." or "")) + if g2==2 then note(hm and "Caught data restored from the original Gen 2 record." or "Caught level and trainer gender come from the Gen 3 met data.") end note("Nature, ability, Secret ID, ball, ribbons, contests and Gen 3 origin data remain only in the archive.") lines.dvs={hp=dvs.hp,atk=dvs.attack,def=dvs.defense,spe=dvs.speed,spc=dvs.special} - fact("Stat Exp","From EVs","new") + fact("DVs",restored and "Restored" or "From IVs",restored and "kept" or "new") + fact("Stat Exp",hm and "Restored" or "From EVs",hm and "kept" or "new") fact("Archived","Nature, ability, ball, ribbons","lost") else local prepared=Store.copy(mon);prepared.species=mon.species diff --git a/src/core/game3/battle/switch_seq.lua b/src/core/game3/battle/switch_seq.lua index d6dfb41a..0d62ec3c 100644 --- a/src/core/game3/battle/switch_seq.lua +++ b/src/core/game3/battle/switch_seq.lua @@ -589,6 +589,17 @@ local function wait_busy() SwitchSeq._waiting = true end +local function play_release_cry(st, d) + local side = step_side(d) + local b = step_battler(st, d) + local sp = b and (b.species or (b.mon and (b.mon.species or b.mon.speciesId))) + if sp then + -- pokefirered/src/pokeball.c:782 + local IntroSeq = require("src.core.game3.battle.intro_seq") + Audio.playCry(sp, IntroSeq.releaseCryMode(b.mon), (side == "player") and -25 or 25) + end +end + local function run_step(step) if not step then return end local kind = step.kind @@ -777,6 +788,12 @@ local function run_step(step) if kind == "shiny_check" then local b = step_battler(st, d) + local nextStep = SwitchSeq._steps and SwitchSeq._steps[SwitchSeq._i + 1] + if nextStep and nextStep.kind == "cry" and not nextStep.cryPlayed then + -- pokeemerald/src/pokeball.c:665 + play_release_cry(st, nextStep.data or {}) + nextStep.cryPlayed = true + end if ShinySeq.start(b, d.id ~= nil and d.id or d.side or "enemy", function() advance() end) then @@ -788,14 +805,7 @@ local function run_step(step) end if kind == "cry" then - local side = step_side(d) - local b = step_battler(st, d) - local sp = b and (b.species or (b.mon and (b.mon.species or b.mon.speciesId))) - if sp then - -- pokefirered/src/pokeball.c:782 - local IntroSeq = require("src.core.game3.battle.intro_seq") - Audio.playCry(sp, IntroSeq.releaseCryMode(b.mon), (side == "player") and -25 or 25) - end + if not step.cryPlayed then play_release_cry(st, d) end SwitchSeq._waiting = true SwitchSeq._waitingCry = true return diff --git a/src/core/game3/bg_affine.lua b/src/core/game3/bg_affine.lua index 809a3962..d8751228 100644 --- a/src/core/game3/bg_affine.lua +++ b/src/core/game3/bg_affine.lua @@ -1,12 +1,26 @@ -local ffi = require("ffi") local bit = require("bit") local Affine = {} +local FLT_MAX = 3.4028234663852886e38 + local function f32(x) - return tonumber(ffi.new("float", x)) + if x ~= x or x == 0 or x == math.huge or x == -math.huge then return x end + local m, e = math.frexp(x) + local bits = e < -125 and 24 - (-125 - e) or 24 + if bits < 0 then return 0 * x end + local s = math.ldexp(m, bits) + local r = math.floor(s) + local d = s - r + if d > 0.5 or (d == 0.5 and r % 2 == 1) then r = r + 1 end + local v = math.ldexp(r, e - bits) + if v > FLT_MAX then return math.huge end + if v < -FLT_MAX then return -math.huge end + return v end +Affine.f32 = f32 + local function trunc(x) if x >= 0 then return math.floor(x) end return -math.floor(-x) diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index 18928602..c52bff29 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -178,7 +178,7 @@ function Pokemon.install(cache) if Pokemon._names then log("species pack ready (" .. tostring(Pokemon._manifest and Pokemon._manifest.numSpecies) .. ")") else - log("species pack missing — re-import FireRed ROM") + log("species pack missing: re-import FireRed ROM") end Pokemon._runReloadHooks() end @@ -349,7 +349,7 @@ function Pokemon.expYield(species) return (meta and tonumber(meta.expYield)) or 0 end ---- ROM BaseStats.growthRate — pret GROWTH_* index into gExperienceTables. +--- ROM BaseStats.growthRate: pret GROWTH_* index into gExperienceTables. function Pokemon.growthRate(species) local meta = Pokemon.speciesMeta(species) return (meta and tonumber(meta.growthRate) or 0) % 6 @@ -1433,8 +1433,9 @@ function Pokemon.isShiny(mon) if not mon then return false end if mon.isShiny ~= nil then return not not mon.isShiny end local p = (tonumber(mon.personality) or 0) % 4294967296 - local tid = (tonumber(mon.otId or mon.trainerId) or 0) % 65536 - local sid = (tonumber(mon.otSecretId) or 0) % 65536 + local full = (tonumber(mon.otId or mon.trainerId) or 0) % 4294967296 + local tid = full % 65536 + local sid = (tonumber(mon.otSecretId) or math.floor(full / 65536)) % 65536 local value = bit.bxor(bit.bxor(tid, sid), bit.bxor(math.floor(p / 65536), p % 65536)) return value < 8 end diff --git a/src/core/game3/rse/frontier/tower.lua b/src/core/game3/rse/frontier/tower.lua index c88cb0fe..22f1545c 100644 --- a/src/core/game3/rse/frontier/tower.lua +++ b/src/core/game3/rse/frontier/tower.lua @@ -247,11 +247,13 @@ function Tower.toBattleTowerMon(mon) local ivs, evs = mon.ivs or {}, mon.evs or {} local held = tonumber(mon.heldItem or mon.item) or 0 if held == D.constants():require("items", "ITEM_ENIGMA_BERRY") then held = 0 end + local otId = (tonumber(mon.otId) or 0) % 4294967296 + if mon.otSecretId ~= nil then otId = otId % 65536 + (tonumber(mon.otSecretId) or 0) % 65536 * 65536 end return { species = tonumber(mon.species) or 0, heldItem = held, moves = Util.deepCopy(mon.moves or {}), level = tonumber(mon.level) or 0, ppBonuses = tonumber(mon.ppBonuses) or 0, hpEV = evs.hp or 0, attackEV = evs.atk or 0, defenseEV = evs.def or 0, speedEV = evs.spe or 0, - spAttackEV = evs.spa or 0, spDefenseEV = evs.spd or 0, otId = tonumber(mon.otId) or 0, + spAttackEV = evs.spa or 0, spDefenseEV = evs.spd or 0, otId = otId, hpIV = ivs.hp or 0, attackIV = ivs.atk or 0, defenseIV = ivs.def or 0, speedIV = ivs.spe or 0, spAttackIV = ivs.spa or 0, spDefenseIV = ivs.spd or 0, abilityNum = tonumber(mon.abilityNum) or 0, personality = tonumber(mon.personality) or 0, nickname = mon.nickname or "", diff --git a/src/core/game3/rse/frontier/trainers.lua b/src/core/game3/rse/frontier/trainers.lua index 2f94af60..3b656983 100644 --- a/src/core/game3/rse/frontier/trainers.lua +++ b/src/core/game3/rse/frontier/trainers.lua @@ -205,7 +205,8 @@ function D.createMon(species, level, fixedIV, personality, otId, opts) gender = Pokemon.gender and Pokemon.gender(species, personality) or "U", friendship = (meta and meta.friendship) or 70, happiness = (meta and meta.friendship) or 70, - otId = otId, + otId = otId % 65536, + otSecretId = math.floor(otId / 65536), otName = opts.otName or "", ot = opts.otName or "", otGender = opts.otGender or 0, diff --git a/src/core/game3/summary_data.lua b/src/core/game3/summary_data.lua index 8e0e7082..f2f3c237 100644 --- a/src/core/game3/summary_data.lua +++ b/src/core/game3/summary_data.lua @@ -41,7 +41,7 @@ end --- Growth Rates matching pokefirered/src/data/pokemon/experience_tables.h -- Enum order = pret GROWTH_MEDIUM_FAST..GROWTH_SLOW (species meta.growthRate from ROM). -- Tables hardcode [0]=0, [1]=1 for every rate; formulas apply from level 2 up --- (Medium Slow at n=1 is negative — pret stores 1). +-- (Medium Slow at n=1 is negative, pret stores 1). local function calc_exp(growthRate, n) n = tonumber(n) or 0 if n <= 0 then return 0 end @@ -140,12 +140,13 @@ end function SummaryData.isShiny(mon) if not mon then return false end if mon.isShiny ~= nil then return not not mon.isShiny end - local p = tonumber(mon.personality) or 0 - local otId = tonumber(mon.otId) or 0 - local secretId = tonumber(mon.otSecretId) or 0 + local p = (tonumber(mon.personality) or 0) % 4294967296 + local full = (tonumber(mon.otId) or 0) % 4294967296 + local otId = full % 65536 + local secretId = (tonumber(mon.otSecretId) or math.floor(full / 65536)) % 65536 - local pHigh = bit.rshift(p, 16) - local pLow = bit.band(p, 0xFFFF) + local pHigh = math.floor(p / 65536) + local pLow = p % 65536 local trainerXor = bit.bxor(otId, secretId) local pidXor = bit.bxor(pHigh, pLow) return bit.bxor(trainerXor, pidXor) < 8 diff --git a/src/import/BoxUI.lua b/src/import/BoxUI.lua index 0225a32d..3f78df1d 100644 --- a/src/import/BoxUI.lua +++ b/src/import/BoxUI.lua @@ -22,7 +22,7 @@ UI.HELP = { Backup = "restores Box and its linked saves together. your current collection gets backed up first. older incomplete backups won’t restore.", Events = "tickets for event trips in the linked game. collect the ticket, then use the normal ship route. story requirements still apply.", Migration = "same pokémon, new generation. check the changes first. the original stays archived so you can switch back.", - MigrationPolicy = "Gen 2 and Gen 3 never had an official transfer route. G1R converts what fits and blocks what doesn’t. Gen 1 and 2 to Gen 3 follows Pokémon Bank: nature from EXP, three perfect IVs, EVs reset, Poké Ball.", + MigrationPolicy = "Gen 2 and Gen 3 never had an official transfer route. G1R converts what fits and blocks what doesn’t. Gen 1 and 2 to Gen 3 follows Pokémon Bank: nature from EXP, IVs from DVs, EVs reset, Poké Ball.", Items = "store bag items here, then send them to another game's bag. gen 1 and 2 share a stash. changing a ball uses one up.", } local HELP_TITLES = { Box = "Box storage", diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index bbb09e4c..c757695e 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -429,13 +429,13 @@ function TextBox:arrowVisible() if self.sfxWait then return false end if self.waiting then return true end -- ../pokecrystal/home/text.asm:566 DoneText - -- pokered home/text_script.asm:96 -> home/joypad2.asm:71-72 - if self.waitButton and self:isGold() then return false end + -- pokered home/text_script.asm:96 -> home/joypad2.asm:60-61, home/window.asm:247-250 + local autoPrompt = self.auto and self.auto.promptFirst and not self.autoPrompted + local stayPrompt = self.stay and self.stay.prompt and not self.stayShown + if self.waitButton and not (autoPrompt or stayPrompt) then return false end return not not (self.done and not self.choice - and (not self.auto - or (self.auto.promptFirst and not self.autoPrompted)) - and (not self.stay - or (self.stay.prompt and not self.stayShown))) + and (not self.auto or autoPrompt) + and (not self.stay or stayPrompt)) end -- home/text.asm:209 diff --git a/src/ui/game3/rse/pokedex.lua b/src/ui/game3/rse/pokedex.lua index 5cec291b..9f37106a 100644 --- a/src/ui/game3/rse/pokedex.lua +++ b/src/ui/game3/rse/pokedex.lua @@ -2584,7 +2584,7 @@ function Pokedex.draw(s) end if page == PAGE.CRY and s.cryNeedle and not ex.select then love.graphics.draw(s.cryNeedle, 184 + (s.cryNeedleX or 0), 80 + (s.cryNeedleY or 0), - (s.cry and s.cry.needle.rotation or 0) * 2 * math.pi / 256, 1, 1, 32, 32) + -(s.cry and s.cry.needle.rotation or 0) * 2 * math.pi / 256, 1, 1, 32, 32) end end end diff --git a/src/ui/game3/trainer_card.lua b/src/ui/game3/trainer_card.lua index 529b8280..7ea23238 100644 --- a/src/ui/game3/trainer_card.lua +++ b/src/ui/game3/trainer_card.lua @@ -589,16 +589,24 @@ local function gatherRse(session) local save = { version = session.version, dex = session.dex, flags = store and store.flags or session.flags, vars = store and store.vars or session.vars } c.caughtMonsCount = Dex.summaryCount(save) - -- pokeemerald/src/trainer_card.c:719 - local hoennMax = Dex.regionalMax(session.version) - allHoenn = hoennMax > 0 - for n = 1, hoennMax do - local nat = nil - for sp, on in pairs(type(session.dex) == "table" and (session.dex.caught or session.dex.owned) or {}) do - if on and Dex.regionalNumber(tonumber(sp) or 0, session.version) == n then nat = sp break end - end - if not nat then allHoenn = false break end + -- pokeemerald/src/pokedex.c:4387 + local have = {} + for sp, on in pairs(type(session.dex) == "table" and (session.dex.caught or session.dex.owned) or {}) do + local n = on and on ~= 0 and Dex.regionalNumber(tonumber(sp) or 0, session.version) + if n then have[n] = true end end + local need = Dex.regionalMax(session.version) - 2 + allHoenn = need > 0 + for n = 1, need do + if not have[n] then allHoenn = false break end + end + end + -- pokeemerald/src/trainer_card.c:745 + if carried then + c.hasAllPaintings = session.hasAllPaintings == true + else + local ContestUtil = require("src.core.game3.rse.contest_util") + c.hasAllPaintings = ContestUtil.countPlayerMuseumPaintings(session) >= 5 end c.pokeblocksWithFriends = capped_stat(session, 34, "pokeblocksWithFriends", 0xFFFF) c.contestsWithFriends = capped_stat(session, 35, "contestsWithFriends", 999) @@ -617,7 +625,7 @@ local function gatherRse(session) local stars = 0 if c.hasHofResult then stars = stars + 1 end if allHoenn then stars = stars + 1 end - if session.hasAllPaintings then stars = stars + 1 end + if c.hasAllPaintings then stars = stars + 1 end if symbols then stars = stars + 1 end c.stars = math.min(4, carried and tonumber(session.stars) or stars) c.cardType = "emerald" @@ -767,6 +775,7 @@ function TrainerCard.backTexts(c) end TrainerCard.cardData = gather +TrainerCard.rseCardData = gatherRse local function draw_texts(list) for _, e in ipairs(list) do diff --git a/tests/drivers/done_arrow_bug2793.lua b/tests/drivers/done_arrow_bug2793.lua new file mode 100644 index 00000000..bf129236 --- /dev/null +++ b/tests/drivers/done_arrow_bug2793.lua @@ -0,0 +1,80 @@ +-- home/text.asm:209 PromptText, :221 DoneText; home/joypad2.asm:55; home/window.asm:225 + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(msg, cond) + if cond then print("PASS " .. msg) else pass = false; print("FAIL " .. msg) end + end + local function topBox() + local top = game.stack:top() + return getmetatable(top) == TextBox and top or nil + end + local function finish() + print(pass and "PASS all" or "FAIL see above") + love.event.quit(pass and 0 or 1) + U.wait(600) + end + local function waitFor(tb, fn, mash) + for i = 1, 600 do + if fn(tb) then return true end + if mash and tb.waiting and i % 6 == 0 then U.tap(game, "a") end + U.wait(1) + end + return false + end + local function close(tb) + for _ = 1, 30 do + if game.stack:top() ~= tb then return end + U.tap(game, "a") + U.wait(4) + end + end + + U.teleport(game, "PALLET_TOWN", 7, 10, "up") + U.wait(10) + + local fedUp = game.data.text._OaksLabRivalFedUpWithWaitingText or "" + check("rival fed-up text ends in done", fedUp:find("{DONE}%s*$") ~= nil) + local tb = TextBox.new(game, fedUp) + game.stack:push(tb) + check("fed-up text reaches its cont wait", waitFor(tb, function(b) return b.waiting end)) + check("cont wait shows the arrow", tb:arrowVisible()) + tb.blink = 0 + U.still(game, DIR .. "/2793_01_fedup_cont_arrow.png") + check("fed-up text finishes", waitFor(tb, function(b) return b.done end, true)) + U.wait(2) + check("done end of fed-up text has no arrow", not tb:arrowVisible()) + tb.blink = 0 + U.still(game, DIR .. "/2793_02_fedup_done_no_arrow.png") + close(tb) + + local great = game.data.text._OaksLabRivalAmIGreatOrWhatText or "" + check("am-I-great text ends in prompt", great:find("{PROMPT}%s*$") ~= nil) + tb = TextBox.new(game, great) + game.stack:push(tb) + check("prompt text finishes", waitFor(tb, function(b) return b.done end, true)) + U.wait(2) + check("prompt end shows the arrow", tb:arrowVisible()) + tb.blink = 0 + U.still(game, DIR .. "/2793_03_prompt_arrow.png") + close(tb) + + U.wait(10) + U.tap(game, "a") + U.wait(10) + tb = topBox() + check("pallet sign opened a text box", tb ~= nil) + if not tb then return finish() end + check("sign text finishes", waitFor(tb, function(b) return b.done end, true)) + U.wait(2) + check("sign done end has no arrow", not tb:arrowVisible()) + tb.blink = 0 + U.still(game, DIR .. "/2793_04_sign_done_no_arrow.png") + close(tb) + + finish() +end diff --git a/tests/drivers/em_cry_needle_2795.lua b/tests/drivers/em_cry_needle_2795.lua new file mode 100644 index 00000000..ef3c083d --- /dev/null +++ b/tests/drivers/em_cry_needle_2795.lua @@ -0,0 +1,97 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_cry_needle_2795" + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + print((failures == 0 and "PASS" or "FAIL") .. " em_cry_needle_2795 failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end + +local function loadShot(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png")) +end + +local function needleAt(img, x, y) + local k = math.floor(math.min(img:getWidth() / 240, img:getHeight() / 160)) + local ox, oy = (img:getWidth() - 240 * k) / 2, (img:getHeight() - 160 * k) / 2 + local r, g, b = img:getPixel(math.floor(ox + (x + 0.5) * k), math.floor(oy + (y + 0.5) * k)) + return r > 0.8 and g < 0.6 and b > 0.3 and b < 0.7 +end + +local function countAlong(img, x0, y0, x1, y1) + local n = 0 + for i = 0, 10 do + local t = i / 10 + if needleAt(img, x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) then n = n + 1 end + end + return n +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not check(game.boot ~= nil, "boot reached") then return finish() end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 }) + U.wait(30) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Dex = require("src.core.game3.dex") + local Pokedex = require("src.ui.game3.rse.pokedex") + local C = require("src.core.game3.constants").of("emerald") + local session = Runtime.getSession() + if not check(session ~= nil, "new game session") then return finish() end + Map.load(nil, game, "EM_ROUTE101", { x = 10, y = 10, facing = "down" }) + U.wait(40) + session.dex = session.dex or Dex.new() + Dex.setSeen(session.dex, C.species.byName.SPECIES_TORCHIC) + Dex.setCaught(session.dex, C.species.byName.SPECIES_TORCHIC) + + local function waitFn(name, limit) + for _ = 1, limit or 600 do + local s = Pokedex.active() + if s and s.fn == name then return true end + U.wait(1) + end + return false + end + + Pokedex.show(session.dex, { session = session }) + local s = Pokedex.active() + if not check(s ~= nil and waitFn("mainInput", 300), "pokedex list") then return finish() end + for _ = 1, 20 do + if s.list.items[s.selected] and s.list.items[s.selected].owned then break end + U.hold(game, "down", 1) + U.wait(20) + end + U.tap(game, "a") + if not check(waitFn("infoInput", 400), "info page") then return finish() end + U.tap(game, "right") + U.wait(10) + U.tap(game, "a") + if not check(waitFn("cryInput", 400), "cry page") then return finish() end + U.wait(60) + check(s.cry and s.cry.needle.rotation == 32, "needle rests at MIN_NEEDLE_POS 32") + local path = DIR .. "/2795_01_needle_rest.png" + U.still(game, path) + local img = loadShot(path) + if check(img ~= nil, "shot readable") then + local left = countAlong(img, 158, 52, 180, 74) + local right = countAlong(img, 188, 74, 210, 52) + print(string.format("[driver] needle pixels up-left=%d up-right=%d", left, right)) + check(left >= 8, "needle leans up-left from its foot") + check(right <= 2, "nothing on the mirrored up-right diagonal") + end + finish() +end diff --git a/tests/drivers/text_arrow_bug2116_test.lua b/tests/drivers/text_arrow_bug2116_test.lua index 26a74960..48275b26 100644 --- a/tests/drivers/text_arrow_bug2116_test.lua +++ b/tests/drivers/text_arrow_bug2116_test.lua @@ -55,18 +55,17 @@ return function(game) U.wait(4) end check("the text finished", tb.done == true) - -- home/text_script.asm:96 -> home/joypad2.asm:71-72 - check("a `done` text still blinks the arrow", tb:arrowVisible()) + -- home/text_script.asm:96 -> home/joypad2.asm:60-61, home/window.asm:247-250 + check("a `done` text ends with no arrow", not tb:arrowVisible()) -- home/joypad2.asm:90-92 check("and closes without SFX_PRESS_AB", tb.waitButton == true) tb.blink = 0 - U.shot(game, DIR .. "/bug2116_2_done_arrow_no_beep.png") + U.shot(game, DIR .. "/bug2116_2_done_no_arrow_no_beep.png") U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") U.log("bug2116_1_cont_arrow.png: the blinking triangle sits on the same") U.log("row as the second line of text, not jammed on the bottom border.") - U.log("bug2116_2_done_arrow_no_beep.png: the final page of a `done` text") - U.log("still shows the triangle -- WaitForTextScrollButtonPress blinks it") - U.log("for every Gen 1 text; only the A-press beep is missing.") + U.log("bug2116_2_done_no_arrow_no_beep.png: the final page of a `done` text") + U.log("shows no triangle and closes without the A-press beep.") U.log("Shots are in " .. DIR) end diff --git a/tests/engine/box_bank_items_test.lua b/tests/engine/box_bank_items_test.lua index df260965..f91db426 100644 --- a/tests/engine/box_bank_items_test.lua +++ b/tests/engine/box_bank_items_test.lua @@ -72,20 +72,16 @@ do local e=entry("gold",7) local a,lines=assert(Migration.convert(e,"firered")) local b=assert(Migration.convert(e,"firered")) - T.eq(Serializer.encode(a.ivs),Serializer.encode(b.ivs),"IV roll is deterministic for one record") - local perfect=0 - for _,iv in pairs(a.ivs) do - T.check(iv>=0 and iv<=31,"IV within 0-31") - if iv==31 then perfect=perfect+1 end - end - T.check(perfect>=3,"at least three perfect IVs") + T.eq(Serializer.encode(a.ivs),Serializer.encode(b.ivs),"IVs are deterministic for one record") + local want={hp=29,atk=15,def=7,spe=19,spa=9,spd=9} + for key,iv in pairs(want) do T.eq(a.ivs[key],iv,"IV "..key.." is DV*2+1") end for key,ev in pairs(a.evs) do T.eq(ev,0,"EV "..key.." reset") end T.eq(a.abilityNum,1,"species with a second ability takes the second slot") T.eq(a.pokeball,4,"Poké Ball") T.eq(a.modernFatefulEncounter,false,"ordinary species has no fateful flag") T.eq(lines.natureExp,125500,"preview exposes source EXP for the nature helper") local other=assert(Migration.convert(entry("gold",8),"firered")) - T.check(Serializer.encode(other.ivs)~=Serializer.encode(a.ivs),"different records roll different IVs") + T.eq(Serializer.encode(other.ivs),Serializer.encode(a.ivs),"same DVs give the same IVs on any record") local shiny=entry("gold",9);shiny.mon.dvs={attack=15,defense=10,speed=10,special=10} local s=assert(Migration.convert(shiny,"emerald")) T.check(require("src.core.game3.pokemon").isShiny(s),"DV shininess survives with an EXP nature") @@ -93,8 +89,7 @@ end for _,case in ipairs({{"red","MEW",151},{"gold","MEW",151},{"gold","CELEBI",251}}) do local out=assert(Migration.convert(entry(case[1],3,case[2]),"emerald")) - local perfect=0;for _,iv in pairs(out.ivs) do if iv==31 then perfect=perfect+1 end end - T.check(perfect>=5,case[2].." from "..case[1].." gets five perfect IVs") + T.eq(out.ivs.spa,out.ivs.spd,case[2].." from "..case[1].." copies Special into both Sp. stats") T.eq(out.abilityNum,0,case[2].." keeps its only ability") T.eq(out.modernFatefulEncounter,case[3]==151,case[2].." fateful flag matches the Mew obedience check") end diff --git a/tests/engine/box_migration_roundtrip_test.lua b/tests/engine/box_migration_roundtrip_test.lua new file mode 100644 index 00000000..e450409a --- /dev/null +++ b/tests/engine/box_migration_roundtrip_test.lua @@ -0,0 +1,91 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Migration=require("src.box.Migration") +local Store=require("src.box.Store") +local Catalog=require("src.box.Catalog") +local Serializer=require("src.core.SaveSerializer") +local CacheFs=require("src.import.CacheFs") +local GameVersion=require("src.core.GameVersion") +local oldRead,tables=CacheFs.readAt,{} +local function put(v,p,value) tables[GameVersion.cachePrefix(v)..p]=Serializer.encode(value) end +for _,v in ipairs(GameVersion.ORDER) do + local gen=GameVersion.generation(v) + if gen==3 then + local root="data/generated/gba/pokemon/" + put(v,root.."names.lua",{[25]="PIKACHU"}) + put(v,root.."national.lua",{toNational={[25]=25},toSpecies={[25]=25}}) + put(v,root.."meta.lua",{[25]={genderRatio=127,growthRate=0}}) + put(v,root.."stats.lua",{[25]={hp=35,atk=55,def=40,spe=90,spa=50,spd=50}}) + put(v,root.."move_names.lua",{[84]="THUNDERSHOCK"}) + put(v,root.."battle_moves.lua",{moves={[84]={pp=30}}}) + put(v,root.."abilities.lua",{[25]={9}}) + put(v,"data/generated/gba/items/pack.lua",{items={[68]={name="RARE CANDY"}}}) + else + put(v,"data/generated/pokemon.lua",{PIKACHU={id="PIKACHU",name="PIKACHU",dex=25,growthRate="MEDIUM_FAST",genderRatio=127,catchRate=190, + baseStats={hp=35,attack=55,defense=30,speed=90,special=50,specialAttack=50,specialDefense=40}}}) + put(v,"data/generated/moves.lua",{THUNDERSHOCK={name="THUNDERSHOCK",pp=30}}) + put(v,"data/generated/items.lua",{RARE_CANDY={name="RARE CANDY"}}) + end +end +CacheFs.readAt=function(path) return tables[path] end;Catalog.reset() + +local function hop(state,version) + local refs={{box=1,slot=1}} + local preview,why=Migration.preview(state,refs,version) + T.check(preview~=nil and preview.allowed,"preview to "..version..": "..tostring(why or preview and preview.rows[1].lines[1])) + local nextState=assert(Migration.apply(state,refs,version,preview)) + return nextState,nextState.boxes[1].mons[1] +end +local function stateWith(version,mon) + local state=Store.new();state.nextId=2 + state.boxes[1].mons[1]={id=1,version=version,generation=GameVersion.generation(version),mon=mon,display=Catalog.describe(version,mon)} + return state +end + +do + local dvs={attack=15,defense=8,speed=11,special=3} + local statExp={hp=3025,attack=2704,defense=2900,speed=3100,special=2500} + local mon={species="PIKACHU",nickname="PIKA",ot="ALICE",otName="ALICE",otId=42,level=20,experience=8000,happiness=120, + dvs=Store.copy(dvs),statExp=Store.copy(statExp),caughtTime=2,caughtLevel=10,caughtLocation=0x23,caughtByGender="girl", + moves={{id="THUNDERSHOCK",pp=30,ppUps=0,maxPp=30}}} + local state=stateWith("crystal",mon) + local emeraldState,em=hop(state,"emerald") + T.eq(em.mon.otGender,1,"Gen 2 caught-by-girl becomes Gen 3 OT gender") + local _,back=hop(emeraldState,"crystal") + for key,value in pairs(dvs) do T.eq(back.mon.dvs[key],value,"Crystal → Emerald → Crystal keeps the "..key.." DV") end + for key,value in pairs(statExp) do T.eq(back.mon.statExp[key],value,"Crystal → Emerald → Crystal keeps "..key.." Stat Exp") end + T.eq(back.mon.caughtTime,2,"round trip keeps caught time") + T.eq(back.mon.caughtLevel,10,"round trip keeps caught level") + T.eq(back.mon.caughtLocation,0x23,"round trip keeps caught location") + T.eq(back.mon.caughtByGender,"girl","round trip keeps caught-by gender") + + local trained=Store.copy(emeraldState) + trained.boxes[1].mons[1].mon.evs.atk=200 + local _,raised=hop(trained,"crystal") + T.eq(raised.mon.statExp.attack,200*200,"Gen 3 training raises archived Stat Exp") + T.eq(raised.mon.statExp.speed,3100,"untrained stats keep archived Stat Exp") +end + +do + local mon={species=25,nickname="PIKA",ot="ALICE",otName="ALICE",otId=42,otSecretId=777,level=20,exp=8000,friendship=120, + personality=0x1234567,ivs={hp=30,atk=29,def=7,spe=18,spa=24,spd=3},evs={hp=40,atk=100,def=8,spe=60,spa=0,spd=12}, + metLocation=16,metLevel=5,metGame=3,pokeball=2,otGender=1,language=2,ribbons=0,contest={cool=10,beauty=0,cute=0,smart=0,tough=0,sheen=3}, + abilityNum=0,moves={84},pp={30},ppBonusesPacked=0,heldItem=0} + local state=stateWith("emerald",mon) + local crystalState,cr=hop(state,"crystal") + T.eq(cr.mon.caughtLevel,5,"Gen 3 met level becomes Gen 2 caught level") + T.eq(cr.mon.caughtByGender,"girl","Gen 3 OT gender becomes Gen 2 caught-by gender") + local _,back=hop(crystalState,"emerald") + T.eq(back.mon.personality,mon.personality,"Emerald → Crystal → Emerald keeps personality") + T.eq(back.mon.otSecretId,777,"round trip keeps Secret ID") + for key,value in pairs(mon.ivs) do T.eq(back.mon.ivs[key],value,"round trip keeps the "..key.." IV") end + for key,value in pairs(mon.evs) do T.eq(back.mon.evs[key],value,"round trip keeps the "..key.." EV") end + for _,key in ipairs({"metLocation","metLevel","metGame","pokeball","otGender"}) do + T.eq(back.mon[key],mon[key],"round trip keeps "..key) + end + T.eq(back.mon.contest.cool,10,"round trip keeps contest stats") +end + +CacheFs.readAt=oldRead;Catalog.reset() +T.finish("Box migration round trip") diff --git a/tests/engine/game3_bg_affine_noffi_test.lua b/tests/engine/game3_bg_affine_noffi_test.lua new file mode 100644 index 00000000..f0106083 --- /dev/null +++ b/tests/engine/game3_bg_affine_noffi_test.lua @@ -0,0 +1,40 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local okFfi, ffi = pcall(require, "ffi") + +package.loaded["src.core.game3.bg_affine"] = nil +local savedFfi = package.loaded.ffi +package.loaded.ffi = nil +table.insert(package.loaders, 1, function(name) + if name == "ffi" then error("module 'ffi' not found") end +end) +local ok, Affine = pcall(require, "src.core.game3.bg_affine") +table.remove(package.loaders, 1) +package.loaded.ffi = savedFfi + +check(ok, "bg_affine loads on a runtime without ffi: " .. tostring(not ok and Affine or "")) + +if ok then + eq(Affine.f32(0.1), 0.100000001490116119384765625, "f32 rounds 0.1 to the nearest float") + eq(Affine.f32(16777217), 16777216, "f32 ties to even at 2^24 + 1") + eq(Affine.f32(16777219), 16777220, "f32 ties to even at 2^24 + 3") + eq(Affine.f32(1e-46), 0, "f32 flushes below the smallest subnormal") + eq(Affine.f32(1e39), math.huge, "f32 overflows to inf") + if okFfi then + local bad = 0 + math.randomseed(7) + for _ = 1, 20000 do + local x = (math.random() - 0.5) * 2 ^ math.random(-150, 130) + if Affine.f32(x) ~= tonumber(ffi.new("float", x)) then bad = bad + 1 end + end + eq(bad, 0, "f32 matches a C float cast") + end + local r = Affine.objAffineSet(256, 256, 32 * 256) + eq(r.a, 181, "ObjAffineSet a at 45 degrees") + eq(r.b, -181, "ObjAffineSet b at 45 degrees") +end + +T.finish() diff --git a/tests/engine/gen1_done_arrow_bug2793.lua b/tests/engine/gen1_done_arrow_bug2793.lua new file mode 100644 index 00000000..3edae326 --- /dev/null +++ b/tests/engine/gen1_done_arrow_bug2793.lua @@ -0,0 +1,119 @@ +-- home/text.asm:209 PromptText, :221 DoneText; home/joypad2.asm:55; home/window.asm:225 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TextBox = require("src.render.TextBox") +local Sound = require("src.core.Sound") + +local realPress, realPlay = Sound.playPress, Sound.play +Sound.playPress = function() return nil end +Sound.play = function() return nil end + +local function newGame(version, generation) + local game = { + save = { player = {}, options = { textSpeed = "FAST" }, + generation = generation or 1, version = version }, + data = { text = {} }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + return game +end + +local function run(game, box, untilFn, press) + local frames = 0 + while not untilFn(box) and frames < 2000 do + game.input.queue.a = press and box.waiting and frames % 2 == 0 or nil + box:update(1 / 60) + frames = frames + 1 + end + game.input.queue.a = nil + return frames < 2000 +end + +local function finished(text, version, opts) + local game = newGame(version) + local box = TextBox.new(game, text, nil, opts) + game.stack:push(box) + check(run(game, box, function(b) return b.done end, true), "the text types out") + return box, game +end + +-- text/OaksLab.asm:187 +local FED_UP = "{RIVAL}: Gramps!\nI'm fed up with\vwaiting!{DONE}" + +for _, version in ipairs({ "red", "blue", "yellow" }) do + local box = finished(FED_UP, version) + check(not box:arrowVisible(), version .. ": a `done` text ends with no arrow") +end + +for _, version in ipairs({ "red", "yellow" }) do + local box = finished("Take your time.{PROMPT}", version) + check(box:arrowVisible(), version .. ": a `prompt` text ends with the arrow") +end + +do + local box = finished("Hi") + check(box:arrowVisible(), "an untagged text keeps the arrow") +end + +do + local game = newGame("red") + local box = TextBox.new(game, FED_UP, nil) + game.stack:push(box) + check(run(game, box, function(b) return b.waiting or b.done end), + "the cont line is reached") + check(box.waiting and box.contAdvance, "the `cont` waits before scrolling") + check(box:arrowVisible(), "and shows the arrow there (_ContText)") +end + +do + local game = newGame("red") + local box = TextBox.new(game, "First\npage.\fSecond\npage.{DONE}", nil) + game.stack:push(box) + check(run(game, box, function(b) return b.waiting or b.done end), + "the para break is reached") + check(box.waiting and not box.contAdvance, "the `para` waits before clearing") + check(box:arrowVisible(), "and shows the arrow there (Paragraph)") + game.input.queue.a = true + box:update(1 / 60) + game.input.queue.a = nil + check(run(game, box, function(b) return b.done end, true), "the last page types out") + check(not box:arrowVisible(), "and the `done` end has no arrow") +end + +do + local box = finished("Hi{DONE}", "red", { stay = { prompt = true } }) + check(box:arrowVisible(), "stay.prompt keeps its arrowed wait on a `done` text") +end + +do + local box = finished("Hi{DONE}", "red", + { auto = { wait = false, delay = 0, promptFirst = true } }) + check(box:arrowVisible(), "auto.promptFirst keeps its arrowed wait too") +end + +do + local game = newGame("gold", 2) + local box = TextBox.new(game, "Hi{DONE}", nil) + game.stack:push(box) + check(run(game, box, function(b) return b.done end), "gold text types out") + check(not box:arrowVisible(), "gold `done` stays arrowless") +end + +Sound.playPress, Sound.play = realPress, realPlay + +T.finish("gen1 done arrow bug2793") diff --git a/tests/engine/gen1_text_terminator_bug2116.lua b/tests/engine/gen1_text_terminator_bug2116.lua index 9cd99950..81adf6f7 100644 --- a/tests/engine/gen1_text_terminator_bug2116.lua +++ b/tests/engine/gen1_text_terminator_bug2116.lua @@ -98,8 +98,8 @@ end do local box, beeps = finished("SEA COTTAGE\nBILL lives here!{DONE}") eq(box.waitButton, true, "an extracted `done` text is a WaitButton box") - -- home/text_script.asm:96 -> home/joypad2.asm:71-72 - check(box:arrowVisible(), "and still blinks the arrow WaitForTextScrollButtonPress draws") + -- home/text_script.asm:96 -> home/joypad2.asm:60-61, home/window.asm:247-250 + check(not box:arrowVisible(), "and WaitForTextScrollButtonPress never shows the arrow") eq(select(2, box:arrowPos()), 128, "at hlcoord 18,16") -- SFX_PRESS_AB lives only in ManualTextScroll, home/joypad2.asm:90-92 eq(beeps, 0, "and closes silently") diff --git a/tests/game3_frontier_shiny_otid_test.lua b/tests/game3_frontier_shiny_otid_test.lua new file mode 100644 index 00000000..ae509dfd --- /dev/null +++ b/tests/game3_frontier_shiny_otid_test.lua @@ -0,0 +1,108 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local bit = require("bit") +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local SummaryData = require("src.core.game3.summary_data") +local Pokemon = require("src.core.game3.pokemon") +local ShinySeq = require("src.core.game3.battle.shiny_seq") + +-- pokeemerald/include/pokemon.h:371 +local function cartShiny(otId, personality) + local v = bit.bxor(bit.bxor(math.floor(otId / 65536), otId % 65536), + bit.bxor(math.floor(personality / 65536), personality % 65536)) + return v < 8 +end + +local OT = 0x9ABC * 65536 + 12345 +local plain = { personality = 0x00010002, otId = OT } +eq(cartShiny(OT, plain.personality), false, "fixture is a non-shiny cart mon") +eq(SummaryData.isShiny(plain), false, "32-bit otId with secret id >= 0x8000 is not shiny in the summary check") +eq(ShinySeq.isShiny({ mon = plain }), false, "send-out shiny check rejects a non-shiny 32-bit otId mon") +eq(Pokemon.isShiny(plain), false, "Pokemon.isShiny rejects a non-shiny 32-bit otId mon") + +local hi, lo = 0x9ABC, 12345 +local shinyP = bit.bxor(hi, lo) * 65536 + 0 +shinyP = shinyP % 4294967296 +eq(cartShiny(OT, shinyP), true, "fixture is a shiny cart mon") +eq(SummaryData.isShiny({ personality = shinyP, otId = OT }), true, "summary check reads the secret id from the otId high half") +eq(Pokemon.isShiny({ personality = shinyP, otId = OT }), true, "Pokemon.isShiny reads the secret id from the otId high half") +eq(SummaryData.isShiny({ personality = shinyP, otId = lo, otSecretId = hi }), true, "split otId stays shiny") + +do + local SwitchSeq = require("src.core.game3.battle.switch_seq") + local Anim = require("src.core.game3.battle.anim") + local Audio = require("src.core.game3.audio") + local oldCry, oldPlaying = Audio.playCry, Audio.isCryPlaying + local cryFrame + local frame = 0 + Audio.playCry = function() cryFrame = cryFrame or frame end + Audio.isCryPlaying = function() return false end + Anim.reset({ headless = false }) + local mon = { species = 25, isShiny = true } + SwitchSeq.reset() + SwitchSeq._st = { enemy = { side = "enemy", species = 25, hp = 10, mon = mon } } + SwitchSeq._steps = { + monAnim = true, + { kind = "shiny_check", data = { side = "enemy" } }, + { kind = "cry", data = { side = "enemy" } }, + } + SwitchSeq._i = 1 + local doneFrame + for _ = 1, 600 do + frame = frame + 1 + if SwitchSeq.update() then doneFrame = frame break end + Anim.update(1 / 60) + end + Audio.playCry, Audio.isCryPlaying = oldCry, oldPlaying + check(doneFrame ~= nil, "shiny switch-in sequence completes") + eq(cryFrame, 1, "release cry plays when the shiny sparkle starts, not after it") + check(doneFrame > 60, "sequence still waits for the sparkle before moving on") +end + +local GameVersion = require("src.core.GameVersion") +require("src.core.game3.profile").reset() +GameVersion.set("emerald") +local Dataset = require("src.core.game3.dataset") +local cache = Dataset.cache() +local src = cache and cache.read and cache:read("data/generated/gba/frontier/trainers.lua") +if type(src) ~= "string" then + if T.failures > 0 then T.finish("game3_frontier_shiny_otid") end + print("[skip] game3_frontier_shiny_otid_test: no Emerald frontier cache") + os.exit(0) +end +require("src.import.gba.versions").select("emerald") +Pokemon.install(nil) +local C = require("src.core.game3.constants").of("emerald") +local D = require("src.core.game3.rse.frontier.trainers") +local Tower = require("src.core.game3.rse.frontier.tower") + +local species = C:require("species", "SPECIES_PIKACHU") +local mismatches, shinies = 0, 0 +for i = 0, 199 do + local p = (i * 2654435761) % 4294967296 + local m = D.createMon(species, 30, 0, p, OT) + local want = cartShiny(OT, p) + if want then shinies = shinies + 1 end + if ShinySeq.isShiny({ mon = m }) ~= want then mismatches = mismatches + 1 end +end +eq(mismatches, 0, "frontier mons built with the player's 32-bit otId match the cart shiny check") +local m = D.createMon(species, 30, 0, 7, OT) +eq(m.otId, lo, "frontier mon keeps the 16-bit trainer id in otId") +eq(m.otSecretId, hi, "frontier mon keeps the secret id in otSecretId") +eq(Tower.toBattleTowerMon(m).otId, OT, "battle tower record keeps the full 32-bit otId") + +local Tents = require("src.core.game3.rse.frontier.tents") +local F = D.FACILITY +local tent = D.tentPack and D.tentPack(F.FACTORY) +if tent and tent.mons then + local ids = {} + for id in pairs(tent.mons) do ids[#ids + 1] = id if #ids == 3 then break end end + local sess = { version = "emerald", trainerId = lo, secretId = hi, frontierTempParty = ids } + local party = Tents.factoryTentParty(sess) + for i, mon in ipairs(party) do + eq(ShinySeq.isShiny({ mon = mon }), cartShiny(OT, mon.personality), "slateport tent opponent " .. i .. " shiny matches the cart") + end +end +T.finish("game3_frontier_shiny_otid") diff --git a/tests/game3_trainer_card_stars_emerald_test.lua b/tests/game3_trainer_card_stars_emerald_test.lua new file mode 100644 index 00000000..7131c770 --- /dev/null +++ b/tests/game3_trainer_card_stars_emerald_test.lua @@ -0,0 +1,100 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) +end + +local HOENN = 202 +package.loaded["src.core.game3.dex"] = { + regionalMax = function() return HOENN end, + regionalNumber = function(sp) + sp = tonumber(sp) + if sp and sp >= 1 and sp <= HOENN then return sp end + return nil + end, + summaryCount = function(save) + local n = 0 + for _, on in pairs(save.dex and save.dex.caught or {}) do if on then n = n + 1 end end + return n + end, + isCaught = function(dex, sp) return dex and dex.caught and dex.caught[sp] == true or false end, + nationalEnabled = function() return true end, + countCaught = function() return 0 end, +} + +local Flags = require("src.core.game3.scripting.flags") +local store = Flags.newStore() +package.loaded["src.core.game3.scripting.space"] = { store = store, getStore = function() return store end } +require("src.core.game3.link.family").activeVersion = function() return "emerald" end + +local TrainerCard = require("src.ui.game3.trainer_card") +local IDS = Flags.forVersion("emerald").IDS + +local function session() + local caught = {} + -- pokeemerald/src/pokedex.c:4392 + for n = 1, HOENN - 2 do caught[n] = true end + local winners = {} + for i = 1, 13 do winners[i] = { species = 0 } end + -- pokeemerald/src/contest_util.c:2386 + for i = 9, 13 do winners[i] = { species = 25 + i } end + return { + version = "emerald", name = "MAY", + hofDebutHours = 40, hofDebutMinutes = 12, hofDebutSeconds = 3, + dex = { caught = caught, seen = caught }, + contestWinners = winners, + } +end + +local function setSymbols(on) + -- pokeemerald/src/trainer_card.c:652 + for i = 0, 6 do + Flags.setFlag(store, nil, IDS.SYS_TOWER_SILVER + 2 * i, on) + Flags.setFlag(store, nil, IDS.SYS_TOWER_GOLD + 2 * i, on) + end +end + +print("[test] 1. HoF + Hoenn dex without Jirachi/Deoxys + 5 paintings + all gold symbols = 4 stars") +eq(IDS.SYS_TOWER_SILVER, 0x860 + 0x64, "FLAG_SYS_TOWER_SILVER is SYSTEM_FLAGS + 0x64") +eq(IDS.SYS_TOWER_GOLD, 0x860 + 0x65, "FLAG_SYS_TOWER_GOLD is SYSTEM_FLAGS + 0x65") +setSymbols(true) +local c = TrainerCard.rseCardData(session()) +check(c.hasAllPaintings, "five museum paintings set hasAllPaintings") +eq(c.stars, 4, "gold card") + +print("[test] 2. Each criterion is worth one star") +local s = session() +for i = 9, 13 do s.contestWinners[i].species = 0 end +s.contestWinners[9].species = 1 +eq(TrainerCard.rseCardData(s).stars, 3, "one painting is not enough") + +s = session() +s.dex.caught[200] = nil +eq(TrainerCard.rseCardData(s).stars, 3, "a missing Hoenn mon below Jirachi drops the dex star") + +s = session() +s.hofDebutHours, s.hofDebutMinutes, s.hofDebutSeconds = 0, 0, 0 +eq(TrainerCard.rseCardData(s).stars, 3, "no HoF debut drops the HoF star") + +Flags.setFlag(store, nil, IDS.SYS_TOWER_GOLD + 2 * 6, false) +eq(TrainerCard.rseCardData(session()).stars, 3, "a silver-only Pyramid symbol drops the frontier star") +setSymbols(false) +eq(TrainerCard.rseCardData(session()).stars, 3, "no symbols leaves HoF, dex and paintings") + +if failed > 0 then + print(string.format("[test] %d FAILED", failed)) + os.exit(1) +end +print("[test] all passed")